From 8d8d0fc9d26a6c9a1279bb800921a253e6f6d740 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 31 May 2023 20:34:30 +0200 Subject: [PATCH 001/857] Bump version to 2023.7.0dev0 (#93869) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0438e674dd..cb24a6a9d45 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.6 + HA_SHORT_VERSION: 2023.7 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 212000aa26d..5d4b0c2b515 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 6 +MINOR_VERSION: Final = 7 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 9bb55d3d29d..93edb0076e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0.dev0" +version = "2023.7.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a1e9cf1c24446f75038f0a4a0f583ca3337ed8e1 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 31 May 2023 16:55:57 -0400 Subject: [PATCH 002/857] Add Dremel 3D Printer integration (#85969) * Add Dremel 3D Printer integration * remove validators requirement * ruff * uno mas * uno mas * uno mas * uno mas --------- Co-authored-by: Franck Nijhof Co-authored-by: Tom Harris --- CODEOWNERS | 2 + .../components/dremel_3d_printer/__init__.py | 41 +++ .../dremel_3d_printer/config_flow.py | 58 ++++ .../components/dremel_3d_printer/const.py | 11 + .../dremel_3d_printer/coordinator.py | 36 +++ .../components/dremel_3d_printer/entity.py | 41 +++ .../dremel_3d_printer/manifest.json | 10 + .../components/dremel_3d_printer/sensor.py | 284 ++++++++++++++++++ .../components/dremel_3d_printer/strings.json | 18 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/dremel_3d_printer/__init__.py | 1 + .../components/dremel_3d_printer/conftest.py | 58 ++++ .../dremel_3d_printer/fixtures/command_1.json | 12 + .../dremel_3d_printer/fixtures/command_2.json | 22 ++ .../fixtures/get_home_message.json | 26 ++ .../dremel_3d_printer/test_config_flow.py | 87 ++++++ .../components/dremel_3d_printer/test_init.py | 80 +++++ .../dremel_3d_printer/test_sensor.py | 110 +++++++ 21 files changed, 910 insertions(+) create mode 100644 homeassistant/components/dremel_3d_printer/__init__.py create mode 100644 homeassistant/components/dremel_3d_printer/config_flow.py create mode 100644 homeassistant/components/dremel_3d_printer/const.py create mode 100644 homeassistant/components/dremel_3d_printer/coordinator.py create mode 100644 homeassistant/components/dremel_3d_printer/entity.py create mode 100644 homeassistant/components/dremel_3d_printer/manifest.json create mode 100644 homeassistant/components/dremel_3d_printer/sensor.py create mode 100644 homeassistant/components/dremel_3d_printer/strings.json create mode 100644 tests/components/dremel_3d_printer/__init__.py create mode 100644 tests/components/dremel_3d_printer/conftest.py create mode 100644 tests/components/dremel_3d_printer/fixtures/command_1.json create mode 100644 tests/components/dremel_3d_printer/fixtures/command_2.json create mode 100644 tests/components/dremel_3d_printer/fixtures/get_home_message.json create mode 100644 tests/components/dremel_3d_printer/test_config_flow.py create mode 100644 tests/components/dremel_3d_printer/test_init.py create mode 100644 tests/components/dremel_3d_printer/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 44b7e4bce36..cd7ae315b09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -289,6 +289,8 @@ build.json @home-assistant/supervisor /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket /homeassistant/components/dormakaba_dkey/ @emontnemery /tests/components/dormakaba_dkey/ @emontnemery +/homeassistant/components/dremel_3d_printer/ @tkdrob +/tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py new file mode 100644 index 00000000000..4daafea5db8 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -0,0 +1,41 @@ +"""The Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" +from __future__ import annotations + +from dremel3dpy import Dremel3DPrinter +from requests.exceptions import ConnectTimeout, HTTPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import Dremel3DPrinterDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Dremel 3D Printer from a config entry.""" + try: + api = await hass.async_add_executor_job( + Dremel3DPrinter, config_entry.data[CONF_HOST] + ) + + except (ConnectTimeout, HTTPError) as ex: + raise ConfigEntryNotReady( + f"Unable to connect to Dremel 3D Printer: {ex}" + ) from ex + + coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Dremel config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py new file mode 100644 index 00000000000..6fa4d2e0a5b --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Dremel 3D Printer (3D20, 3D40, 3D45).""" +from __future__ import annotations + +from json.decoder import JSONDecodeError +from typing import Any + +from dremel3dpy import Dremel3DPrinter +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, LOGGER + + +def _schema_with_defaults(host: str = "") -> vol.Schema: + return vol.Schema({vol.Required(CONF_HOST, default=host): cv.string}) + + +class Dremel3DPrinterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Dremel 3D Printer.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=_schema_with_defaults(), + ) + host = user_input[CONF_HOST] + + try: + api = await self.hass.async_add_executor_job(Dremel3DPrinter, host) + except (ConnectTimeout, HTTPError, JSONDecodeError): + errors = {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + LOGGER.exception("An unknown error has occurred") + errors = {"base": "unknown"} + + if errors: + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=_schema_with_defaults(host=host), + ) + + await self.async_set_unique_id(api.get_serial_number()) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=api.get_title(), data={CONF_HOST: host}) diff --git a/homeassistant/components/dremel_3d_printer/const.py b/homeassistant/components/dremel_3d_printer/const.py new file mode 100644 index 00000000000..611b3b86306 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/const.py @@ -0,0 +1,11 @@ +"""Constants for the Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" +from __future__ import annotations + +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "dremel_3d_printer" + +ATTR_EXTRUDER = "extruder" +ATTR_PLATFORM = "platform" diff --git a/homeassistant/components/dremel_3d_printer/coordinator.py b/homeassistant/components/dremel_3d_printer/coordinator.py new file mode 100644 index 00000000000..81e0053fd77 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/coordinator.py @@ -0,0 +1,36 @@ +"""Data update coordinator for the Dremel 3D Printer integration.""" + +from datetime import timedelta + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class Dremel3DPrinterDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Dremel 3D Printer data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, api: Dremel3DPrinter) -> None: + """Initialize Dremel 3D Printer data update coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.api = api + + async def _async_update_data(self) -> None: + """Update data via APIs.""" + try: + await self.hass.async_add_executor_job(self.api.refresh) + except RuntimeError as ex: + raise UpdateFailed( + f"Unable to refresh printer information: Printer offline: {ex}" + ) from ex diff --git a/homeassistant/components/dremel_3d_printer/entity.py b/homeassistant/components/dremel_3d_printer/entity.py new file mode 100644 index 00000000000..392869a138b --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/entity.py @@ -0,0 +1,41 @@ +"""Entity representing a Dremel 3D Printer.""" + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import Dremel3DPrinterDataUpdateCoordinator + + +class Dremel3DPrinterEntity(CoordinatorEntity[Dremel3DPrinterDataUpdateCoordinator]): + """Defines a Dremel 3D Printer device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: Dremel3DPrinterDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the base device entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Dremel printer.""" + return DeviceInfo( + identifiers={(DOMAIN, self._api.get_serial_number())}, + manufacturer=self._api.get_manufacturer(), + model=self._api.get_model(), + name=self._api.get_title(), + sw_version=self._api.get_firmware_version(), + ) + + @property + def _api(self) -> Dremel3DPrinter: + """Return to api from coordinator.""" + return self.coordinator.api diff --git a/homeassistant/components/dremel_3d_printer/manifest.json b/homeassistant/components/dremel_3d_printer/manifest.json new file mode 100644 index 00000000000..12d4e4003c4 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "dremel_3d_printer", + "name": "Dremel 3D Printer", + "codeowners": ["@tkdrob"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/dremel_3d_printer", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["dremel3dpy==2.1.1"] +} diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py new file mode 100644 index 00000000000..00002e44c4e --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -0,0 +1,284 @@ +"""Support for monitoring Dremel 3D Printer sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfInformation, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow +from homeassistant.util.variance import ignore_variance + +from .const import ATTR_EXTRUDER, ATTR_PLATFORM, DOMAIN +from .entity import Dremel3DPrinterEntity + + +@dataclass +class Dremel3DPrinterSensorEntityMixin: + """Mixin for Dremel 3D Printer sensor.""" + + value_fn: Callable[[Dremel3DPrinter, str], StateType | datetime] + + +@dataclass +class Dremel3DPrinterSensorEntityDescription( + SensorEntityDescription, Dremel3DPrinterSensorEntityMixin +): + """Describes a Dremel 3D Printer sensor.""" + + available_fn: Callable[[Dremel3DPrinter, str], bool] = lambda api, _: True + + +SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( + Dremel3DPrinterSensorEntityDescription( + key="job_phase", + name="Job phase", + icon="mdi:printer-3d", + value_fn=lambda api, _: api.get_printing_status(), + ), + Dremel3DPrinterSensorEntityDescription( + key="remaining_time", + name="Remaining time", + device_class=SensorDeviceClass.TIMESTAMP, + available_fn=lambda api, key: api.get_job_status()[key] > 0, + value_fn=ignore_variance( + lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), + timedelta(minutes=2), + ), + ), + Dremel3DPrinterSensorEntityDescription( + key="progress", + name="Progress", + icon="mdi:printer-3d-nozzle", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_printing_progress(), + ), + Dremel3DPrinterSensorEntityDescription( + key="chamber", + name="Chamber", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_temperature_type(key), + ), + Dremel3DPrinterSensorEntityDescription( + key="platform_temperature", + name="Platform temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_type(ATTR_PLATFORM), + ), + Dremel3DPrinterSensorEntityDescription( + key="target_platform_temperature", + name="Target platform temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_PLATFORM)[ + "target_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="max_platform_temperature", + name="Max platform temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_PLATFORM)[ + "max_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key=ATTR_EXTRUDER, + name="Extruder", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_temperature_type(key), + ), + Dremel3DPrinterSensorEntityDescription( + key="target_extruder_temperature", + name="Target extruder temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_EXTRUDER)[ + "target_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="max_extruder_temperature", + name="Max extruder temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_EXTRUDER)[ + "max_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="network_build", + name="Network build", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="filament", + name="Filament", + icon="mdi:printer-3d-nozzle", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="elapsed_time", + name="Elapsed time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + available_fn=lambda api, _: api.get_printing_status() == "building", + value_fn=ignore_variance( + lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), + timedelta(minutes=2), + ), + ), + Dremel3DPrinterSensorEntityDescription( + key="estimated_total_time", + name="Estimated total time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + available_fn=lambda api, key: api.get_job_status()[key] > 0, + value_fn=ignore_variance( + lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), + timedelta(minutes=2), + ), + ), + Dremel3DPrinterSensorEntityDescription( + key="job_status", + name="Job status", + icon="mdi:printer-3d", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="job_name", + name="Job name", + icon="mdi:file", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_job_name(), + ), + Dremel3DPrinterSensorEntityDescription( + key="api_version", + name="API version", + icon="mdi:api", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="host", + name="Host", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="connection_type", + name="Connection type", + icon="mdi:network", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="available_storage", + name="Available storage", + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key] * 100, + ), + Dremel3DPrinterSensorEntityDescription( + key="hours_used", + name="Hours used", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the available Dremel 3D Printer sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + Dremel3DPrinterSensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class Dremel3DPrinterSensor(Dremel3DPrinterEntity, SensorEntity): + """Representation of an Dremel 3D Printer sensor.""" + + entity_description: Dremel3DPrinterSensorEntityDescription + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return super().available and self.entity_description.available_fn( + self._api, self.entity_description.key + ) + + @property + def native_value(self) -> StateType | datetime: + """Return the sensor state.""" + return self.entity_description.value_fn(self._api, self.entity_description.key) diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json new file mode 100644 index 00000000000..64b95cbfd05 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ca81e7befaf..efb821a3b2b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -101,6 +101,7 @@ FLOWS = { "dnsip", "doorbird", "dormakaba_dkey", + "dremel_3d_printer", "dsmr", "dsmr_reader", "dunehd", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6418e93aa03..37f7c2e6071 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1172,6 +1172,12 @@ "integration_type": "hub", "config_flow": false }, + "dremel_3d_printer": { + "name": "Dremel 3D Printer", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "dsmr": { "name": "DSMR Slimme Meter", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index dd6e25400a3..f8b232073f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -616,6 +616,9 @@ doorbirdpy==2.1.0 # homeassistant.components.dovado dovado==0.4.1 +# homeassistant.components.dremel_3d_printer +dremel3dpy==2.1.1 + # homeassistant.components.dsmr dsmr_parser==0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fe6051f676..c21d02028de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,6 +493,9 @@ discovery30303==0.2.1 # homeassistant.components.doorbird doorbirdpy==2.1.0 +# homeassistant.components.dremel_3d_printer +dremel3dpy==2.1.1 + # homeassistant.components.dsmr dsmr_parser==0.33 diff --git a/tests/components/dremel_3d_printer/__init__.py b/tests/components/dremel_3d_printer/__init__.py new file mode 100644 index 00000000000..90da6ee929b --- /dev/null +++ b/tests/components/dremel_3d_printer/__init__.py @@ -0,0 +1 @@ +"""Tests for the Dremel 3D Printer integration.""" diff --git a/tests/components/dremel_3d_printer/conftest.py b/tests/components/dremel_3d_printer/conftest.py new file mode 100644 index 00000000000..8df59a2e64a --- /dev/null +++ b/tests/components/dremel_3d_printer/conftest.py @@ -0,0 +1,58 @@ +"""Configure tests for the Dremel 3D Printer integration.""" +from http import HTTPStatus +from unittest.mock import patch + +import pytest +import requests_mock + +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +HOST = "1.2.3.4" +CONF_DATA = {CONF_HOST: HOST} + + +def create_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create fixture for adding config entry in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA, unique_id="123456789") + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add config entry in Home Assistant.""" + return create_entry(hass) + + +@pytest.fixture +def connection() -> None: + """Mock Dremel 3D Printer connection.""" + mock = requests_mock.Mocker() + mock.post( + f"http://{HOST}:80/command", + response_list=[ + {"text": load_fixture("dremel_3d_printer/command_1.json")}, + {"text": load_fixture("dremel_3d_printer/command_2.json")}, + {"text": load_fixture("dremel_3d_printer/command_1.json")}, + {"text": load_fixture("dremel_3d_printer/command_2.json")}, + ], + ) + + mock.post( + f"https://{HOST}:11134/getHomeMessage", + text=load_fixture("dremel_3d_printer/get_home_message.json"), + status_code=HTTPStatus.OK, + ) + mock.start() + + +def patch_async_setup_entry(): + """Patch the async entry setup of Dremel 3D Printer.""" + return patch( + "homeassistant.components.dremel_3d_printer.async_setup_entry", + return_value=True, + ) diff --git a/tests/components/dremel_3d_printer/fixtures/command_1.json b/tests/components/dremel_3d_printer/fixtures/command_1.json new file mode 100644 index 00000000000..4e61b40ea46 --- /dev/null +++ b/tests/components/dremel_3d_printer/fixtures/command_1.json @@ -0,0 +1,12 @@ +{ + "SN": "123456789", + "api_version": "1.0.2-alpha", + "error_code": 200, + "ethernet_connected": 1, + "ethernet_ip": "1.2.3.4", + "firmware_version": "v3.0_R02.12.10", + "machine_type": "DREMEL 3D45 IDEA BUILDER", + "message": "success", + "wifi_connected": 1, + "wifi_ip": "1.2.3.5" +} diff --git a/tests/components/dremel_3d_printer/fixtures/command_2.json b/tests/components/dremel_3d_printer/fixtures/command_2.json new file mode 100644 index 00000000000..fc73b10cb57 --- /dev/null +++ b/tests/components/dremel_3d_printer/fixtures/command_2.json @@ -0,0 +1,22 @@ +{ + "buildPlate_target_temperature": 60, + "chamber_temperature": 27, + "door_open": 0, + "elaspedtime": 0, + "error_code": 200, + "extruder_target_temperature": 230, + "fanSpeed": 0, + "filament_type ": "ECO-ABS", + "firmware_version": "v3.0_R02.12.10", + "jobname": "D32_Imperial_Credit.gcode", + "jobstatus": "building", + "layer": 0, + "message": "success", + "networkBuild": 1, + "platform_temperature": 60, + "progress": 13.9, + "remaining": 3736, + "status": "busy", + "temperature": 230, + "totalTime": 4340 +} diff --git a/tests/components/dremel_3d_printer/fixtures/get_home_message.json b/tests/components/dremel_3d_printer/fixtures/get_home_message.json new file mode 100644 index 00000000000..1a79210c35d --- /dev/null +++ b/tests/components/dremel_3d_printer/fixtures/get_home_message.json @@ -0,0 +1,26 @@ +{ + "BedTemp": 60, + "BedTempTarget": 60, + "ErrorCode": 200, + "FilamentType": 2, + "FirwareVersion": "v3.0_R02.12.10", + "Message": "success", + "NozzleTemp": 230, + "NozzleTempTarget": 230, + "PreheatBed": 0, + "PreheatNozzle": 0, + "PrinterBedMessage": "Bed 0-100 ℃", + "PrinterCamera": "http://1.2.3.4:10123/?action=stream", + "PrinterFiles": 10, + "PrinterMicrons": "50-300 microns", + "PrinterName": "DREMEL DIGILAB 3D45", + "PrinterNozzleMessage": "Nozzle 0-280 ℃", + "PrinterStatus": "printing", + "PrintererAvailabelStorage": 87, + "PrintingFileName": "D32_Imperial_Credit.gcode", + "PrintingFilePic": "/tmp/mnt/dev/mmcblk0p3/modelFromDevice/pic/D32_Imperial_Credit_gcode.bmp", + "PrintingProgress": 13.9, + "RemainTime": 3736, + "SerialNumber": "123456789", + "UsageCounter": "7" +} diff --git a/tests/components/dremel_3d_printer/test_config_flow.py b/tests/components/dremel_3d_printer/test_config_flow.py new file mode 100644 index 00000000000..8161662a14a --- /dev/null +++ b/tests/components/dremel_3d_printer/test_config_flow.py @@ -0,0 +1,87 @@ +"""Test Dremel 3D Printer config flow.""" +from unittest.mock import patch + +from requests.exceptions import ConnectTimeout + +from homeassistant import data_entry_flow +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant + +from .conftest import CONF_DATA, patch_async_setup_entry + +from tests.common import MockConfigEntry + +MOCK = "homeassistant.components.dremel_3d_printer.config_flow.Dremel3DPrinter" + + +async def test_full_user_flow_implementation(hass: HomeAssistant, connection) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "DREMEL 3D45" + assert result["data"] == CONF_DATA + + +async def test_already_configured( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test we abort if the device is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_cannot_connect(hass: HomeAssistant, connection) -> None: + """Test we show user form on connection error.""" + with patch(MOCK, side_effect=ConnectTimeout): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == CONF_DATA + + +async def test_unknown_error(hass: HomeAssistant, connection) -> None: + """Test we show user form on unknown error.""" + with patch(MOCK, side_effect=Exception): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "DREMEL 3D45" + assert result["data"] == CONF_DATA diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py new file mode 100644 index 00000000000..5d97c89b9cd --- /dev/null +++ b/tests/components/dremel_3d_printer/test_init.py @@ -0,0 +1,80 @@ +"""Test Dremel 3D Printer integration.""" +from datetime import timedelta +from unittest.mock import patch + +from requests.exceptions import ConnectTimeout + +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_setup( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test load and unload.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + assert config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + with patch( + "homeassistant.components.dremel_3d_printer.Dremel3DPrinter", + side_effect=ConnectTimeout, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_update_failed( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test coordinator throws UpdateFailed after failed update.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + assert config_entry.state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.dremel_3d_printer.Dremel3DPrinter.refresh", + side_effect=RuntimeError, + ) as updater: + next_update = dt_util.utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + updater.assert_called_once() + state = hass.states.get("sensor.dremel_3d45_job_phase") + assert state.state == STATE_UNAVAILABLE + + +async def test_device_info( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test device info.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, config_entry.unique_id)}) + + assert device.manufacturer == "Dremel" + assert device.model == "3D45" + assert device.name == "DREMEL 3D45" + assert device.sw_version == "v3.0_R02.12.10" diff --git a/tests/components/dremel_3d_printer/test_sensor.py b/tests/components/dremel_3d_printer/test_sensor.py new file mode 100644 index 00000000000..b38a5feff36 --- /dev/null +++ b/tests/components/dremel_3d_printer/test_sensor.py @@ -0,0 +1,110 @@ +"""Sensor tests for the Dremel 3D Printer integration.""" +from datetime import datetime +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfInformation, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import UTC + +from tests.common import MockConfigEntry + + +async def test_sensors( + hass: HomeAssistant, + connection, + config_entry: MockConfigEntry, + entity_registry_enabled_by_default: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we get sensor data.""" + freezer.move_to(datetime(2023, 5, 31, 13, 30, tzinfo=UTC)) + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + state = hass.states.get("sensor.dremel_3d45_job_phase") + assert state.state == "building" + state = hass.states.get("sensor.dremel_3d45_remaining_time") + assert state.state == "2023-05-31T12:27:44+00:00" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.dremel_3d45_progress") + assert state.state == "13.9" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_chamber") + assert state.state == "27" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_platform_temperature") + assert state.state == "60" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_target_platform_temperature") + assert state.state == "60" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_max_platform_temperature") + assert state.state == "100" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_extruder") + assert state.state == "230" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_target_extruder_temperature") + assert state.state == "230" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_max_extruder_temperature") + assert state.state == "280" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_network_build") + assert state.state == "1" + state = hass.states.get("sensor.dremel_3d45_filament") + assert state.state == "ECO-ABS" + state = hass.states.get("sensor.dremel_3d45_elapsed_time") + assert state.state == "2023-05-31T13:30:00+00:00" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.dremel_3d45_estimated_total_time") + assert state.state == "2023-05-31T12:17:40+00:00" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.dremel_3d45_job_status") + assert state.state == "building" + state = hass.states.get("sensor.dremel_3d45_job_name") + assert state.state == "D32_Imperial_Credit" + state = hass.states.get("sensor.dremel_3d45_api_version") + assert state.state == "1.0.2-alpha" + state = hass.states.get("sensor.dremel_3d45_host") + assert state.state == "1.2.3.4" + state = hass.states.get("sensor.dremel_3d45_connection_type") + assert state.state == "eth0" + state = hass.states.get("sensor.dremel_3d45_available_storage") + assert state.state == "8700" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfInformation.MEGABYTES + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_SIZE + state = hass.states.get("sensor.dremel_3d45_hours_used") + assert state.state == "7" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTime.HOURS + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION From cd330a2740731e44843cf1ff919b78625ff9583a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 31 May 2023 16:56:12 -0400 Subject: [PATCH 003/857] Pass device ID to conversation input (#93867) --- .../components/assist_pipeline/__init__.py | 2 ++ .../components/assist_pipeline/pipeline.py | 9 +++++++-- .../components/conversation/__init__.py | 2 ++ homeassistant/components/conversation/agent.py | 1 + homeassistant/components/esphome/__init__.py | 3 ++- .../components/esphome/voice_assistant.py | 2 ++ homeassistant/components/voip/voip.py | 1 + tests/components/conversation/test_init.py | 1 + tests/components/esphome/test_voice_assistant.py | 16 ++++++++++------ tests/components/voip/test_voip.py | 4 +++- 10 files changed, 31 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 9e460464cb9..55b192a730a 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -57,6 +57,7 @@ async def async_pipeline_from_audio_stream( pipeline_id: str | None = None, conversation_id: str | None = None, tts_audio_output: str | None = None, + device_id: str | None = None, ) -> None: """Create an audio pipeline from an audio stream. @@ -64,6 +65,7 @@ async def async_pipeline_from_audio_stream( """ pipeline_input = PipelineInput( conversation_id=conversation_id, + device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, run=PipelineRun( diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 12764c04f04..031053e8a45 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -499,7 +499,7 @@ class PipelineRun: self.intent_agent = agent_info.id async def recognize_intent( - self, intent_input: str, conversation_id: str | None + self, intent_input: str, conversation_id: str | None, device_id: str | None ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" if self.intent_agent is None: @@ -521,6 +521,7 @@ class PipelineRun: hass=self.hass, text=intent_input, conversation_id=conversation_id, + device_id=device_id, context=self.context, language=self.pipeline.conversation_language, agent_id=self.intent_agent, @@ -655,6 +656,8 @@ class PipelineInput: conversation_id: str | None = None + device_id: str | None = None + async def execute(self) -> None: """Run pipeline.""" self.run.start() @@ -678,7 +681,9 @@ class PipelineInput: if current_stage == PipelineStage.INTENT: assert intent_input is not None tts_input = await self.run.recognize_intent( - intent_input, self.conversation_id + intent_input, + self.conversation_id, + self.device_id, ) current_stage = PipelineStage.TTS diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f156acfd568..ea1eb041fe5 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -362,6 +362,7 @@ async def async_converse( context: core.Context, language: str | None = None, agent_id: str | None = None, + device_id: str | None = None, ) -> ConversationResult: """Process text and get intent.""" agent = await _get_agent_manager(hass).async_get_agent(agent_id) @@ -375,6 +376,7 @@ async def async_converse( text=text, context=context, conversation_id=conversation_id, + device_id=device_id, language=language, ) ) diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 162338a6ff0..99b9c9392d8 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -16,6 +16,7 @@ class ConversationInput: text: str context: Context conversation_id: str | None + device_id: str | None language: str diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index f95763d3a6c..297ce9b7882 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -143,7 +143,7 @@ async def async_setup_entry( # noqa: C901 port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] noise_psk = entry.data.get(CONF_NOISE_PSK) - device_id: str | None = None + device_id: str = None # type: ignore[assignment] zeroconf_instance = await zeroconf.async_get_instance(hass) @@ -316,6 +316,7 @@ async def async_setup_entry( # noqa: C901 hass.async_create_background_task( voice_assistant_udp_server.run_pipeline( + device_id=device_id, conversation_id=conversation_id or None, use_vad=use_vad, ), diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index efb4162ae1a..4f6131f449b 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -293,6 +293,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): async def run_pipeline( self, + device_id: str, conversation_id: str | None, use_vad: bool = False, pipeline_timeout: float = 30.0, @@ -331,6 +332,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.hass, DOMAIN, self.device_info.mac_address ), conversation_id=conversation_id, + device_id=device_id, tts_audio_output=tts_audio_output, ) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 80e24d6eb83..d7e261508fd 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -251,6 +251,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self.hass, DOMAIN, self.voip_device.voip_id ), conversation_id=self._conversation_id, + device_id=self.voip_device.device_id, tts_audio_output="raw", ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 54e7020fdda..e0243b1841c 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1371,6 +1371,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non text="open the front door", context=Context(), conversation_id=None, + device_id=None, language=hass.config.language, ) ) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index f8c2d62d095..08750d06dd0 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -68,7 +68,9 @@ async def test_pipeline_events( ) -> None: """Test that the pipeline function is called.""" - async def async_pipeline_from_audio_stream(*args, **kwargs): + async def async_pipeline_from_audio_stream(*args, device_id, **kwargs): + assert device_id == "mock-device-id" + event_callback = kwargs["event_callback"] # Fake events @@ -121,7 +123,9 @@ async def test_pipeline_events( ): voice_assistant_udp_server_v1.transport = Mock() - await voice_assistant_udp_server_v1.run_pipeline(conversation_id=None) + await voice_assistant_udp_server_v1.run_pipeline( + device_id="mock-device-id", conversation_id=None + ) async def test_udp_server( @@ -380,7 +384,7 @@ async def test_speech_detection( voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) await voice_assistant_udp_server_v2.run_pipeline( - conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 ) @@ -412,7 +416,7 @@ async def test_no_speech( voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) await voice_assistant_udp_server_v2.run_pipeline( - conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 ) @@ -452,7 +456,7 @@ async def test_speech_timeout( voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * (_ONE_SECOND * 2))) await voice_assistant_udp_server_v2.run_pipeline( - conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 ) @@ -467,7 +471,7 @@ async def test_cancelled( voice_assistant_udp_server_v2.queue.put_nowait(b"") await voice_assistant_udp_server_v2.run_pipeline( - conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 ) # No events should be sent if cancelled while waiting for speech diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index bd9a3587a9a..8fc98f31167 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -31,7 +31,9 @@ async def test_pipeline( # Used to test that audio queue is cleared before pipeline starts bad_chunk = bytes([1, 2, 3, 4]) - async def async_pipeline_from_audio_stream(*args, **kwargs): + async def async_pipeline_from_audio_stream(*args, device_id, **kwargs): + assert device_id == voip_device.device_id + stt_stream = kwargs["stt_stream"] event_callback = kwargs["event_callback"] async for _chunk in stt_stream: From 47a2d5b472b56b81dfaab2f2f3606ef318a2b0bb Mon Sep 17 00:00:00 2001 From: Jeff Grieger Date: Wed, 31 May 2023 18:09:35 -0400 Subject: [PATCH 004/857] Add zwave_js speed config for additional GE/Jasco fan controllers (#92371) * Add zwave_js speed config for additional GE/Jasco fan controllers Add speed info for Honeywell(GE/Jasco) 39358 In-Wall Fan Speed Control and Enbrighten(GE/Jasco) 55258 In-Wall Fan Speed Control. * Add test for Honeywell 39358 In-Wall Fan Speed Control --- .../components/zwave_js/discovery.py | 8 +- tests/components/zwave_js/conftest.py | 14 + .../fixtures/fan_honeywell_39358_state.json | 10511 ++++++++++++++++ tests/components/zwave_js/test_fan.py | 69 + 4 files changed, 10600 insertions(+), 2 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/fan_honeywell_39358_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 62fc665c72e..685319de343 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -256,11 +256,15 @@ DISCOVERY_SCHEMAS = [ # Honeywell 39358 In-Wall Fan Control using switch multilevel CC ZWaveDiscoverySchema( platform=Platform.FAN, + hint="has_fan_value_mapping", manufacturer_id={0x0039}, product_id={0x3131}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], + data_template=FixedFanValueMappingDataTemplate( + FanValueMapping(speeds=[(1, 32), (33, 66), (67, 99)]), + ), ), # GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002 ZWaveDiscoverySchema( @@ -274,12 +278,12 @@ DISCOVERY_SCHEMAS = [ FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]), ), ), - # GE/Jasco - In-Wall Smart Fan Control - 14287 / ZW4002 + # GE/Jasco - In-Wall Smart Fan Control - 14287 / 55258 / ZW4002 ZWaveDiscoverySchema( platform=Platform.FAN, hint="has_fan_value_mapping", manufacturer_id={0x0063}, - product_id={0x3131}, + product_id={0x3131, 0x3337}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=FixedFanValueMappingDataTemplate( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 860e5742c80..bbf422c8b71 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -433,6 +433,12 @@ def leviton_zw4sf_state_fixture(): return json.loads(load_fixture("zwave_js/leviton_zw4sf_state.json")) +@pytest.fixture(name="fan_honeywell_39358_state", scope="session") +def fan_honeywell_39358_state_fixture(): + """Load the fan node state fixture data.""" + return json.loads(load_fixture("zwave_js/fan_honeywell_39358_state.json")) + + @pytest.fixture(name="gdc_zw062_state", scope="session") def motorized_barrier_cover_state_fixture(): """Load the motorized barrier cover node state fixture data.""" @@ -943,6 +949,14 @@ def leviton_zw4sf_fixture(client, leviton_zw4sf_state): return node +@pytest.fixture(name="fan_honeywell_39358") +def fan_honeywell_39358_fixture(client, fan_honeywell_39358_state): + """Mock a fan node.""" + node = Node(client, copy.deepcopy(fan_honeywell_39358_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="null_name_check") def null_name_check_fixture(client, null_name_check_state): """Mock a node with no name.""" diff --git a/tests/components/zwave_js/fixtures/fan_honeywell_39358_state.json b/tests/components/zwave_js/fixtures/fan_honeywell_39358_state.json new file mode 100644 index 00000000000..063eae25b0f --- /dev/null +++ b/tests/components/zwave_js/fixtures/fan_honeywell_39358_state.json @@ -0,0 +1,10511 @@ +{ + "nodeId": 61, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 57, + "productId": 12593, + "productType": 18756, + "firmwareVersion": "5.24", + "zwavePlusVersion": 1, + "name": "honeywell_in_wall_smart_fan_control", + "deviceConfig": { + "filename": "/srv/zwavejs2mqtt/node_modules/@zwave-js/config/config/devices/0x0039/39358_39465_zw4002.json", + "isEmbedded": true, + "manufacturer": "Honeywell", + "manufacturerId": 57, + "label": "39358 / 39465 / ZW4002", + "description": "In-Wall Fan Speed Control, 500S", + "devices": [ + { + "productType": 18756, + "productId": 12593 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "removeCCs": {}, + "treatBasicSetAsEvent": true + }, + "metadata": { + "inclusion": "1. Follow the instructions for your Z-Wave certified controller to include a device to the Z-Wave network.\n2. Once the controller is ready to include your device, press and release the top or bottom of the smart fan control switch (rocker) to include it in the network.\n3. Once your controller has confirmed the device has been included, refresh the Z-Wave network to optimize performance", + "exclusion": "1. Follow the instructions for your Z-Wave certified controller to exclude a device from the Z-Wave network. \n2. Once the controller is ready to Exclude your device, press and release the top or bottom of the wireless smart switch (rocker) to exclude it from the network", + "reset": "1. Quickly press ON (Top) button three (3) times then immediately press the OFF (Bottom) button three (3) times. The LED will flash ON/OFF 5 times when completed successfully.\nNote: This should only be used in the event your network\u2019s primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2725/39358-HQSG-v1-para.pdf" + } + }, + "label": "39358 / 39465 / ZW4002", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 61, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 8, + "label": "Fan Switch" + }, + "mandatorySupportedCCs": [32, 38, 133, 89, 114, 115, 134, 94], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 32, + "name": "Basic", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 86, + "name": "CRC-16 Encapsulation", + "version": 1, + "isSecure": false + }, + { + "id": 44, + "name": "Scene Actuator Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 2, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "event", + "propertyName": "event", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Event value", + "min": 0, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 1, + "propertyName": "level", + "propertyKeyName": "1", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (1)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 1, + "propertyName": "dimmingDuration", + "propertyKeyName": "1", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (1)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 2, + "propertyName": "level", + "propertyKeyName": "2", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (2)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 2, + "propertyName": "dimmingDuration", + "propertyKeyName": "2", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (2)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 3, + "propertyName": "level", + "propertyKeyName": "3", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (3)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 3, + "propertyName": "dimmingDuration", + "propertyKeyName": "3", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (3)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 4, + "propertyName": "level", + "propertyKeyName": "4", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (4)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 4, + "propertyName": "dimmingDuration", + "propertyKeyName": "4", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (4)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 5, + "propertyName": "level", + "propertyKeyName": "5", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (5)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 5, + "propertyName": "dimmingDuration", + "propertyKeyName": "5", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (5)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 6, + "propertyName": "level", + "propertyKeyName": "6", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (6)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 6, + "propertyName": "dimmingDuration", + "propertyKeyName": "6", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (6)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 7, + "propertyName": "level", + "propertyKeyName": "7", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (7)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 7, + "propertyName": "dimmingDuration", + "propertyKeyName": "7", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (7)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 8, + "propertyName": "level", + "propertyKeyName": "8", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (8)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 8, + "propertyName": "dimmingDuration", + "propertyKeyName": "8", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (8)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 9, + "propertyName": "level", + "propertyKeyName": "9", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (9)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 9, + "propertyName": "dimmingDuration", + "propertyKeyName": "9", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (9)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 10, + "propertyName": "level", + "propertyKeyName": "10", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (10)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 10, + "propertyName": "dimmingDuration", + "propertyKeyName": "10", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (10)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 11, + "propertyName": "level", + "propertyKeyName": "11", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (11)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 11, + "propertyName": "dimmingDuration", + "propertyKeyName": "11", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (11)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 12, + "propertyName": "level", + "propertyKeyName": "12", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (12)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 12, + "propertyName": "dimmingDuration", + "propertyKeyName": "12", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (12)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 13, + "propertyName": "level", + "propertyKeyName": "13", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (13)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 13, + "propertyName": "dimmingDuration", + "propertyKeyName": "13", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (13)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 14, + "propertyName": "level", + "propertyKeyName": "14", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (14)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 14, + "propertyName": "dimmingDuration", + "propertyKeyName": "14", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (14)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 15, + "propertyName": "level", + "propertyKeyName": "15", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (15)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 15, + "propertyName": "dimmingDuration", + "propertyKeyName": "15", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (15)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 16, + "propertyName": "level", + "propertyKeyName": "16", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (16)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 16, + "propertyName": "dimmingDuration", + "propertyKeyName": "16", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (16)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 17, + "propertyName": "level", + "propertyKeyName": "17", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (17)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 17, + "propertyName": "dimmingDuration", + "propertyKeyName": "17", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (17)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 18, + "propertyName": "level", + "propertyKeyName": "18", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (18)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 18, + "propertyName": "dimmingDuration", + "propertyKeyName": "18", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (18)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 19, + "propertyName": "level", + "propertyKeyName": "19", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (19)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 19, + "propertyName": "dimmingDuration", + "propertyKeyName": "19", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (19)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 20, + "propertyName": "level", + "propertyKeyName": "20", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (20)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 20, + "propertyName": "dimmingDuration", + "propertyKeyName": "20", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (20)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 21, + "propertyName": "level", + "propertyKeyName": "21", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (21)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 21, + "propertyName": "dimmingDuration", + "propertyKeyName": "21", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (21)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 22, + "propertyName": "level", + "propertyKeyName": "22", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (22)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 22, + "propertyName": "dimmingDuration", + "propertyKeyName": "22", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (22)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 23, + "propertyName": "level", + "propertyKeyName": "23", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (23)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 23, + "propertyName": "dimmingDuration", + "propertyKeyName": "23", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (23)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 24, + "propertyName": "level", + "propertyKeyName": "24", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (24)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 24, + "propertyName": "dimmingDuration", + "propertyKeyName": "24", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (24)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 25, + "propertyName": "level", + "propertyKeyName": "25", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (25)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 25, + "propertyName": "dimmingDuration", + "propertyKeyName": "25", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (25)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 26, + "propertyName": "level", + "propertyKeyName": "26", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (26)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 26, + "propertyName": "dimmingDuration", + "propertyKeyName": "26", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (26)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 27, + "propertyName": "level", + "propertyKeyName": "27", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (27)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 27, + "propertyName": "dimmingDuration", + "propertyKeyName": "27", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (27)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 28, + "propertyName": "level", + "propertyKeyName": "28", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (28)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 28, + "propertyName": "dimmingDuration", + "propertyKeyName": "28", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (28)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 29, + "propertyName": "level", + "propertyKeyName": "29", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (29)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 29, + "propertyName": "dimmingDuration", + "propertyKeyName": "29", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (29)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 30, + "propertyName": "level", + "propertyKeyName": "30", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (30)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 30, + "propertyName": "dimmingDuration", + "propertyKeyName": "30", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (30)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 31, + "propertyName": "level", + "propertyKeyName": "31", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (31)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 31, + "propertyName": "dimmingDuration", + "propertyKeyName": "31", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (31)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 32, + "propertyName": "level", + "propertyKeyName": "32", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (32)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 32, + "propertyName": "dimmingDuration", + "propertyKeyName": "32", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (32)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 33, + "propertyName": "level", + "propertyKeyName": "33", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (33)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 33, + "propertyName": "dimmingDuration", + "propertyKeyName": "33", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (33)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 34, + "propertyName": "level", + "propertyKeyName": "34", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (34)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 34, + "propertyName": "dimmingDuration", + "propertyKeyName": "34", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (34)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 35, + "propertyName": "level", + "propertyKeyName": "35", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (35)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 35, + "propertyName": "dimmingDuration", + "propertyKeyName": "35", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (35)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 36, + "propertyName": "level", + "propertyKeyName": "36", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (36)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 36, + "propertyName": "dimmingDuration", + "propertyKeyName": "36", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (36)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 37, + "propertyName": "level", + "propertyKeyName": "37", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (37)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 37, + "propertyName": "dimmingDuration", + "propertyKeyName": "37", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (37)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 38, + "propertyName": "level", + "propertyKeyName": "38", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (38)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 38, + "propertyName": "dimmingDuration", + "propertyKeyName": "38", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (38)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 39, + "propertyName": "level", + "propertyKeyName": "39", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (39)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 39, + "propertyName": "dimmingDuration", + "propertyKeyName": "39", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (39)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 40, + "propertyName": "level", + "propertyKeyName": "40", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (40)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 40, + "propertyName": "dimmingDuration", + "propertyKeyName": "40", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (40)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 41, + "propertyName": "level", + "propertyKeyName": "41", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (41)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 41, + "propertyName": "dimmingDuration", + "propertyKeyName": "41", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (41)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 42, + "propertyName": "level", + "propertyKeyName": "42", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (42)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 42, + "propertyName": "dimmingDuration", + "propertyKeyName": "42", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (42)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 43, + "propertyName": "level", + "propertyKeyName": "43", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (43)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 43, + "propertyName": "dimmingDuration", + "propertyKeyName": "43", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (43)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 44, + "propertyName": "level", + "propertyKeyName": "44", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (44)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 44, + "propertyName": "dimmingDuration", + "propertyKeyName": "44", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (44)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 45, + "propertyName": "level", + "propertyKeyName": "45", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (45)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 45, + "propertyName": "dimmingDuration", + "propertyKeyName": "45", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (45)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 46, + "propertyName": "level", + "propertyKeyName": "46", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (46)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 46, + "propertyName": "dimmingDuration", + "propertyKeyName": "46", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (46)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 47, + "propertyName": "level", + "propertyKeyName": "47", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (47)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 47, + "propertyName": "dimmingDuration", + "propertyKeyName": "47", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (47)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 48, + "propertyName": "level", + "propertyKeyName": "48", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (48)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 48, + "propertyName": "dimmingDuration", + "propertyKeyName": "48", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (48)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 49, + "propertyName": "level", + "propertyKeyName": "49", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (49)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 49, + "propertyName": "dimmingDuration", + "propertyKeyName": "49", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (49)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 50, + "propertyName": "level", + "propertyKeyName": "50", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (50)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 50, + "propertyName": "dimmingDuration", + "propertyKeyName": "50", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (50)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 51, + "propertyName": "level", + "propertyKeyName": "51", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (51)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 51, + "propertyName": "dimmingDuration", + "propertyKeyName": "51", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (51)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 52, + "propertyName": "level", + "propertyKeyName": "52", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (52)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 52, + "propertyName": "dimmingDuration", + "propertyKeyName": "52", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (52)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 53, + "propertyName": "level", + "propertyKeyName": "53", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (53)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 53, + "propertyName": "dimmingDuration", + "propertyKeyName": "53", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (53)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 54, + "propertyName": "level", + "propertyKeyName": "54", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (54)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 54, + "propertyName": "dimmingDuration", + "propertyKeyName": "54", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (54)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 55, + "propertyName": "level", + "propertyKeyName": "55", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (55)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 55, + "propertyName": "dimmingDuration", + "propertyKeyName": "55", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (55)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 56, + "propertyName": "level", + "propertyKeyName": "56", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (56)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 56, + "propertyName": "dimmingDuration", + "propertyKeyName": "56", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (56)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 57, + "propertyName": "level", + "propertyKeyName": "57", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (57)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 57, + "propertyName": "dimmingDuration", + "propertyKeyName": "57", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (57)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 58, + "propertyName": "level", + "propertyKeyName": "58", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (58)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 58, + "propertyName": "dimmingDuration", + "propertyKeyName": "58", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (58)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 59, + "propertyName": "level", + "propertyKeyName": "59", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (59)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 59, + "propertyName": "dimmingDuration", + "propertyKeyName": "59", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (59)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 60, + "propertyName": "level", + "propertyKeyName": "60", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (60)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 60, + "propertyName": "dimmingDuration", + "propertyKeyName": "60", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (60)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 61, + "propertyName": "level", + "propertyKeyName": "61", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (61)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 61, + "propertyName": "dimmingDuration", + "propertyKeyName": "61", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (61)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 62, + "propertyName": "level", + "propertyKeyName": "62", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (62)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 62, + "propertyName": "dimmingDuration", + "propertyKeyName": "62", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (62)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 63, + "propertyName": "level", + "propertyKeyName": "63", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (63)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 63, + "propertyName": "dimmingDuration", + "propertyKeyName": "63", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (63)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 64, + "propertyName": "level", + "propertyKeyName": "64", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (64)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 64, + "propertyName": "dimmingDuration", + "propertyKeyName": "64", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (64)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 65, + "propertyName": "level", + "propertyKeyName": "65", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (65)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 65, + "propertyName": "dimmingDuration", + "propertyKeyName": "65", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (65)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 66, + "propertyName": "level", + "propertyKeyName": "66", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (66)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 66, + "propertyName": "dimmingDuration", + "propertyKeyName": "66", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (66)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 67, + "propertyName": "level", + "propertyKeyName": "67", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (67)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 67, + "propertyName": "dimmingDuration", + "propertyKeyName": "67", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (67)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 68, + "propertyName": "level", + "propertyKeyName": "68", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (68)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 68, + "propertyName": "dimmingDuration", + "propertyKeyName": "68", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (68)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 69, + "propertyName": "level", + "propertyKeyName": "69", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (69)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 69, + "propertyName": "dimmingDuration", + "propertyKeyName": "69", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (69)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 70, + "propertyName": "level", + "propertyKeyName": "70", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (70)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 70, + "propertyName": "dimmingDuration", + "propertyKeyName": "70", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (70)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 71, + "propertyName": "level", + "propertyKeyName": "71", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (71)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 71, + "propertyName": "dimmingDuration", + "propertyKeyName": "71", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (71)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 72, + "propertyName": "level", + "propertyKeyName": "72", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (72)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 72, + "propertyName": "dimmingDuration", + "propertyKeyName": "72", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (72)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 73, + "propertyName": "level", + "propertyKeyName": "73", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (73)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 73, + "propertyName": "dimmingDuration", + "propertyKeyName": "73", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (73)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 74, + "propertyName": "level", + "propertyKeyName": "74", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (74)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 74, + "propertyName": "dimmingDuration", + "propertyKeyName": "74", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (74)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 75, + "propertyName": "level", + "propertyKeyName": "75", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (75)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 75, + "propertyName": "dimmingDuration", + "propertyKeyName": "75", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (75)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 76, + "propertyName": "level", + "propertyKeyName": "76", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (76)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 76, + "propertyName": "dimmingDuration", + "propertyKeyName": "76", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (76)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 77, + "propertyName": "level", + "propertyKeyName": "77", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (77)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 77, + "propertyName": "dimmingDuration", + "propertyKeyName": "77", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (77)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 78, + "propertyName": "level", + "propertyKeyName": "78", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (78)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 78, + "propertyName": "dimmingDuration", + "propertyKeyName": "78", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (78)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 79, + "propertyName": "level", + "propertyKeyName": "79", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (79)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 79, + "propertyName": "dimmingDuration", + "propertyKeyName": "79", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (79)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 80, + "propertyName": "level", + "propertyKeyName": "80", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (80)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 80, + "propertyName": "dimmingDuration", + "propertyKeyName": "80", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (80)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 81, + "propertyName": "level", + "propertyKeyName": "81", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (81)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 81, + "propertyName": "dimmingDuration", + "propertyKeyName": "81", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (81)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 82, + "propertyName": "level", + "propertyKeyName": "82", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (82)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 82, + "propertyName": "dimmingDuration", + "propertyKeyName": "82", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (82)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 83, + "propertyName": "level", + "propertyKeyName": "83", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (83)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 83, + "propertyName": "dimmingDuration", + "propertyKeyName": "83", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (83)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 84, + "propertyName": "level", + "propertyKeyName": "84", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (84)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 84, + "propertyName": "dimmingDuration", + "propertyKeyName": "84", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (84)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 85, + "propertyName": "level", + "propertyKeyName": "85", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (85)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 85, + "propertyName": "dimmingDuration", + "propertyKeyName": "85", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (85)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 86, + "propertyName": "level", + "propertyKeyName": "86", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (86)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 86, + "propertyName": "dimmingDuration", + "propertyKeyName": "86", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (86)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 87, + "propertyName": "level", + "propertyKeyName": "87", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (87)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 87, + "propertyName": "dimmingDuration", + "propertyKeyName": "87", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (87)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 88, + "propertyName": "level", + "propertyKeyName": "88", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (88)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 88, + "propertyName": "dimmingDuration", + "propertyKeyName": "88", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (88)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 89, + "propertyName": "level", + "propertyKeyName": "89", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (89)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 89, + "propertyName": "dimmingDuration", + "propertyKeyName": "89", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (89)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 90, + "propertyName": "level", + "propertyKeyName": "90", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (90)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 90, + "propertyName": "dimmingDuration", + "propertyKeyName": "90", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (90)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 91, + "propertyName": "level", + "propertyKeyName": "91", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (91)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 91, + "propertyName": "dimmingDuration", + "propertyKeyName": "91", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (91)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 92, + "propertyName": "level", + "propertyKeyName": "92", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (92)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 92, + "propertyName": "dimmingDuration", + "propertyKeyName": "92", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (92)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 93, + "propertyName": "level", + "propertyKeyName": "93", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (93)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 93, + "propertyName": "dimmingDuration", + "propertyKeyName": "93", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (93)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 94, + "propertyName": "level", + "propertyKeyName": "94", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (94)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 94, + "propertyName": "dimmingDuration", + "propertyKeyName": "94", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (94)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 95, + "propertyName": "level", + "propertyKeyName": "95", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (95)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 95, + "propertyName": "dimmingDuration", + "propertyKeyName": "95", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (95)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 96, + "propertyName": "level", + "propertyKeyName": "96", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (96)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 96, + "propertyName": "dimmingDuration", + "propertyKeyName": "96", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (96)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 97, + "propertyName": "level", + "propertyKeyName": "97", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (97)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 97, + "propertyName": "dimmingDuration", + "propertyKeyName": "97", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (97)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 98, + "propertyName": "level", + "propertyKeyName": "98", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (98)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 98, + "propertyName": "dimmingDuration", + "propertyKeyName": "98", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (98)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 99, + "propertyName": "level", + "propertyKeyName": "99", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (99)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 99, + "propertyName": "dimmingDuration", + "propertyKeyName": "99", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (99)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 100, + "propertyName": "level", + "propertyKeyName": "100", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (100)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 100, + "propertyName": "dimmingDuration", + "propertyKeyName": "100", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (100)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 101, + "propertyName": "level", + "propertyKeyName": "101", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (101)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 101, + "propertyName": "dimmingDuration", + "propertyKeyName": "101", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (101)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 102, + "propertyName": "level", + "propertyKeyName": "102", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (102)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 102, + "propertyName": "dimmingDuration", + "propertyKeyName": "102", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (102)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 103, + "propertyName": "level", + "propertyKeyName": "103", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (103)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 103, + "propertyName": "dimmingDuration", + "propertyKeyName": "103", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (103)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 104, + "propertyName": "level", + "propertyKeyName": "104", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (104)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 104, + "propertyName": "dimmingDuration", + "propertyKeyName": "104", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (104)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 105, + "propertyName": "level", + "propertyKeyName": "105", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (105)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 105, + "propertyName": "dimmingDuration", + "propertyKeyName": "105", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (105)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 106, + "propertyName": "level", + "propertyKeyName": "106", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (106)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 106, + "propertyName": "dimmingDuration", + "propertyKeyName": "106", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (106)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 107, + "propertyName": "level", + "propertyKeyName": "107", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (107)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 107, + "propertyName": "dimmingDuration", + "propertyKeyName": "107", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (107)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 108, + "propertyName": "level", + "propertyKeyName": "108", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (108)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 108, + "propertyName": "dimmingDuration", + "propertyKeyName": "108", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (108)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 109, + "propertyName": "level", + "propertyKeyName": "109", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (109)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 109, + "propertyName": "dimmingDuration", + "propertyKeyName": "109", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (109)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 110, + "propertyName": "level", + "propertyKeyName": "110", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (110)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 110, + "propertyName": "dimmingDuration", + "propertyKeyName": "110", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (110)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 111, + "propertyName": "level", + "propertyKeyName": "111", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (111)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 111, + "propertyName": "dimmingDuration", + "propertyKeyName": "111", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (111)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 112, + "propertyName": "level", + "propertyKeyName": "112", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (112)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 112, + "propertyName": "dimmingDuration", + "propertyKeyName": "112", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (112)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 113, + "propertyName": "level", + "propertyKeyName": "113", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (113)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 113, + "propertyName": "dimmingDuration", + "propertyKeyName": "113", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (113)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 114, + "propertyName": "level", + "propertyKeyName": "114", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (114)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 114, + "propertyName": "dimmingDuration", + "propertyKeyName": "114", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (114)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 115, + "propertyName": "level", + "propertyKeyName": "115", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (115)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 115, + "propertyName": "dimmingDuration", + "propertyKeyName": "115", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (115)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 116, + "propertyName": "level", + "propertyKeyName": "116", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (116)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 116, + "propertyName": "dimmingDuration", + "propertyKeyName": "116", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (116)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 117, + "propertyName": "level", + "propertyKeyName": "117", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (117)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 117, + "propertyName": "dimmingDuration", + "propertyKeyName": "117", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (117)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 118, + "propertyName": "level", + "propertyKeyName": "118", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (118)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 118, + "propertyName": "dimmingDuration", + "propertyKeyName": "118", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (118)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 119, + "propertyName": "level", + "propertyKeyName": "119", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (119)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 119, + "propertyName": "dimmingDuration", + "propertyKeyName": "119", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (119)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 120, + "propertyName": "level", + "propertyKeyName": "120", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (120)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 120, + "propertyName": "dimmingDuration", + "propertyKeyName": "120", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (120)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 121, + "propertyName": "level", + "propertyKeyName": "121", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (121)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 121, + "propertyName": "dimmingDuration", + "propertyKeyName": "121", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (121)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 122, + "propertyName": "level", + "propertyKeyName": "122", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (122)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 122, + "propertyName": "dimmingDuration", + "propertyKeyName": "122", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (122)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 123, + "propertyName": "level", + "propertyKeyName": "123", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (123)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 123, + "propertyName": "dimmingDuration", + "propertyKeyName": "123", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (123)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 124, + "propertyName": "level", + "propertyKeyName": "124", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (124)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 124, + "propertyName": "dimmingDuration", + "propertyKeyName": "124", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (124)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 125, + "propertyName": "level", + "propertyKeyName": "125", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (125)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 125, + "propertyName": "dimmingDuration", + "propertyKeyName": "125", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (125)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 126, + "propertyName": "level", + "propertyKeyName": "126", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (126)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 126, + "propertyName": "dimmingDuration", + "propertyKeyName": "126", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (126)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 127, + "propertyName": "level", + "propertyKeyName": "127", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (127)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 127, + "propertyName": "dimmingDuration", + "propertyKeyName": "127", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (127)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 128, + "propertyName": "level", + "propertyKeyName": "128", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (128)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 128, + "propertyName": "dimmingDuration", + "propertyKeyName": "128", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (128)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 129, + "propertyName": "level", + "propertyKeyName": "129", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (129)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 129, + "propertyName": "dimmingDuration", + "propertyKeyName": "129", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (129)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 130, + "propertyName": "level", + "propertyKeyName": "130", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (130)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 130, + "propertyName": "dimmingDuration", + "propertyKeyName": "130", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (130)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 131, + "propertyName": "level", + "propertyKeyName": "131", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (131)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 131, + "propertyName": "dimmingDuration", + "propertyKeyName": "131", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (131)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 132, + "propertyName": "level", + "propertyKeyName": "132", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (132)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 132, + "propertyName": "dimmingDuration", + "propertyKeyName": "132", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (132)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 133, + "propertyName": "level", + "propertyKeyName": "133", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (133)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 133, + "propertyName": "dimmingDuration", + "propertyKeyName": "133", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (133)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 134, + "propertyName": "level", + "propertyKeyName": "134", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (134)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 134, + "propertyName": "dimmingDuration", + "propertyKeyName": "134", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (134)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 135, + "propertyName": "level", + "propertyKeyName": "135", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (135)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 135, + "propertyName": "dimmingDuration", + "propertyKeyName": "135", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (135)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 136, + "propertyName": "level", + "propertyKeyName": "136", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (136)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 136, + "propertyName": "dimmingDuration", + "propertyKeyName": "136", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (136)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 137, + "propertyName": "level", + "propertyKeyName": "137", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (137)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 137, + "propertyName": "dimmingDuration", + "propertyKeyName": "137", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (137)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 138, + "propertyName": "level", + "propertyKeyName": "138", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (138)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 138, + "propertyName": "dimmingDuration", + "propertyKeyName": "138", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (138)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 139, + "propertyName": "level", + "propertyKeyName": "139", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (139)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 139, + "propertyName": "dimmingDuration", + "propertyKeyName": "139", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (139)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 140, + "propertyName": "level", + "propertyKeyName": "140", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (140)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 140, + "propertyName": "dimmingDuration", + "propertyKeyName": "140", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (140)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 141, + "propertyName": "level", + "propertyKeyName": "141", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (141)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 141, + "propertyName": "dimmingDuration", + "propertyKeyName": "141", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (141)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 142, + "propertyName": "level", + "propertyKeyName": "142", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (142)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 142, + "propertyName": "dimmingDuration", + "propertyKeyName": "142", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (142)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 143, + "propertyName": "level", + "propertyKeyName": "143", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (143)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 143, + "propertyName": "dimmingDuration", + "propertyKeyName": "143", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (143)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 144, + "propertyName": "level", + "propertyKeyName": "144", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (144)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 144, + "propertyName": "dimmingDuration", + "propertyKeyName": "144", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (144)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 145, + "propertyName": "level", + "propertyKeyName": "145", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (145)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 145, + "propertyName": "dimmingDuration", + "propertyKeyName": "145", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (145)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 146, + "propertyName": "level", + "propertyKeyName": "146", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (146)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 146, + "propertyName": "dimmingDuration", + "propertyKeyName": "146", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (146)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 147, + "propertyName": "level", + "propertyKeyName": "147", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (147)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 147, + "propertyName": "dimmingDuration", + "propertyKeyName": "147", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (147)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 148, + "propertyName": "level", + "propertyKeyName": "148", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (148)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 148, + "propertyName": "dimmingDuration", + "propertyKeyName": "148", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (148)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 149, + "propertyName": "level", + "propertyKeyName": "149", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (149)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 149, + "propertyName": "dimmingDuration", + "propertyKeyName": "149", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (149)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 150, + "propertyName": "level", + "propertyKeyName": "150", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (150)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 150, + "propertyName": "dimmingDuration", + "propertyKeyName": "150", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (150)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 151, + "propertyName": "level", + "propertyKeyName": "151", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (151)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 151, + "propertyName": "dimmingDuration", + "propertyKeyName": "151", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (151)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 152, + "propertyName": "level", + "propertyKeyName": "152", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (152)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 152, + "propertyName": "dimmingDuration", + "propertyKeyName": "152", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (152)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 153, + "propertyName": "level", + "propertyKeyName": "153", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (153)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 153, + "propertyName": "dimmingDuration", + "propertyKeyName": "153", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (153)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 154, + "propertyName": "level", + "propertyKeyName": "154", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (154)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 154, + "propertyName": "dimmingDuration", + "propertyKeyName": "154", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (154)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 155, + "propertyName": "level", + "propertyKeyName": "155", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (155)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 155, + "propertyName": "dimmingDuration", + "propertyKeyName": "155", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (155)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 156, + "propertyName": "level", + "propertyKeyName": "156", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (156)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 156, + "propertyName": "dimmingDuration", + "propertyKeyName": "156", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (156)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 157, + "propertyName": "level", + "propertyKeyName": "157", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (157)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 157, + "propertyName": "dimmingDuration", + "propertyKeyName": "157", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (157)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 158, + "propertyName": "level", + "propertyKeyName": "158", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (158)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 158, + "propertyName": "dimmingDuration", + "propertyKeyName": "158", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (158)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 159, + "propertyName": "level", + "propertyKeyName": "159", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (159)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 159, + "propertyName": "dimmingDuration", + "propertyKeyName": "159", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (159)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 160, + "propertyName": "level", + "propertyKeyName": "160", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (160)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 160, + "propertyName": "dimmingDuration", + "propertyKeyName": "160", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (160)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 161, + "propertyName": "level", + "propertyKeyName": "161", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (161)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 161, + "propertyName": "dimmingDuration", + "propertyKeyName": "161", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (161)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 162, + "propertyName": "level", + "propertyKeyName": "162", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (162)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 162, + "propertyName": "dimmingDuration", + "propertyKeyName": "162", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (162)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 163, + "propertyName": "level", + "propertyKeyName": "163", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (163)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 163, + "propertyName": "dimmingDuration", + "propertyKeyName": "163", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (163)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 164, + "propertyName": "level", + "propertyKeyName": "164", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (164)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 164, + "propertyName": "dimmingDuration", + "propertyKeyName": "164", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (164)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 165, + "propertyName": "level", + "propertyKeyName": "165", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (165)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 165, + "propertyName": "dimmingDuration", + "propertyKeyName": "165", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (165)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 166, + "propertyName": "level", + "propertyKeyName": "166", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (166)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 166, + "propertyName": "dimmingDuration", + "propertyKeyName": "166", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (166)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 167, + "propertyName": "level", + "propertyKeyName": "167", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (167)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 167, + "propertyName": "dimmingDuration", + "propertyKeyName": "167", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (167)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 168, + "propertyName": "level", + "propertyKeyName": "168", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (168)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 168, + "propertyName": "dimmingDuration", + "propertyKeyName": "168", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (168)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 169, + "propertyName": "level", + "propertyKeyName": "169", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (169)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 169, + "propertyName": "dimmingDuration", + "propertyKeyName": "169", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (169)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 170, + "propertyName": "level", + "propertyKeyName": "170", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (170)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 170, + "propertyName": "dimmingDuration", + "propertyKeyName": "170", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (170)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 171, + "propertyName": "level", + "propertyKeyName": "171", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (171)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 171, + "propertyName": "dimmingDuration", + "propertyKeyName": "171", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (171)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 172, + "propertyName": "level", + "propertyKeyName": "172", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (172)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 172, + "propertyName": "dimmingDuration", + "propertyKeyName": "172", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (172)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 173, + "propertyName": "level", + "propertyKeyName": "173", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (173)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 173, + "propertyName": "dimmingDuration", + "propertyKeyName": "173", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (173)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 174, + "propertyName": "level", + "propertyKeyName": "174", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (174)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 174, + "propertyName": "dimmingDuration", + "propertyKeyName": "174", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (174)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 175, + "propertyName": "level", + "propertyKeyName": "175", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (175)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 175, + "propertyName": "dimmingDuration", + "propertyKeyName": "175", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (175)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 176, + "propertyName": "level", + "propertyKeyName": "176", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (176)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 176, + "propertyName": "dimmingDuration", + "propertyKeyName": "176", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (176)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 177, + "propertyName": "level", + "propertyKeyName": "177", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (177)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 177, + "propertyName": "dimmingDuration", + "propertyKeyName": "177", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (177)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 178, + "propertyName": "level", + "propertyKeyName": "178", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (178)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 178, + "propertyName": "dimmingDuration", + "propertyKeyName": "178", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (178)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 179, + "propertyName": "level", + "propertyKeyName": "179", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (179)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 179, + "propertyName": "dimmingDuration", + "propertyKeyName": "179", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (179)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 180, + "propertyName": "level", + "propertyKeyName": "180", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (180)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 180, + "propertyName": "dimmingDuration", + "propertyKeyName": "180", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (180)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 181, + "propertyName": "level", + "propertyKeyName": "181", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (181)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 181, + "propertyName": "dimmingDuration", + "propertyKeyName": "181", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (181)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 182, + "propertyName": "level", + "propertyKeyName": "182", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (182)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 182, + "propertyName": "dimmingDuration", + "propertyKeyName": "182", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (182)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 183, + "propertyName": "level", + "propertyKeyName": "183", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (183)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 183, + "propertyName": "dimmingDuration", + "propertyKeyName": "183", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (183)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 184, + "propertyName": "level", + "propertyKeyName": "184", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (184)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 184, + "propertyName": "dimmingDuration", + "propertyKeyName": "184", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (184)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 185, + "propertyName": "level", + "propertyKeyName": "185", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (185)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 185, + "propertyName": "dimmingDuration", + "propertyKeyName": "185", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (185)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 186, + "propertyName": "level", + "propertyKeyName": "186", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (186)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 186, + "propertyName": "dimmingDuration", + "propertyKeyName": "186", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (186)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 187, + "propertyName": "level", + "propertyKeyName": "187", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (187)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 187, + "propertyName": "dimmingDuration", + "propertyKeyName": "187", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (187)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 188, + "propertyName": "level", + "propertyKeyName": "188", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (188)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 188, + "propertyName": "dimmingDuration", + "propertyKeyName": "188", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (188)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 189, + "propertyName": "level", + "propertyKeyName": "189", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (189)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 189, + "propertyName": "dimmingDuration", + "propertyKeyName": "189", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (189)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 190, + "propertyName": "level", + "propertyKeyName": "190", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (190)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 190, + "propertyName": "dimmingDuration", + "propertyKeyName": "190", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (190)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 191, + "propertyName": "level", + "propertyKeyName": "191", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (191)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 191, + "propertyName": "dimmingDuration", + "propertyKeyName": "191", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (191)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 192, + "propertyName": "level", + "propertyKeyName": "192", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (192)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 192, + "propertyName": "dimmingDuration", + "propertyKeyName": "192", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (192)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 193, + "propertyName": "level", + "propertyKeyName": "193", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (193)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 193, + "propertyName": "dimmingDuration", + "propertyKeyName": "193", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (193)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 194, + "propertyName": "level", + "propertyKeyName": "194", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (194)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 194, + "propertyName": "dimmingDuration", + "propertyKeyName": "194", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (194)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 195, + "propertyName": "level", + "propertyKeyName": "195", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (195)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 195, + "propertyName": "dimmingDuration", + "propertyKeyName": "195", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (195)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 196, + "propertyName": "level", + "propertyKeyName": "196", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (196)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 196, + "propertyName": "dimmingDuration", + "propertyKeyName": "196", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (196)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 197, + "propertyName": "level", + "propertyKeyName": "197", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (197)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 197, + "propertyName": "dimmingDuration", + "propertyKeyName": "197", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (197)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 198, + "propertyName": "level", + "propertyKeyName": "198", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (198)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 198, + "propertyName": "dimmingDuration", + "propertyKeyName": "198", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (198)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 199, + "propertyName": "level", + "propertyKeyName": "199", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (199)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 199, + "propertyName": "dimmingDuration", + "propertyKeyName": "199", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (199)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 200, + "propertyName": "level", + "propertyKeyName": "200", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (200)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 200, + "propertyName": "dimmingDuration", + "propertyKeyName": "200", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (200)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 201, + "propertyName": "level", + "propertyKeyName": "201", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (201)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 201, + "propertyName": "dimmingDuration", + "propertyKeyName": "201", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (201)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 202, + "propertyName": "level", + "propertyKeyName": "202", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (202)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 202, + "propertyName": "dimmingDuration", + "propertyKeyName": "202", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (202)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 203, + "propertyName": "level", + "propertyKeyName": "203", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (203)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 203, + "propertyName": "dimmingDuration", + "propertyKeyName": "203", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (203)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 204, + "propertyName": "level", + "propertyKeyName": "204", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (204)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 204, + "propertyName": "dimmingDuration", + "propertyKeyName": "204", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (204)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 205, + "propertyName": "level", + "propertyKeyName": "205", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (205)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 205, + "propertyName": "dimmingDuration", + "propertyKeyName": "205", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (205)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 206, + "propertyName": "level", + "propertyKeyName": "206", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (206)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 206, + "propertyName": "dimmingDuration", + "propertyKeyName": "206", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (206)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 207, + "propertyName": "level", + "propertyKeyName": "207", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (207)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 207, + "propertyName": "dimmingDuration", + "propertyKeyName": "207", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (207)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 208, + "propertyName": "level", + "propertyKeyName": "208", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (208)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 208, + "propertyName": "dimmingDuration", + "propertyKeyName": "208", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (208)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 209, + "propertyName": "level", + "propertyKeyName": "209", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (209)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 209, + "propertyName": "dimmingDuration", + "propertyKeyName": "209", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (209)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 210, + "propertyName": "level", + "propertyKeyName": "210", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (210)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 210, + "propertyName": "dimmingDuration", + "propertyKeyName": "210", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (210)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 211, + "propertyName": "level", + "propertyKeyName": "211", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (211)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 211, + "propertyName": "dimmingDuration", + "propertyKeyName": "211", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (211)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 212, + "propertyName": "level", + "propertyKeyName": "212", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (212)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 212, + "propertyName": "dimmingDuration", + "propertyKeyName": "212", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (212)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 213, + "propertyName": "level", + "propertyKeyName": "213", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (213)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 213, + "propertyName": "dimmingDuration", + "propertyKeyName": "213", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (213)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 214, + "propertyName": "level", + "propertyKeyName": "214", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (214)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 214, + "propertyName": "dimmingDuration", + "propertyKeyName": "214", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (214)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 215, + "propertyName": "level", + "propertyKeyName": "215", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (215)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 215, + "propertyName": "dimmingDuration", + "propertyKeyName": "215", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (215)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 216, + "propertyName": "level", + "propertyKeyName": "216", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (216)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 216, + "propertyName": "dimmingDuration", + "propertyKeyName": "216", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (216)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 217, + "propertyName": "level", + "propertyKeyName": "217", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (217)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 217, + "propertyName": "dimmingDuration", + "propertyKeyName": "217", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (217)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 218, + "propertyName": "level", + "propertyKeyName": "218", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (218)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 218, + "propertyName": "dimmingDuration", + "propertyKeyName": "218", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (218)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 219, + "propertyName": "level", + "propertyKeyName": "219", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (219)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 219, + "propertyName": "dimmingDuration", + "propertyKeyName": "219", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (219)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 220, + "propertyName": "level", + "propertyKeyName": "220", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (220)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 220, + "propertyName": "dimmingDuration", + "propertyKeyName": "220", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (220)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 221, + "propertyName": "level", + "propertyKeyName": "221", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (221)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 221, + "propertyName": "dimmingDuration", + "propertyKeyName": "221", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (221)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 222, + "propertyName": "level", + "propertyKeyName": "222", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (222)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 222, + "propertyName": "dimmingDuration", + "propertyKeyName": "222", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (222)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 223, + "propertyName": "level", + "propertyKeyName": "223", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (223)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 223, + "propertyName": "dimmingDuration", + "propertyKeyName": "223", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (223)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 224, + "propertyName": "level", + "propertyKeyName": "224", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (224)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 224, + "propertyName": "dimmingDuration", + "propertyKeyName": "224", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (224)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 225, + "propertyName": "level", + "propertyKeyName": "225", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (225)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 225, + "propertyName": "dimmingDuration", + "propertyKeyName": "225", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (225)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 226, + "propertyName": "level", + "propertyKeyName": "226", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (226)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 226, + "propertyName": "dimmingDuration", + "propertyKeyName": "226", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (226)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 227, + "propertyName": "level", + "propertyKeyName": "227", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (227)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 227, + "propertyName": "dimmingDuration", + "propertyKeyName": "227", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (227)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 228, + "propertyName": "level", + "propertyKeyName": "228", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (228)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 228, + "propertyName": "dimmingDuration", + "propertyKeyName": "228", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (228)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 229, + "propertyName": "level", + "propertyKeyName": "229", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (229)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 229, + "propertyName": "dimmingDuration", + "propertyKeyName": "229", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (229)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 230, + "propertyName": "level", + "propertyKeyName": "230", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (230)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 230, + "propertyName": "dimmingDuration", + "propertyKeyName": "230", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (230)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 231, + "propertyName": "level", + "propertyKeyName": "231", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (231)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 231, + "propertyName": "dimmingDuration", + "propertyKeyName": "231", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (231)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 232, + "propertyName": "level", + "propertyKeyName": "232", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (232)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 232, + "propertyName": "dimmingDuration", + "propertyKeyName": "232", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (232)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 233, + "propertyName": "level", + "propertyKeyName": "233", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (233)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 233, + "propertyName": "dimmingDuration", + "propertyKeyName": "233", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (233)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 234, + "propertyName": "level", + "propertyKeyName": "234", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (234)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 234, + "propertyName": "dimmingDuration", + "propertyKeyName": "234", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (234)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 235, + "propertyName": "level", + "propertyKeyName": "235", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (235)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 235, + "propertyName": "dimmingDuration", + "propertyKeyName": "235", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (235)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 236, + "propertyName": "level", + "propertyKeyName": "236", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (236)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 236, + "propertyName": "dimmingDuration", + "propertyKeyName": "236", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (236)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 237, + "propertyName": "level", + "propertyKeyName": "237", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (237)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 237, + "propertyName": "dimmingDuration", + "propertyKeyName": "237", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (237)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 238, + "propertyName": "level", + "propertyKeyName": "238", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (238)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 238, + "propertyName": "dimmingDuration", + "propertyKeyName": "238", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (238)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 239, + "propertyName": "level", + "propertyKeyName": "239", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (239)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 239, + "propertyName": "dimmingDuration", + "propertyKeyName": "239", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (239)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 240, + "propertyName": "level", + "propertyKeyName": "240", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (240)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 240, + "propertyName": "dimmingDuration", + "propertyKeyName": "240", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (240)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 241, + "propertyName": "level", + "propertyKeyName": "241", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (241)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 241, + "propertyName": "dimmingDuration", + "propertyKeyName": "241", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (241)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 242, + "propertyName": "level", + "propertyKeyName": "242", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (242)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 242, + "propertyName": "dimmingDuration", + "propertyKeyName": "242", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (242)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 243, + "propertyName": "level", + "propertyKeyName": "243", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (243)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 243, + "propertyName": "dimmingDuration", + "propertyKeyName": "243", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (243)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 244, + "propertyName": "level", + "propertyKeyName": "244", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (244)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 244, + "propertyName": "dimmingDuration", + "propertyKeyName": "244", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (244)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 245, + "propertyName": "level", + "propertyKeyName": "245", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (245)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 245, + "propertyName": "dimmingDuration", + "propertyKeyName": "245", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (245)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 246, + "propertyName": "level", + "propertyKeyName": "246", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (246)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 246, + "propertyName": "dimmingDuration", + "propertyKeyName": "246", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (246)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 247, + "propertyName": "level", + "propertyKeyName": "247", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (247)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 247, + "propertyName": "dimmingDuration", + "propertyKeyName": "247", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (247)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 248, + "propertyName": "level", + "propertyKeyName": "248", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (248)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 248, + "propertyName": "dimmingDuration", + "propertyKeyName": "248", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (248)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 249, + "propertyName": "level", + "propertyKeyName": "249", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (249)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 249, + "propertyName": "dimmingDuration", + "propertyKeyName": "249", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (249)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 250, + "propertyName": "level", + "propertyKeyName": "250", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (250)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 250, + "propertyName": "dimmingDuration", + "propertyKeyName": "250", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (250)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 251, + "propertyName": "level", + "propertyKeyName": "251", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (251)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 251, + "propertyName": "dimmingDuration", + "propertyKeyName": "251", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (251)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 252, + "propertyName": "level", + "propertyKeyName": "252", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (252)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 252, + "propertyName": "dimmingDuration", + "propertyKeyName": "252", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (252)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 253, + "propertyName": "level", + "propertyKeyName": "253", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (253)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 253, + "propertyName": "dimmingDuration", + "propertyKeyName": "253", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (253)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 254, + "propertyName": "level", + "propertyKeyName": "254", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (254)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 254, + "propertyName": "dimmingDuration", + "propertyKeyName": "254", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (254)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 255, + "propertyName": "level", + "propertyKeyName": "255", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (255)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 255, + "propertyName": "dimmingDuration", + "propertyKeyName": "255", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (255)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Invert Switch", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Invert Switch", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Default", + "1": "Invert" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 57 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 18756 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 12593 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "4.54" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["5.24"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 255 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 8, + "label": "Fan Switch" + }, + "mandatorySupportedCCs": [32, 38, 133, 89, 114, 115, 134, 94], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0039:0x4944:0x3131:5.24", + "statistics": { + "commandsTX": 40, + "commandsRX": 39, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 35.8 + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index e4ff285feb2..2b508700413 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -938,3 +938,72 @@ async def test_thermostat_fan_without_preset_modes( assert not state.attributes.get(ATTR_PRESET_MODE) assert not state.attributes.get(ATTR_PRESET_MODES) + + +async def test_honeywell_39358_fan( + hass: HomeAssistant, client, fan_honeywell_39358, integration +) -> None: + """Test a Honeywell 39358 fan with 3 fixed speeds.""" + node = fan_honeywell_39358 + node_id = 61 + entity_id = "fan.honeywell_in_wall_smart_fan_control" + + async def get_zwave_speed_from_percentage(percentage): + """Set the fan to a particular percentage and get the resulting Zwave speed.""" + client.async_send_command.reset_mock() + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "percentage": percentage}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node_id + return args["value"] + + async def get_percentage_from_zwave_speed(zwave_speed): + """Set the underlying device speed and get the resulting percentage.""" + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": zwave_speed, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(entity_id) + return state.attributes[ATTR_PERCENTAGE] + + # This device has the speeds: + # low = 1-32, med = 33-66, high = 67-99 + percentages_to_zwave_speeds = [ + [[0], [0]], + [range(1, 34), range(1, 33)], + [range(34, 68), range(33, 67)], + [range(68, 101), range(67, 100)], + ] + + for percentages, zwave_speeds in percentages_to_zwave_speeds: + for percentage in percentages: + actual_zwave_speed = await get_zwave_speed_from_percentage(percentage) + assert actual_zwave_speed in zwave_speeds + for zwave_speed in zwave_speeds: + actual_percentage = await get_percentage_from_zwave_speed(zwave_speed) + assert actual_percentage in percentages + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PERCENTAGE_STEP] == pytest.approx(33.3333, rel=1e-3) + assert state.attributes[ATTR_PRESET_MODES] == [] From e272e3c721a06f72280e1bae07969b974d9ea605 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 31 May 2023 18:11:00 -0400 Subject: [PATCH 005/857] Refactor try catch in hassio.issues per feedback (#93872) --- homeassistant/components/hassio/issues.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index a92fc392fa4..2af0a6ed764 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -274,12 +274,13 @@ class SupervisorIssues: data["suggestions"] = ( await self._client.get_suggestions_for_issue(data["uuid"]) )[ATTR_SUGGESTIONS] - self.add_issue(Issue.from_dict(data)) except HassioAPIError: _LOGGER.error( "Could not get suggestions for supervisor issue %s, skipping it", data["uuid"], ) + return + self.add_issue(Issue.from_dict(data)) def remove_issue(self, issue: Issue) -> None: """Remove an issue from the list. Delete a repair if necessary.""" From 198608906c8267c1385fd163ed6997ad0fcc59c7 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 31 May 2023 18:21:13 -0400 Subject: [PATCH 006/857] Address late review for whirlpool (#93874) Address late review --- homeassistant/components/whirlpool/sensor.py | 4 ++-- tests/components/whirlpool/test_sensor.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 5b83c35cca4..de415035c76 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -296,9 +296,9 @@ class WasherDryerTimeClass(RestoreSensor): ) if ( - isinstance(self._attr_native_value, datetime) + self._attr_native_value is None + or isinstance(self._attr_native_value, datetime) and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) - or self._attr_native_value is None ): self._attr_native_value = new_timestamp self._async_write_ha_state() diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 063ebd49c4c..be78b0e2df8 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -311,10 +311,11 @@ async def test_no_restore_state( await init_integration(hass) # restore from cache state = hass.states.get(entity_id) - state.state = "unknown" + assert state.state == "unknown" + mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle state = await update_sensor_state(hass, entity_id, mock_sensor1_api) - state.state = datetime.now().isoformat() + assert state.state != "unknown" async def test_callback( From 226647f62575a0acdcafb43d9c83a4a61a9dafd6 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 31 May 2023 20:07:14 -0400 Subject: [PATCH 007/857] Add binary sensor platform to Dremel 3D Printer (#93881) Add binary sensor platform to Dremel --- .../components/dremel_3d_printer/__init__.py | 2 +- .../dremel_3d_printer/binary_sensor.py | 74 +++++++++++++++++++ .../dremel_3d_printer/test_binary_sensor.py | 27 +++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/dremel_3d_printer/binary_sensor.py create mode 100644 tests/components/dremel_3d_printer/test_binary_sensor.py diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py index 4daafea5db8..eaf22383839 100644 --- a/homeassistant/components/dremel_3d_printer/__init__.py +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import Dremel3DPrinterDataUpdateCoordinator -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py new file mode 100644 index 00000000000..1832e10d3c9 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -0,0 +1,74 @@ +"""Support for monitoring Dremel 3D Printer binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import Dremel3DPrinterEntity + + +@dataclass +class Dremel3DPrinterBinarySensorEntityMixin: + """Mixin for Dremel 3D Printer binary sensor.""" + + value_fn: Callable[[Dremel3DPrinter], bool] + + +@dataclass +class Dremel3DPrinterBinarySensorEntityDescription( + BinarySensorEntityDescription, Dremel3DPrinterBinarySensorEntityMixin +): + """Describes a Dremel 3D Printer binary sensor.""" + + +BINARY_SENSOR_TYPES: tuple[Dremel3DPrinterBinarySensorEntityDescription, ...] = ( + Dremel3DPrinterBinarySensorEntityDescription( + key="door", + name="Door", + device_class=BinarySensorDeviceClass.DOOR, + value_fn=lambda api: api.is_door_open(), + ), + Dremel3DPrinterBinarySensorEntityDescription( + key="running", + name="Running", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=lambda api: api.is_running(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the available Dremel binary sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + Dremel3DPrinterBinarySensor(coordinator, description) + for description in BINARY_SENSOR_TYPES + ) + + +class Dremel3DPrinterBinarySensor(Dremel3DPrinterEntity, BinarySensorEntity): + """Representation of a Dremel 3D Printer door binary sensor.""" + + entity_description: Dremel3DPrinterBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return True if door is open.""" + return self.entity_description.value_fn(self._api) diff --git a/tests/components/dremel_3d_printer/test_binary_sensor.py b/tests/components/dremel_3d_printer/test_binary_sensor.py new file mode 100644 index 00000000000..081cc7a02fb --- /dev/null +++ b/tests/components/dremel_3d_printer/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Binary sensor tests for the Dremel 3D Printer integration.""" +from unittest.mock import AsyncMock + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_binary_sensors( + hass: HomeAssistant, + connection, + config_entry: MockConfigEntry, + entity_registry_enabled_by_default: AsyncMock, +) -> None: + """Test we get binary sensor data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + state = hass.states.get("binary_sensor.dremel_3d45_door") + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.dremel_3d45_running") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.RUNNING From b9ec3a8af87c97dae5a0aa0b7e386a4ae62f7ed4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 31 May 2023 20:08:18 -0400 Subject: [PATCH 008/857] Increase Zigbee command retries (#93877) * Enable retries for requests * Update unit tests * Account for fewer group retries in unit tests --- .../zha/core/cluster_handlers/__init__.py | 4 + .../zha/test_alarm_control_panel.py | 5 + tests/components/zha/test_device_action.py | 4 +- tests/components/zha/test_discover.py | 2 +- tests/components/zha/test_light.py | 98 +++++++++++-------- tests/components/zha/test_switch.py | 4 +- 6 files changed, 71 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 7863b043455..ec29e4e53eb 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -45,6 +45,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +DEFAULT_REQUEST_RETRIES = 3 + class AttrReportConfig(TypedDict, total=True): """Configuration to report for the attributes.""" @@ -78,6 +80,8 @@ def decorate_command(cluster_handler, command): @wraps(command) async def wrapper(*args, **kwds): + kwds.setdefault("tries", DEFAULT_REQUEST_RETRIES) + try: result = await command(*args, **kwds) cluster_handler.debug( diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index 319301cf7dc..34ce746e128 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -96,6 +96,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) # disarm from HA @@ -134,6 +135,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.Emergency, + tries=3, ) # reset the panel @@ -157,6 +159,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) # arm_night from HA @@ -177,6 +180,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) # reset the panel @@ -274,5 +278,6 @@ async def reset_alarm_panel(hass, cluster, entity_id): 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) cluster.client_command.reset_mock() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index f1ab44f69eb..9d9a4bc2a54 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -328,7 +328,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=1, + tries=3, tsn=None, ) in cluster.request.call_args_list @@ -345,7 +345,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=1, + tries=3, tsn=None, ) in cluster.request.call_args_list diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 236a3c4ad86..a87d624ec00 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -131,7 +131,7 @@ async def test_devices( ), expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) ] diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index c4751f7e7f6..5ea71573a27 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -553,7 +553,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -589,7 +589,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -600,7 +600,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -637,7 +637,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -674,7 +674,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -685,7 +685,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -696,7 +696,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -758,7 +758,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -769,7 +769,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -780,7 +780,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -838,7 +838,7 @@ async def test_transitions( dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -850,7 +850,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -910,7 +910,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -968,7 +968,7 @@ async def test_transitions( transition_time=1, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev2_cluster_color.request.call_args == call( @@ -979,7 +979,7 @@ async def test_transitions( transition_time=1, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev2_cluster_level.request.call_args_list[1] == call( @@ -990,7 +990,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1121,7 +1121,7 @@ async def test_transitions( transition_time=20, # transition time expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1151,7 +1151,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1184,7 +1184,7 @@ async def test_transitions( eWeLink_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1195,7 +1195,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1261,7 +1261,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1272,7 +1272,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1319,7 +1319,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1330,7 +1330,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -1341,7 +1341,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1373,7 +1373,9 @@ async def async_test_on_from_light(hass, cluster, entity_id): assert hass.states.get(entity_id).state == STATE_ON -async def async_test_on_off_from_hass(hass, cluster, entity_id): +async def async_test_on_off_from_hass( + hass, cluster, entity_id, expected_tries: int = 3 +): """Test on off functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1388,14 +1390,16 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id): cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) - await async_test_off_from_hass(hass, cluster, entity_id) + await async_test_off_from_hass( + hass, cluster, entity_id, expected_tries=expected_tries + ) -async def async_test_off_from_hass(hass, cluster, entity_id): +async def async_test_off_from_hass(hass, cluster, entity_id, expected_tries: int = 3): """Test turning off the light from Home Assistant.""" # turn off via UI @@ -1411,13 +1415,18 @@ async def async_test_off_from_hass(hass, cluster, entity_id): cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) async def async_test_level_on_off_from_hass( - hass, on_off_cluster, level_cluster, entity_id, expected_default_transition: int = 0 + hass, + on_off_cluster, + level_cluster, + entity_id, + expected_default_transition: int = 0, + expected_tries: int = 3, ): """Test on off functionality from hass.""" @@ -1439,7 +1448,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1463,7 +1472,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) assert level_cluster.request.call_args == call( @@ -1474,7 +1483,7 @@ async def async_test_level_on_off_from_hass( transition_time=100, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1499,13 +1508,15 @@ async def async_test_level_on_off_from_hass( transition_time=int(expected_default_transition), expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() - await async_test_off_from_hass(hass, on_off_cluster, entity_id) + await async_test_off_from_hass( + hass, on_off_cluster, entity_id, expected_tries=expected_tries + ) async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected_state): @@ -1522,7 +1533,9 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected assert hass.states.get(entity_id).attributes.get("brightness") == level -async def async_test_flash_from_hass(hass, cluster, entity_id, flash): +async def async_test_flash_from_hass( + hass, cluster, entity_id, flash, expected_tries: int = 3 +): """Test flash functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1542,7 +1555,7 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash): effect_variant=general.Identify.EffectVariant.Default, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) @@ -1642,13 +1655,15 @@ async def test_zha_group_light_entity( assert "color_mode" not in group_state.attributes # test turning the lights on and off from the HA - await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) + await async_test_on_off_from_hass( + hass, group_cluster_on_off, group_entity_id, expected_tries=1 + ) await async_shift_time(hass) # test short flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_SHORT + hass, group_cluster_identify, group_entity_id, FLASH_SHORT, expected_tries=1 ) await async_shift_time(hass) @@ -1663,6 +1678,7 @@ async def test_zha_group_light_entity( group_cluster_level, group_entity_id, expected_default_transition=1, # a Sengled light is in that group and needs a minimum 0.1s transition + expected_tries=1, ) await async_shift_time(hass) @@ -1683,7 +1699,7 @@ async def test_zha_group_light_entity( # test long flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_LONG + hass, group_cluster_identify, group_entity_id, FLASH_LONG, expected_tries=1 ) await async_shift_time(hass) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 9f98acb9359..8fb7825a953 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -176,7 +176,7 @@ async def test_switch( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -196,7 +196,7 @@ async def test_switch( cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) From 11e268775c3915792feff5a338fe85e0c547db4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 1 Jun 2023 02:09:23 +0200 Subject: [PATCH 009/857] Update aioairzone-cloud to v0.1.7 (#93871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone-cloud to v0.1.7 Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: fix copy&paste description Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/entity.py | 6 ++++++ homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/util.py | 5 +++++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 59f6aa14bf8..c7e59ee1a3f 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -6,6 +6,7 @@ from typing import Any from aioairzone_cloud.const import ( AZD_AIDOOS, + AZD_AVAILABLE, AZD_FIRMWARE, AZD_NAME, AZD_SYSTEM_ID, @@ -26,6 +27,11 @@ from .coordinator import AirzoneUpdateCoordinator class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" + @property + def available(self) -> bool: + """Return Airzone Cloud entity availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + @abstractmethod def get_airzone_value(self, key: str) -> Any: """Return Airzone Cloud entity value by key.""" diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index d03fe5913c2..b2899a7c80c 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.1.6"] + "requirements": ["aioairzone-cloud==0.1.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index f8b232073f2..f6063a35016 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.6 +aioairzone-cloud==0.1.7 # homeassistant.components.airzone aioairzone==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c21d02028de..ee5053df08e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.6 +aioairzone-cloud==0.1.7 # homeassistant.components.airzone aioairzone==0.6.1 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 044cf880a16..4eab870297b 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -32,6 +32,7 @@ from aioairzone_cloud.const import ( API_SYSTEM_NUMBER, API_TYPE, API_WARNINGS, + API_WS_CONNECTED, API_WS_FW, API_WS_ID, API_WS_IDS, @@ -160,6 +161,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ERRORS: [], API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_LOCAL_TEMP: { API_CELSIUS: 21, API_FAH: 70, @@ -170,12 +172,14 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ERRORS: [], API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_WARNINGS: [], } if device.get_id() == "zone2": return { API_HUMIDITY: 24, API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_LOCAL_TEMP: { API_FAH: 77, API_CELSIUS: 25, @@ -185,6 +189,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_HUMIDITY: 30, API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_LOCAL_TEMP: { API_FAH: 68, API_CELSIUS: 20, From 022fa1ee678d0e26845bc970669b3da9019a38f1 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 1 Jun 2023 02:03:55 +0100 Subject: [PATCH 010/857] Always update Filter sensors attr on new_state (#89096) * always update attr * reset filter on unit change --- homeassistant/components/filter/sensor.py | 27 +++++++++-------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 9b1e2250a28..a733040da01 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -18,10 +18,8 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import get_instance, history from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASSES as SENSOR_DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, - STATE_CLASSES as SENSOR_STATE_CLASSES, SensorDeviceClass, SensorEntity, ) @@ -273,22 +271,15 @@ class SensorFilter(SensorEntity): self._state = temp_state.state - if self._attr_icon is None: - self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) - if ( - self._attr_device_class is None - and new_state.attributes.get(ATTR_DEVICE_CLASS) in SENSOR_DEVICE_CLASSES + if self._attr_native_unit_of_measurement != new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT ): - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) - - if ( - self._attr_state_class is None - and new_state.attributes.get(ATTR_STATE_CLASS) in SENSOR_STATE_CLASSES - ): - self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) - - if self._attr_native_unit_of_measurement is None: + for filt in self._filters: + filt.reset() self._attr_native_unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT ) @@ -460,6 +451,10 @@ class Filter: """Return whether the current filter_state should be skipped.""" return self._skip_processing + def reset(self) -> None: + """Reset filter.""" + self.states.clear() + def _filter_state(self, new_state: FilterState) -> FilterState: """Implement filter.""" raise NotImplementedError() From 046ae8eb1e6e305c800dba7caf38eba78762e8f0 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 1 Jun 2023 02:10:15 +0100 Subject: [PATCH 011/857] Delay filter integration until after HA has started (#91034) * delay filter start * Update homeassistant/components/filter/sensor.py * Update homeassistant/components/filter/sensor.py --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/filter/sensor.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index a733040da01..a1470baa4d2 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -39,6 +39,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util @@ -351,11 +352,16 @@ class SensorFilter(SensorEntity): if state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, None]: self._update_filter_sensor_state(state, False) - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._entity], self._update_filter_sensor_state_event + @callback + def _async_hass_started(hass: HomeAssistant) -> None: + """Delay source entity tracking.""" + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._entity], self._update_filter_sensor_state_event + ) ) - ) + + self.async_on_remove(async_at_started(self.hass, _async_hass_started)) @property def native_value(self) -> datetime | StateType: From 7995d3777a9ccf8384b4a0e8481913aa26a46c1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 31 May 2023 20:12:53 -0500 Subject: [PATCH 012/857] Fix package names to match pypi index metadata (#93883) * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * Fix package names to match pypi index metadata * uses _ * uses - * fix metadata --- .github/workflows/builder.yml | 2 +- .github/workflows/wheels.yml | 4 +- .../components/advantage_air/manifest.json | 2 +- .../components/airthings/manifest.json | 2 +- .../components/alpha_vantage/manifest.json | 2 +- .../components/ambiclimate/manifest.json | 2 +- .../components/anel_pwrctrl/manifest.json | 2 +- .../components/aquostv/manifest.json | 2 +- .../components/asterisk_mbox/manifest.json | 2 +- homeassistant/components/atome/manifest.json | 2 +- homeassistant/components/awair/manifest.json | 2 +- .../components/beewi_smartclim/manifest.json | 2 +- homeassistant/components/blebox/manifest.json | 2 +- .../components/blinksticklight/manifest.json | 2 +- .../bluetooth_tracker/manifest.json | 2 +- .../bmw_connected_drive/manifest.json | 2 +- .../components/bt_smarthub/manifest.json | 2 +- homeassistant/components/cast/manifest.json | 2 +- .../components/co2signal/manifest.json | 2 +- .../components/decora_wifi/manifest.json | 2 +- .../components/discogs/manifest.json | 2 +- .../components/dlib_face_detect/manifest.json | 2 +- .../dlib_face_identify/manifest.json | 2 +- homeassistant/components/doods/manifest.json | 2 +- .../components/doorbird/manifest.json | 2 +- homeassistant/components/dsmr/manifest.json | 2 +- .../components/dynalite/manifest.json | 2 +- .../components/eight_sleep/manifest.json | 2 +- .../components/electrasmart/manifest.json | 2 +- homeassistant/components/elmax/manifest.json | 2 +- homeassistant/components/emby/manifest.json | 2 +- .../components/emulated_hue/manifest.json | 2 +- .../components/emulated_roku/manifest.json | 2 +- .../components/enphase_envoy/manifest.json | 2 +- .../environment_canada/manifest.json | 2 +- .../components/eufylife_ble/manifest.json | 2 +- homeassistant/components/flume/manifest.json | 2 +- .../components/flux_led/manifest.json | 2 +- homeassistant/components/foobot/manifest.json | 2 +- .../components/forecast_solar/manifest.json | 2 +- .../components/fronius/manifest.json | 2 +- homeassistant/components/gdacs/manifest.json | 2 +- .../components/generic/manifest.json | 2 +- .../components/geo_json_events/manifest.json | 2 +- .../components/geo_rss_events/manifest.json | 2 +- .../components/geonetnz_quakes/manifest.json | 2 +- .../components/geonetnz_volcano/manifest.json | 2 +- .../components/glances/manifest.json | 2 +- .../components/goalfeed/manifest.json | 2 +- .../components/greeneye_monitor/manifest.json | 2 +- .../components/here_travel_time/manifest.json | 2 +- .../components/hikvision/manifest.json | 2 +- .../components/honeywell/manifest.json | 2 +- homeassistant/components/http/manifest.json | 2 +- .../components/hydrawise/manifest.json | 2 +- .../components/ibeacon/manifest.json | 2 +- .../components/ign_sismologia/manifest.json | 2 +- .../components/image_upload/manifest.json | 2 +- .../islamic_prayer_times/manifest.json | 2 +- .../components/keenetic_ndms2/manifest.json | 2 +- homeassistant/components/knx/manifest.json | 2 +- .../components/laundrify/manifest.json | 2 +- homeassistant/components/lifx/manifest.json | 4 +- .../components/logi_circle/manifest.json | 2 +- .../components/media_extractor/manifest.json | 2 +- homeassistant/components/met/manifest.json | 2 +- .../components/met_eireann/manifest.json | 2 +- .../components/modem_callerid/manifest.json | 2 +- homeassistant/components/mopeka/manifest.json | 2 +- homeassistant/components/nad/manifest.json | 2 +- .../components/nextbus/manifest.json | 2 +- homeassistant/components/nina/manifest.json | 2 +- .../components/norway_air/manifest.json | 2 +- .../nsw_rural_fire_service_feed/manifest.json | 2 +- .../components/panasonic_viera/manifest.json | 2 +- homeassistant/components/plex/manifest.json | 2 +- .../components/progettihwsw/manifest.json | 2 +- .../components/prometheus/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- .../components/pushover/manifest.json | 2 +- .../components/python_script/manifest.json | 2 +- .../components/qld_bushfire/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/rachio/manifest.json | 2 +- .../components/recorder/manifest.json | 2 +- homeassistant/components/ring/manifest.json | 2 +- .../components/russound_rio/manifest.json | 2 +- .../components/satel_integra/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- .../components/seven_segments/manifest.json | 2 +- .../components/sighthound/manifest.json | 2 +- .../components/sony_projector/manifest.json | 2 +- homeassistant/components/splunk/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- .../swiss_public_transport/manifest.json | 2 +- .../components/switchmate/manifest.json | 2 +- .../components/syncthru/manifest.json | 2 +- .../components/tank_utility/manifest.json | 2 +- .../components/tasmota/manifest.json | 2 +- .../components/tensorflow/manifest.json | 2 +- .../thermoworks_smoke/manifest.json | 2 +- .../components/totalconnect/manifest.json | 2 +- .../components/tplink_omada/manifest.json | 2 +- homeassistant/components/upb/manifest.json | 2 +- .../usgs_earthquakes_feed/manifest.json | 2 +- .../components/wolflink/manifest.json | 2 +- .../components/zhong_hong/manifest.json | 2 +- .../components/zwave_me/manifest.json | 2 +- homeassistant/package_constraints.txt | 18 +- pyproject.toml | 6 +- requirements.txt | 6 +- requirements_all.txt | 356 +++++++++--------- requirements_test_all.txt | 254 ++++++------- script/gen_requirements_all.py | 11 +- 114 files changed, 438 insertions(+), 433 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 063342cc6b7..421579951d4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -182,7 +182,7 @@ jobs: # will drop the platform in the near future (they consider it # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. - sed -i "s|env_canada|# env_canada|g" requirements_all.txt + sed -i "s|env-canada|# env-canada|g" requirements_all.txt sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c735a446938..961459090c6 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -274,7 +274,7 @@ jobs: sed -i "s|# evdev|evdev|g" ${requirement_file} sed -i "s|# pycups|pycups|g" ${requirement_file} sed -i "s|# homekit|homekit|g" ${requirement_file} - sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# decora-wifi|decora-wifi|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} # Some packages are not buildable on armhf anymore @@ -284,7 +284,7 @@ jobs: # will drop the platform in the near future (they consider it # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. - sed -i "s|env_canada|# env_canada|g" ${requirement_file} + sed -i "s|env-canada|# env-canada|g" ${requirement_file} sed -i "s|noaa-coops|# noaa-coops|g" ${requirement_file} sed -i "s|pyezviz|# pyezviz|g" ${requirement_file} sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index ed9d3bff989..a07d14896eb 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["advantage_air"], "quality_scale": "platinum", - "requirements": ["advantage_air==0.4.4"] + "requirements": ["advantage-air==0.4.4"] } diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index 6e30048d844..da7f30679c6 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airthings", "iot_class": "cloud_polling", "loggers": ["airthings"], - "requirements": ["airthings_cloud==0.1.0"] + "requirements": ["airthings-cloud==0.1.0"] } diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json index 66de4b6a5f8..c94da6bf487 100644 --- a/homeassistant/components/alpha_vantage/manifest.json +++ b/homeassistant/components/alpha_vantage/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/alpha_vantage", "iot_class": "cloud_polling", "loggers": ["alpha_vantage"], - "requirements": ["alpha_vantage==2.3.1"] + "requirements": ["alpha-vantage==2.3.1"] } diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index f2fd0ea5d77..315490b2d62 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ambiclimate", "iot_class": "cloud_polling", "loggers": ["ambiclimate"], - "requirements": ["ambiclimate==0.2.1"] + "requirements": ["Ambiclimate==0.2.1"] } diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json index f1de40bc89e..48cc3b96ec0 100644 --- a/homeassistant/components/anel_pwrctrl/manifest.json +++ b/homeassistant/components/anel_pwrctrl/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/anel_pwrctrl", "iot_class": "local_polling", "loggers": ["anel_pwrctrl"], - "requirements": ["anel_pwrctrl-homeassistant==0.0.1.dev2"] + "requirements": ["anel-pwrctrl-homeassistant==0.0.1.dev2"] } diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 1bac2bdfb5f..011b8e67a19 100644 --- a/homeassistant/components/aquostv/manifest.json +++ b/homeassistant/components/aquostv/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aquostv", "iot_class": "local_polling", "loggers": ["sharp_aquos_rc"], - "requirements": ["sharp_aquos_rc==0.3.2"] + "requirements": ["sharp-aquos-rc==0.3.2"] } diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json index 8348e40ba6b..840c48aff2a 100644 --- a/homeassistant/components/asterisk_mbox/manifest.json +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox", "iot_class": "local_push", "loggers": ["asterisk_mbox"], - "requirements": ["asterisk_mbox==0.5.0"] + "requirements": ["asterisk-mbox==0.5.0"] } diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json index 236bf6cb082..cafe24e2e13 100644 --- a/homeassistant/components/atome/manifest.json +++ b/homeassistant/components/atome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/atome", "iot_class": "cloud_polling", "loggers": ["pyatome"], - "requirements": ["pyatome==0.1.1"] + "requirements": ["pyAtome==0.1.1"] } diff --git a/homeassistant/components/awair/manifest.json b/homeassistant/components/awair/manifest.json index 19e3339cef6..25257bc3e1c 100644 --- a/homeassistant/components/awair/manifest.json +++ b/homeassistant/components/awair/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/awair", "iot_class": "local_polling", "loggers": ["python_awair"], - "requirements": ["python_awair==0.2.4"], + "requirements": ["python-awair==0.2.4"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json index f238c76d366..3555f9181bb 100644 --- a/homeassistant/components/beewi_smartclim/manifest.json +++ b/homeassistant/components/beewi_smartclim/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/beewi_smartclim", "iot_class": "local_polling", "loggers": ["beewi_smartclim"], - "requirements": ["beewi_smartclim==0.0.10"] + "requirements": ["beewi-smartclim==0.0.10"] } diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 8cb7ddb5c1e..b639e28d698 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/blebox", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox_uniapi==2.1.4"], + "requirements": ["blebox-uniapi==2.1.4"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json index 70e5c2a4672..e3a6638f2a9 100644 --- a/homeassistant/components/blinksticklight/manifest.json +++ b/homeassistant/components/blinksticklight/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/blinksticklight", "iot_class": "local_polling", "loggers": ["blinkstick"], - "requirements": ["blinkstick==1.2.0"] + "requirements": ["BlinkStick==1.2.0"] } diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json index b1411a41f87..0a0356e6669 100644 --- a/homeassistant/components/bluetooth_tracker/manifest.json +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker", "iot_class": "local_polling", "loggers": ["bluetooth", "bt_proximity"], - "requirements": ["bt_proximity==0.2.1", "pybluez==0.22"] + "requirements": ["bt-proximity==0.2.1", "PyBluez==0.22"] } diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c9612d00c64..a719cbdf3d0 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.13.6"] + "requirements": ["bimmer-connected==0.13.6"] } diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json index 90f5d92a0a2..8f2dc631e80 100644 --- a/homeassistant/components/bt_smarthub/manifest.json +++ b/homeassistant/components/bt_smarthub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/bt_smarthub", "iot_class": "local_polling", "loggers": ["btsmarthub_devicelist"], - "requirements": ["btsmarthub_devicelist==0.2.3"] + "requirements": ["btsmarthub-devicelist==0.2.3"] } diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 48921303ce0..7cf318f12a6 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["pychromecast==13.0.7"], + "requirements": ["PyChromecast==13.0.7"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 801718b88a7..b4dc01d03aa 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/co2signal", "iot_class": "cloud_polling", "loggers": ["CO2Signal"], - "requirements": ["co2signal==0.4.2"] + "requirements": ["CO2Signal==0.4.2"] } diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json index 592942ee99b..0bead527e78 100644 --- a/homeassistant/components/decora_wifi/manifest.json +++ b/homeassistant/components/decora_wifi/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/decora_wifi", "iot_class": "cloud_polling", "loggers": ["decora_wifi"], - "requirements": ["decora_wifi==1.4"] + "requirements": ["decora-wifi==1.4"] } diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json index 2b405341841..fceb214aded 100644 --- a/homeassistant/components/discogs/manifest.json +++ b/homeassistant/components/discogs/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/discogs", "iot_class": "cloud_polling", "loggers": ["discogs_client"], - "requirements": ["discogs_client==2.3.0"] + "requirements": ["discogs-client==2.3.0"] } diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json index 33811d5821c..e395a84f206 100644 --- a/homeassistant/components/dlib_face_detect/manifest.json +++ b/homeassistant/components/dlib_face_detect/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dlib_face_detect", "iot_class": "local_push", "loggers": ["face_recognition"], - "requirements": ["face_recognition==1.2.3"] + "requirements": ["face-recognition==1.2.3"] } diff --git a/homeassistant/components/dlib_face_identify/manifest.json b/homeassistant/components/dlib_face_identify/manifest.json index 34cc7344cd9..60c0ef3c766 100644 --- a/homeassistant/components/dlib_face_identify/manifest.json +++ b/homeassistant/components/dlib_face_identify/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dlib_face_identify", "iot_class": "local_push", "loggers": ["face_recognition"], - "requirements": ["face_recognition==1.2.3"] + "requirements": ["face-recognition==1.2.3"] } diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 79c114e2f38..52c89f3f34b 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "pillow==9.5.0"] + "requirements": ["pydoods==1.0.2", "Pillow==9.5.0"] } diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index d6eba115bb8..2bb981ab06f 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/doorbird", "iot_class": "local_push", "loggers": ["doorbirdpy"], - "requirements": ["doorbirdpy==2.1.0"], + "requirements": ["DoorBirdPy==2.1.0"], "zeroconf": [ { "type": "_axis-video._tcp.local.", diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 2ba7ce55835..3fc81d2f8e7 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr_parser==0.33"] + "requirements": ["dsmr-parser==0.33"] } diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index a3dd890cc11..8fd138dc49b 100644 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "iot_class": "local_push", "loggers": ["dynalite_devices_lib"], - "requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.4"] + "requirements": ["dynalite-devices==0.1.47", "dynalite-panel==0.0.4"] } diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index b95e24823d6..71e01f75d46 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/eight_sleep", "iot_class": "cloud_polling", "loggers": ["pyeight"], - "requirements": ["pyeight==0.3.2"] + "requirements": ["pyEight==0.3.2"] } diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json index a2a3f928eeb..405d9ee688a 100644 --- a/homeassistant/components/electrasmart/manifest.json +++ b/homeassistant/components/electrasmart/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/electrasmart", "iot_class": "cloud_polling", - "requirements": ["pyelectra==1.2.0"] + "requirements": ["pyElectra==1.2.0"] } diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index e6e8d76be91..dfb90763c83 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax_api==0.0.4"] + "requirements": ["elmax-api==0.0.4"] } diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json index 872b3cca1e1..340f2395033 100644 --- a/homeassistant/components/emby/manifest.json +++ b/homeassistant/components/emby/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/emby", "iot_class": "local_push", "loggers": ["pyemby"], - "requirements": ["pyemby==1.8"] + "requirements": ["pyEmby==1.8"] } diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index ff3591e0066..01dae2dca77 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "requirements": ["aiohttp-cors==0.7.0"] } diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json index 960b3d41f63..739f3b04ec0 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_roku", "iot_class": "local_push", "loggers": ["emulated_roku"], - "requirements": ["emulated_roku==0.2.1"] + "requirements": ["emulated-roku==0.2.1"] } diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 447c9034309..28a8d0ba28a 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["envoy_reader"], - "requirements": ["envoy_reader==0.20.1"], + "requirements": ["envoy-reader==0.20.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 6262a28302f..8fba07198f2 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.34"] + "requirements": ["env-canada==0.5.34"] } diff --git a/homeassistant/components/eufylife_ble/manifest.json b/homeassistant/components/eufylife_ble/manifest.json index ad70dd97d58..c3a2357ebca 100644 --- a/homeassistant/components/eufylife_ble/manifest.json +++ b/homeassistant/components/eufylife_ble/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/eufylife_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["eufylife_ble_client==0.1.7"] + "requirements": ["eufylife-ble-client==0.1.7"] } diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index f3b2bacbafe..17a2b0b53be 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/flume/", "iot_class": "cloud_polling", "loggers": ["pyflume"], - "requirements": ["pyflume==0.6.5"] + "requirements": ["PyFlume==0.6.5"] } diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index a6e8183bcdb..13f7ba36bcd 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -51,5 +51,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux_led==0.28.37"] + "requirements": ["flux-led==0.28.37"] } diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json index a517f1fea6f..890cd95784c 100644 --- a/homeassistant/components/foobot/manifest.json +++ b/homeassistant/components/foobot/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/foobot", "iot_class": "cloud_polling", "loggers": ["foobot_async"], - "requirements": ["foobot_async==1.0.0"] + "requirements": ["foobot-async==1.0.0"] } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index ac6a3f7c308..94b603e108c 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["forecast_solar==3.0.0"] + "requirements": ["forecast-solar==3.0.0"] } diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 7120530c973..ecf3f81b380 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["pyfronius"], "quality_scale": "platinum", - "requirements": ["pyfronius==0.7.1"] + "requirements": ["PyFronius==0.7.1"] } diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index 86904e3e9bc..b6fb3d8cee3 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", - "requirements": ["aio_georss_gdacs==0.8"] + "requirements": ["aio-georss-gdacs==0.8"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index fc06155121b..134ce00ef70 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.0", "pillow==9.5.0"] + "requirements": ["ha-av==10.1.0", "Pillow==9.5.0"] } diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index b02339eb20a..9f77f9b112e 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_generic_client"], - "requirements": ["aio_geojson_generic_client==0.3"] + "requirements": ["aio-geojson-generic-client==0.3"] } diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index 3ed5418fa0f..bdf8f126680 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", "iot_class": "cloud_polling", "loggers": ["georss_client", "georss_generic_client"], - "requirements": ["georss_generic_client==0.6"] + "requirements": ["georss-generic-client==0.6"] } diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 74ca6406782..9ed59b2bc97 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_quakes"], "quality_scale": "platinum", - "requirements": ["aio_geojson_geonetnz_quakes==0.15"] + "requirements": ["aio-geojson-geonetnz-quakes==0.15"] } diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json index c6cffad477d..6e9503e0243 100644 --- a/homeassistant/components/geonetnz_volcano/manifest.json +++ b/homeassistant/components/geonetnz_volcano/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_volcano"], - "requirements": ["aio_geojson_geonetnz_volcano==0.8"] + "requirements": ["aio-geojson-geonetnz-volcano==0.8"] } diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index 767a27ffdfd..2f335441e41 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances_api==0.4.2"] + "requirements": ["glances-api==0.4.2"] } diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json index 077596b0185..3ce7ffb8065 100644 --- a/homeassistant/components/goalfeed/manifest.json +++ b/homeassistant/components/goalfeed/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/goalfeed", "iot_class": "cloud_push", "loggers": ["pysher"], - "requirements": ["pysher==1.0.7"] + "requirements": ["Pysher==1.0.7"] } diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json index fcf4d004d26..33a4947c01d 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", "iot_class": "local_push", "loggers": ["greeneye"], - "requirements": ["greeneye_monitor==3.0.3"] + "requirements": ["greeneye-monitor==3.0.3"] } diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index f024b55d009..19c5c4d73d9 100644 --- a/homeassistant/components/here_travel_time/manifest.json +++ b/homeassistant/components/here_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/here_travel_time", "iot_class": "cloud_polling", "loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"], - "requirements": ["here_routing==0.2.0", "here_transit==1.2.0"] + "requirements": ["here-routing==0.2.0", "here-transit==1.2.0"] } diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index 1e175a2a0df..e37e149ccda 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/hikvision", "iot_class": "local_push", "loggers": ["pyhik"], - "requirements": ["pyhik==0.3.2"] + "requirements": ["pyHik==0.3.2"] } diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 8f3b66ddeac..16b07e91446 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["aiosomecomfort==0.0.14"] + "requirements": ["AIOSomecomfort==0.0.14"] } diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index bce425adbdb..dec1b9485b6 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "requirements": ["aiohttp-cors==0.7.0"] } diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 2489317a6a2..fc88c08b27a 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["hydrawiser"], - "requirements": ["hydrawiser==0.2"] + "requirements": ["Hydrawiser==0.2"] } diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index a805277cb71..6f00f63b090 100644 --- a/homeassistant/components/ibeacon/manifest.json +++ b/homeassistant/components/ibeacon/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ibeacon", "iot_class": "local_push", "loggers": ["bleak"], - "requirements": ["ibeacon_ble==1.0.1"] + "requirements": ["ibeacon-ble==1.0.1"] } diff --git a/homeassistant/components/ign_sismologia/manifest.json b/homeassistant/components/ign_sismologia/manifest.json index 0fc69a7ba19..6eeea6b4a02 100644 --- a/homeassistant/components/ign_sismologia/manifest.json +++ b/homeassistant/components/ign_sismologia/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_ign_sismologia_client"], - "requirements": ["georss_ign_sismologia_client==0.6"] + "requirements": ["georss-ign-sismologia-client==0.6"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 947c3cb67d5..48c57fb5d03 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["pillow==9.5.0"] + "requirements": ["Pillow==9.5.0"] } diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json index 7e68ba9b24d..c87cb2d28ac 100644 --- a/homeassistant/components/islamic_prayer_times/manifest.json +++ b/homeassistant/components/islamic_prayer_times/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times", "iot_class": "cloud_polling", "loggers": ["prayer_times_calculator"], - "requirements": ["prayer_times_calculator==0.0.6"] + "requirements": ["prayer-times-calculator==0.0.6"] } diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 41a1d0f2a2f..0751b40acd2 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2", "iot_class": "local_polling", "loggers": ["ndms2_client"], - "requirements": ["ndms2_client==0.1.2"], + "requirements": ["ndms2-client==0.1.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index ba706c756cb..92c44f87b26 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,6 +13,6 @@ "requirements": [ "xknx==2.10.0", "xknxproject==3.1.0", - "knx_frontend==2023.5.31.141540" + "knx-frontend==2023.5.31.141540" ] } diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index b9469f79e65..8dca67058b7 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/laundrify", "iot_class": "cloud_polling", - "requirements": ["laundrify_aio==1.1.2"] + "requirements": ["laundrify-aio==1.1.2"] } diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index e867bb65eb0..d6b253bd478 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -42,7 +42,7 @@ "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.10", - "aiolifx_effects==0.3.2", - "aiolifx_themes==0.4.5" + "aiolifx-effects==0.3.2", + "aiolifx-themes==0.4.5" ] } diff --git a/homeassistant/components/logi_circle/manifest.json b/homeassistant/components/logi_circle/manifest.json index 2f08fe6f135..f4f65b22505 100644 --- a/homeassistant/components/logi_circle/manifest.json +++ b/homeassistant/components/logi_circle/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/logi_circle", "iot_class": "cloud_polling", "loggers": ["logi_circle"], - "requirements": ["logi_circle==0.2.3"] + "requirements": ["logi-circle==0.2.3"] } diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index c358b29062a..e463a456e33 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["youtube_dl"], "quality_scale": "internal", - "requirements": ["youtube_dl==2021.12.17"] + "requirements": ["youtube-dl==2021.12.17"] } diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index 32d37e9b4ff..5c476b10665 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.10.0"] + "requirements": ["PyMetno==0.10.0"] } diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json index 1e05787158a..72afc6977dd 100644 --- a/homeassistant/components/met_eireann/manifest.json +++ b/homeassistant/components/met_eireann/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met_eireann", "iot_class": "cloud_polling", "loggers": ["meteireann"], - "requirements": ["pyMetEireann==2021.8.0"] + "requirements": ["PyMetEireann==2021.8.0"] } diff --git a/homeassistant/components/modem_callerid/manifest.json b/homeassistant/components/modem_callerid/manifest.json index 1ff348fb3b7..34e5be43155 100644 --- a/homeassistant/components/modem_callerid/manifest.json +++ b/homeassistant/components/modem_callerid/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["phone_modem"], - "requirements": ["phone_modem==0.1.1"], + "requirements": ["phone-modem==0.1.1"], "usb": [ { "vid": "0572", diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index 71104192153..d6b5618bf97 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka_iot_ble==0.4.1"] + "requirements": ["mopeka-iot-ble==0.4.1"] } diff --git a/homeassistant/components/nad/manifest.json b/homeassistant/components/nad/manifest.json index dd354086a1a..2e2d44341af 100644 --- a/homeassistant/components/nad/manifest.json +++ b/homeassistant/components/nad/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/nad", "iot_class": "local_polling", "loggers": ["nad_receiver"], - "requirements": ["nad_receiver==0.3.0"] + "requirements": ["nad-receiver==0.3.0"] } diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index b77ffa86f03..4b8bd1a9294 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py_nextbusnext==0.1.5"] + "requirements": ["py-nextbusnext==0.1.5"] } diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 6386a70d08b..98a088620ea 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["pynina==0.3.0"] + "requirements": ["PyNINA==0.3.0"] } diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index d04e07f0214..4a3fc7cee96 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.10.0"] + "requirements": ["PyMetno==0.10.0"] } diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json index 02f7b985b3b..cea62996e6d 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json +++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_nsw_rfs_incidents"], - "requirements": ["aio_geojson_nsw_rfs_incidents==0.6"] + "requirements": ["aio-geojson-nsw-rfs-incidents==0.6"] } diff --git a/homeassistant/components/panasonic_viera/manifest.json b/homeassistant/components/panasonic_viera/manifest.json index d626ae2bf9e..2afa6599cb2 100644 --- a/homeassistant/components/panasonic_viera/manifest.json +++ b/homeassistant/components/panasonic_viera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/panasonic_viera", "iot_class": "local_polling", "loggers": ["panasonic_viera"], - "requirements": ["panasonic_viera==0.3.6"] + "requirements": ["panasonic-viera==0.3.6"] } diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 4c4ed8d8d0a..bc0c54c49bf 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "plexapi==4.13.2", + "PlexAPI==4.13.2", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/homeassistant/components/progettihwsw/manifest.json b/homeassistant/components/progettihwsw/manifest.json index e22abd6dd4a..6cad66e1360 100644 --- a/homeassistant/components/progettihwsw/manifest.json +++ b/homeassistant/components/progettihwsw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/progettihwsw", "iot_class": "local_polling", "loggers": ["ProgettiHWSW"], - "requirements": ["progettihwsw==0.1.1"] + "requirements": ["ProgettiHWSW==0.1.1"] } diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json index dbbe8a1c9fc..8ec332c1daf 100644 --- a/homeassistant/components/prometheus/manifest.json +++ b/homeassistant/components/prometheus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prometheus", "iot_class": "assumed_state", "loggers": ["prometheus_client"], - "requirements": ["prometheus_client==0.7.1"] + "requirements": ["prometheus-client==0.7.1"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 7ebaa6e53dd..88a2a6c9b0f 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==9.5.0"] + "requirements": ["Pillow==9.5.0"] } diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index d086321c088..3b538f756e0 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pushover", "iot_class": "cloud_push", "loggers": ["pushover_complete"], - "requirements": ["pushover_complete==1.1.1"] + "requirements": ["pushover-complete==1.1.1"] } diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index eb6cfe236e0..63aa2f2f916 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["restrictedpython==6.0"] + "requirements": ["RestrictedPython==6.0"] } diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json index e21371d96af..5e7d9948309 100644 --- a/homeassistant/components/qld_bushfire/manifest.json +++ b/homeassistant/components/qld_bushfire/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_qld_bushfire_alert_client"], - "requirements": ["georss_qld_bushfire_alert_client==0.5"] + "requirements": ["georss-qld-bushfire-alert-client==0.5"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 787255187cc..a19760ad989 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["pillow==9.5.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==9.5.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json index 14582134e84..e58341633b1 100644 --- a/homeassistant/components/rachio/manifest.json +++ b/homeassistant/components/rachio/manifest.json @@ -25,7 +25,7 @@ }, "iot_class": "cloud_push", "loggers": ["rachiopy"], - "requirements": ["rachiopy==1.0.3"], + "requirements": ["RachioPy==1.0.3"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 33c6a516c65..2e868542457 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "sqlalchemy==2.0.15", + "SQLAlchemy==2.0.15", "fnv-hash-fast==0.3.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 7cb34b4d71f..355c630272e 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring_doorbell==0.7.2"] + "requirements": ["ring-doorbell==0.7.2"] } diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 728c40121e0..70d519c16bd 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/russound_rio", "iot_class": "local_push", "loggers": ["russound_rio"], - "requirements": ["russound_rio==0.1.8"] + "requirements": ["russound-rio==0.1.8"] } diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index ffb2c1a3af2..828261aa466 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/satel_integra", "iot_class": "local_push", "loggers": ["satel_integra"], - "requirements": ["satel_integra==0.3.7"] + "requirements": ["satel-integra==0.3.7"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 72072d36031..257baae12f5 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense_energy==0.11.2"] + "requirements": ["sense-energy==0.11.2"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 90c5bf59fa3..e9b2e9e2e9c 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["pillow==9.5.0"] + "requirements": ["Pillow==9.5.0"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 1b6fbe9548d..2fdf15a4a10 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["pillow==9.5.0", "simplehound==0.3"] + "requirements": ["Pillow==9.5.0", "simplehound==0.3"] } diff --git a/homeassistant/components/sony_projector/manifest.json b/homeassistant/components/sony_projector/manifest.json index d6637b4232b..5cf5df4c96f 100644 --- a/homeassistant/components/sony_projector/manifest.json +++ b/homeassistant/components/sony_projector/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sony_projector", "iot_class": "local_polling", "loggers": ["pysdcp"], - "requirements": ["pysdcp==1"] + "requirements": ["pySDCP==1"] } diff --git a/homeassistant/components/splunk/manifest.json b/homeassistant/components/splunk/manifest.json index d889c1437d0..947af317b35 100644 --- a/homeassistant/components/splunk/manifest.json +++ b/homeassistant/components/splunk/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/splunk", "iot_class": "local_push", "loggers": ["hass_splunk"], - "requirements": ["hass_splunk==0.1.1"] + "requirements": ["hass-splunk==0.1.1"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 4d3e95d6b30..44de8fc6923 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.15"] + "requirements": ["SQLAlchemy==2.0.15"] } diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json index 189e93d3650..fd9908bffeb 100644 --- a/homeassistant/components/swiss_public_transport/manifest.json +++ b/homeassistant/components/swiss_public_transport/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/swiss_public_transport", "iot_class": "cloud_polling", "loggers": ["opendata_transport"], - "requirements": ["python_opendata_transport==0.3.0"] + "requirements": ["python-opendata-transport==0.3.0"] } diff --git a/homeassistant/components/switchmate/manifest.json b/homeassistant/components/switchmate/manifest.json index 7f4d3de5b0e..5467dc512c3 100644 --- a/homeassistant/components/switchmate/manifest.json +++ b/homeassistant/components/switchmate/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchmate", "iot_class": "local_polling", "loggers": ["switchmate"], - "requirements": ["pySwitchmate==0.5.1"] + "requirements": ["PySwitchmate==0.5.1"] } diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index d67e93aa962..a93e02a51c7 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/syncthru", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["pysyncthru==0.7.10", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.7.10", "url-normalize==1.4.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/tank_utility/manifest.json b/homeassistant/components/tank_utility/manifest.json index dcd206e0c1c..3f4d7bbaa15 100644 --- a/homeassistant/components/tank_utility/manifest.json +++ b/homeassistant/components/tank_utility/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/tank_utility", "iot_class": "cloud_polling", "loggers": ["tank_utility"], - "requirements": ["tank_utility==1.4.1"] + "requirements": ["tank-utility==1.4.1"] } diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index a5a8ed2f0d2..f235256f772 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["hatasmota==0.6.5"] + "requirements": ["HATasmota==0.6.5"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 2178930199d..672bd899962 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.1", "numpy==1.23.2", - "pillow==9.5.0" + "Pillow==9.5.0" ] } diff --git a/homeassistant/components/thermoworks_smoke/manifest.json b/homeassistant/components/thermoworks_smoke/manifest.json index 6e112d78e48..43ce96dd012 100644 --- a/homeassistant/components/thermoworks_smoke/manifest.json +++ b/homeassistant/components/thermoworks_smoke/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/thermoworks_smoke", "iot_class": "cloud_polling", "loggers": ["thermoworks_smoke"], - "requirements": ["stringcase==1.2.0", "thermoworks_smoke==0.1.8"] + "requirements": ["stringcase==1.2.0", "thermoworks-smoke==0.1.8"] } diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 8e0d58b7b77..a81e7518132 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total_connect_client==2023.2"] + "requirements": ["total-connect-client==2023.2"] } diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 280ae56bbd5..795e6adf5b7 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.2.4"] + "requirements": ["tplink_omada_client==1.2.4"] } diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 00cebe1e0d9..240660ac89f 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb_lib==0.5.4"] + "requirements": ["upb-lib==0.5.4"] } diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json index 09ff6c106df..6dbe43cb4e3 100644 --- a/homeassistant/components/usgs_earthquakes_feed/manifest.json +++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_usgs_earthquakes"], - "requirements": ["aio_geojson_usgs_earthquakes==0.2"] + "requirements": ["aio-geojson-usgs-earthquakes==0.2"] } diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index d01ca126781..0d793385a3b 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_smartset"], - "requirements": ["wolf_smartset==0.1.11"] + "requirements": ["wolf-smartset==0.1.11"] } diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json index 637f468b89b..77f85c9dfcd 100644 --- a/homeassistant/components/zhong_hong/manifest.json +++ b/homeassistant/components/zhong_hong/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/zhong_hong", "iot_class": "local_push", "loggers": ["zhong_hong_hvac"], - "requirements": ["zhong_hong_hvac==1.0.9"] + "requirements": ["zhong-hong-hvac==1.0.9"] } diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 5870874efa8..d5c5a69cb96 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave_me_ws==0.4.3", "url-normalize==1.4.3"], + "requirements": ["zwave-me-ws==0.4.3", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dd2f8b44fb4..6f90157f65e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,12 +1,9 @@ -PyJWT==2.7.0 -PyNaCl==1.5.0 -PyTurboJPEG==1.6.7 aiodiscover==1.4.16 +aiohttp-cors==0.7.0 aiohttp==3.8.4 -aiohttp_cors==0.7.0 astral==2.2 +async-timeout==4.0.2 async-upnp-client==0.33.2 -async_timeout==4.0.2 atomicwrites-homeassistant==1.4.1 attrs==22.2.0 awesomeversion==22.9.0 @@ -30,22 +27,25 @@ home-assistant-intents==2023.5.30 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 -jinja2==3.1.2 +Jinja2==3.1.2 lru-dict==1.1.8 mutagen==1.46.0 orjson==3.8.12 paho-mqtt==1.6.1 -pillow==9.5.0 +Pillow==9.5.0 pip>=21.0,<23.2 psutil-home-assistant==0.0.1 +PyJWT==2.7.0 +PyNaCl==1.5.0 pyOpenSSL==23.1.0 pyserial==3.5 python-slugify==4.0.1 +PyTurboJPEG==1.6.7 pyudev==0.23.2 -pyyaml==6.0 +PyYAML==6.0 requests==2.31.0 scapy==2.5.0 -sqlalchemy==2.0.15 +SQLAlchemy==2.0.15 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous-serialize==2.6.0 diff --git a/pyproject.toml b/pyproject.toml index 93edb0076e6..3868e85988b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ requires-python = ">=3.10.0" dependencies = [ "aiohttp==3.8.4", "astral==2.2", - "async_timeout==4.0.2", + "async-timeout==4.0.2", "attrs==22.2.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==22.9.0", @@ -37,7 +37,7 @@ dependencies = [ "httpx==0.24.1", "home-assistant-bluetooth==1.10.0", "ifaddr==0.2.0", - "jinja2==3.1.2", + "Jinja2==3.1.2", "lru-dict==1.1.8", "PyJWT==2.7.0", # PyJWT has loose dependency. We want the latest one. @@ -47,7 +47,7 @@ dependencies = [ "orjson==3.8.12", "pip>=21.0,<23.2", "python-slugify==4.0.1", - "pyyaml==6.0", + "PyYAML==6.0", "requests==2.31.0", "typing-extensions>=4.5.0,<5.0", "ulid-transform==0.7.2", diff --git a/requirements.txt b/requirements.txt index 818eeec8515..6a2630e2ab4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ # Home Assistant Core aiohttp==3.8.4 astral==2.2 -async_timeout==4.0.2 +async-timeout==4.0.2 attrs==22.2.0 atomicwrites-homeassistant==1.4.1 awesomeversion==22.9.0 @@ -13,7 +13,7 @@ ciso8601==2.3.0 httpx==0.24.1 home-assistant-bluetooth==1.10.0 ifaddr==0.2.0 -jinja2==3.1.2 +Jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.7.0 cryptography==40.0.2 @@ -21,7 +21,7 @@ pyOpenSSL==23.1.0 orjson==3.8.12 pip>=21.0,<23.2 python-slugify==4.0.1 -pyyaml==6.0 +PyYAML==6.0 requests==2.31.0 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.2 diff --git a/requirements_all.txt b/requirements_all.txt index f6063a35016..f76656b4127 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,24 +7,83 @@ AEMET-OpenData==0.2.2 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.56 +# homeassistant.components.honeywell +AIOSomecomfort==0.0.14 + # homeassistant.components.adax Adax-local==0.1.5 +# homeassistant.components.ambiclimate +Ambiclimate==0.2.1 + +# homeassistant.components.blinksticklight +BlinkStick==1.2.0 + +# homeassistant.components.co2signal +CO2Signal==0.4.2 + +# homeassistant.components.doorbird +DoorBirdPy==2.1.0 + # homeassistant.components.homekit HAP-python==4.6.0 +# homeassistant.components.tasmota +HATasmota==0.6.5 + +# homeassistant.components.hydrawise +Hydrawiser==0.2 + # homeassistant.components.mastodon Mastodon.py==1.5.1 +# homeassistant.components.doods +# homeassistant.components.generic +# homeassistant.components.image_upload +# homeassistant.components.proxy +# homeassistant.components.qrcode +# homeassistant.components.seven_segments +# homeassistant.components.sighthound +# homeassistant.components.tensorflow +Pillow==9.5.0 + +# homeassistant.components.plex +PlexAPI==4.13.2 + +# homeassistant.components.progettihwsw +ProgettiHWSW==0.1.1 + +# homeassistant.components.bluetooth_tracker +# PyBluez==0.22 + +# homeassistant.components.cast +PyChromecast==13.0.7 + # homeassistant.components.flick_electric PyFlick==0.0.2 +# homeassistant.components.flume +PyFlume==0.6.5 + +# homeassistant.components.fronius +PyFronius==0.7.1 + # homeassistant.components.mvglive PyMVGLive==1.1.4 +# homeassistant.components.met_eireann +PyMetEireann==2021.8.0 + +# homeassistant.components.met +# homeassistant.components.norway_air +PyMetno==0.10.0 + # homeassistant.components.keymitt_ble PyMicroBot==0.0.9 +# homeassistant.components.nina +PyNINA==0.3.0 + # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 @@ -42,6 +101,12 @@ PySocks==1.7.1 # homeassistant.components.switchbot PySwitchbot==0.37.6 +# homeassistant.components.switchmate +PySwitchmate==0.5.1 + +# homeassistant.components.syncthru +PySyncThru==0.7.10 + # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -55,9 +120,22 @@ PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 +# homeassistant.components.goalfeed +Pysher==1.0.7 + +# homeassistant.components.rachio +RachioPy==1.0.3 + +# homeassistant.components.python_script +RestrictedPython==6.0 + # homeassistant.components.remember_the_milk RtmAPI==0.7.2 +# homeassistant.components.recorder +# homeassistant.components.sql +SQLAlchemy==2.0.15 + # homeassistant.components.travisci TravisPy==0.3.5 @@ -86,7 +164,7 @@ adext==0.4.2 adguardhome==0.6.1 # homeassistant.components.advantage_air -advantage_air==0.4.4 +advantage-air==0.4.4 # homeassistant.components.frontier_silicon afsapi==0.2.7 @@ -95,22 +173,22 @@ afsapi==0.2.7 agent-py==0.0.23 # homeassistant.components.geo_json_events -aio_geojson_generic_client==0.3 +aio-geojson-generic-client==0.3 # homeassistant.components.geonetnz_quakes -aio_geojson_geonetnz_quakes==0.15 +aio-geojson-geonetnz-quakes==0.15 # homeassistant.components.geonetnz_volcano -aio_geojson_geonetnz_volcano==0.8 +aio-geojson-geonetnz-volcano==0.8 # homeassistant.components.nsw_rural_fire_service_feed -aio_geojson_nsw_rfs_incidents==0.6 +aio-geojson-nsw-rfs-incidents==0.6 # homeassistant.components.usgs_earthquakes_feed -aio_geojson_usgs_earthquakes==0.2 +aio-geojson-usgs-earthquakes==0.2 # homeassistant.components.gdacs -aio_georss_gdacs==0.8 +aio-georss-gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 @@ -181,7 +259,7 @@ aiohomekit==2.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp_cors==0.7.0 +aiohttp-cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -195,15 +273,15 @@ aiokafka==0.7.2 # homeassistant.components.kef aiokef==0.2.16 +# homeassistant.components.lifx +aiolifx-effects==0.3.2 + +# homeassistant.components.lifx +aiolifx-themes==0.4.5 + # homeassistant.components.lifx aiolifx==0.8.10 -# homeassistant.components.lifx -aiolifx_effects==0.3.2 - -# homeassistant.components.lifx -aiolifx_themes==0.4.5 - # homeassistant.components.livisi aiolivisi==0.0.19 @@ -278,9 +356,6 @@ aioskybell==22.7.0 # homeassistant.components.slimproto aioslimproto==2.1.1 -# homeassistant.components.honeywell -aiosomecomfort==0.0.14 - # homeassistant.components.steamist aiosteamist==0.3.2 @@ -315,20 +390,17 @@ airly==1.1.0 airthings-ble==0.5.3 # homeassistant.components.airthings -airthings_cloud==0.1.0 +airthings-cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 # homeassistant.components.alpha_vantage -alpha_vantage==2.3.1 +alpha-vantage==2.3.1 # homeassistant.components.amberelectric amberelectric==1.0.4 -# homeassistant.components.ambiclimate -ambiclimate==0.2.1 - # homeassistant.components.amcrest amcrest==1.9.7 @@ -339,7 +411,7 @@ androidtv[async]==0.0.70 androidtvremote2==0.0.9 # homeassistant.components.anel_pwrctrl -anel_pwrctrl-homeassistant==0.0.1.dev2 +anel-pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anova anova-wifi==0.10.0 @@ -372,7 +444,7 @@ arris-tg2492lg==1.2.1 asmog==0.0.6 # homeassistant.components.asterisk_mbox -asterisk_mbox==0.5.0 +asterisk-mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -428,13 +500,13 @@ batinfo==0.4.2 beautifulsoup4==4.11.1 # homeassistant.components.beewi_smartclim -# beewi_smartclim==0.0.10 +# beewi-smartclim==0.0.10 # homeassistant.components.zha bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.6 +bimmer-connected==0.13.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -446,14 +518,11 @@ bleak-retry-connector==3.0.2 bleak==0.20.2 # homeassistant.components.blebox -blebox_uniapi==2.1.4 +blebox-uniapi==2.1.4 # homeassistant.components.blink blinkpy==0.21.0 -# homeassistant.components.blinksticklight -blinkstick==1.2.0 - # homeassistant.components.bitcoin blockchain==1.4.4 @@ -499,7 +568,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bluetooth_tracker -bt_proximity==0.2.1 +bt-proximity==0.2.1 # homeassistant.components.bthome bthome-ble==2.11.3 @@ -508,7 +577,7 @@ bthome-ble==2.11.3 bthomehub5-devicelist==0.1.1 # homeassistant.components.bt_smarthub -btsmarthub_devicelist==0.2.3 +btsmarthub-devicelist==0.2.3 # homeassistant.components.buienradar buienradar==1.0.5 @@ -528,9 +597,6 @@ clearpasspy==1.0.2 # homeassistant.components.sinch clx-sdk-xms==1.0.0 -# homeassistant.components.co2signal -co2signal==0.4.2 - # homeassistant.components.coinbase coinbase==2.1.0 @@ -575,12 +641,12 @@ dbus-fast==1.86.0 # homeassistant.components.debugpy debugpy==1.6.7 +# homeassistant.components.decora_wifi +# decora-wifi==1.4 + # homeassistant.components.decora # decora==0.6 -# homeassistant.components.decora_wifi -# decora_wifi==1.4 - # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect @@ -605,14 +671,11 @@ devolo-plc-api==1.3.1 directv==0.4.0 # homeassistant.components.discogs -discogs_client==2.3.0 +discogs-client==2.3.0 # homeassistant.components.steamist discovery30303==0.2.1 -# homeassistant.components.doorbird -doorbirdpy==2.1.0 - # homeassistant.components.dovado dovado==0.4.1 @@ -620,7 +683,7 @@ dovado==0.4.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr_parser==0.33 +dsmr-parser==0.33 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 @@ -629,10 +692,10 @@ dwdwfsapi==1.0.6 dweepy==0.3.0 # homeassistant.components.dynalite -dynalite_devices==0.1.47 +dynalite-devices==0.1.47 # homeassistant.components.dynalite -dynalite_panel==0.0.4 +dynalite-panel==0.0.4 # homeassistant.components.rainforest_eagle eagle100==0.1.1 @@ -656,10 +719,10 @@ eliqonline==1.2.2 elkm1-lib==2.2.2 # homeassistant.components.elmax -elmax_api==0.0.4 +elmax-api==0.0.4 # homeassistant.components.emulated_roku -emulated_roku==0.2.1 +emulated-roku==0.2.1 # homeassistant.components.huisbaasje energyflip-client==0.2.2 @@ -674,10 +737,10 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.34 +env-canada==0.5.34 # homeassistant.components.enphase_envoy -envoy_reader==0.20.1 +envoy-reader==0.20.1 # homeassistant.components.season ephem==4.1.2 @@ -695,7 +758,7 @@ esphome-dashboard-api==1.2.3 eternalegypt==0.0.16 # homeassistant.components.eufylife_ble -eufylife_ble_client==0.1.7 +eufylife-ble-client==0.1.7 # homeassistant.components.keyboard_remote # evdev==1.4.0 @@ -708,7 +771,7 @@ faadelays==0.0.7 # homeassistant.components.dlib_face_detect # homeassistant.components.dlib_face_identify -# face_recognition==1.2.3 +# face-recognition==1.2.3 # homeassistant.components.fastdotcom fastdotcom==0.0.3 @@ -738,17 +801,17 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.37 +flux-led==0.28.37 # homeassistant.components.homekit # homeassistant.components.recorder fnv-hash-fast==0.3.1 # homeassistant.components.foobot -foobot_async==1.0.0 +foobot-async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==3.0.0 +forecast-solar==3.0.0 # homeassistant.components.fortios fortiosapi==1.0.5 @@ -782,13 +845,13 @@ geocachingapi==0.2.1 geopy==2.3.0 # homeassistant.components.geo_rss_events -georss_generic_client==0.6 +georss-generic-client==0.6 # homeassistant.components.ign_sismologia -georss_ign_sismologia_client==0.6 +georss-ign-sismologia-client==0.6 # homeassistant.components.qld_bushfire -georss_qld_bushfire_alert_client==0.5 +georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef @@ -805,7 +868,7 @@ gios==3.1.0 gitterpy==0.1.7 # homeassistant.components.glances -glances_api==0.4.2 +glances-api==0.4.2 # homeassistant.components.goalzero goalzero==0.2.1 @@ -848,7 +911,7 @@ gps3==0.33.3 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye_monitor==3.0.3 +greeneye-monitor==3.0.3 # homeassistant.components.greenwave greenwavereality==0.5.1 @@ -891,14 +954,11 @@ habitipy==0.2.0 hass-nabucasa==0.67.1 # homeassistant.components.splunk -hass_splunk==0.1.1 +hass-splunk==0.1.1 # homeassistant.components.conversation hassil==1.0.6 -# homeassistant.components.tasmota -hatasmota==0.6.5 - # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -906,10 +966,10 @@ hdate==0.10.4 heatmiserV3==1.1.18 # homeassistant.components.here_travel_time -here_routing==0.2.0 +here-routing==0.2.0 # homeassistant.components.here_travel_time -here_transit==1.2.0 +here-transit==1.2.0 # homeassistant.components.hikvisioncam hikvision==0.4 @@ -950,9 +1010,6 @@ httplib2==0.20.4 # homeassistant.components.huawei_lte huawei-lte-api==1.6.11 -# homeassistant.components.hydrawise -hydrawiser==0.2 - # homeassistant.components.hyperion hyperion-py==0.7.5 @@ -963,7 +1020,7 @@ iammeter==0.1.7 iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==1.0.1 +ibeacon-ble==1.0.1 # homeassistant.components.watson_iot ibmiotf==0.3.4 @@ -1035,7 +1092,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx_frontend==2023.5.31.141540 +knx-frontend==2023.5.31.141540 # homeassistant.components.konnected konnected==1.2.0 @@ -1050,7 +1107,7 @@ lacrosse-view==1.0.1 lakeside==0.13 # homeassistant.components.laundrify -laundrify_aio==1.1.2 +laundrify-aio==1.1.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1089,7 +1146,7 @@ linode-api==4.1.9b1 locationsharinglib==5.0.1 # homeassistant.components.logi_circle -logi_circle==0.2.3 +logi-circle==0.2.3 # homeassistant.components.london_underground london-tube-status==0.5 @@ -1158,7 +1215,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka_iot_ble==0.4.1 +mopeka-iot-ble==0.4.1 # homeassistant.components.motion_blinds motionblinds==0.6.18 @@ -1176,10 +1233,10 @@ mutagen==1.46.0 mutesync==0.0.1 # homeassistant.components.nad -nad_receiver==0.3.0 +nad-receiver==0.3.0 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.1.2 +ndms2-client==0.1.2 # homeassistant.components.ness_alarm nessclient==0.10.0 @@ -1332,7 +1389,7 @@ paho-mqtt==1.6.1 panacotta==0.2 # homeassistant.components.panasonic_viera -panasonic_viera==0.3.6 +panasonic-viera==0.3.6 # homeassistant.components.dunehd pdunehd==1.3.2 @@ -1353,7 +1410,7 @@ pescea==1.0.12 pexpect==4.6.0 # homeassistant.components.modem_callerid -phone_modem==0.1.1 +phone-modem==0.1.1 # homeassistant.components.remote_rpi_gpio pigpio==1.78 @@ -1361,22 +1418,9 @@ pigpio==1.78 # homeassistant.components.pilight pilight==0.1.1 -# homeassistant.components.doods -# homeassistant.components.generic -# homeassistant.components.image_upload -# homeassistant.components.proxy -# homeassistant.components.qrcode -# homeassistant.components.seven_segments -# homeassistant.components.sighthound -# homeassistant.components.tensorflow -pillow==9.5.0 - # homeassistant.components.dominos pizzapi==0.0.3 -# homeassistant.components.plex -plexapi==4.13.2 - # homeassistant.components.plex plexauth==0.0.6 @@ -1399,16 +1443,13 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer_times_calculator==0.0.6 - -# homeassistant.components.progettihwsw -progettihwsw==0.1.1 +prayer-times-calculator==0.0.6 # homeassistant.components.proliphix proliphix==0.4.1 # homeassistant.components.prometheus -prometheus_client==0.7.1 +prometheus-client==0.7.1 # homeassistant.components.proxmoxve proxmoxer==2.0.1 @@ -1430,7 +1471,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover_complete==1.1.1 +pushover-complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1447,6 +1488,9 @@ py-dormakaba-dkey==1.0.4 # homeassistant.components.melissa py-melissa-climate==2.1.4 +# homeassistant.components.nextbus +py-nextbusnext==0.1.5 + # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1465,24 +1509,32 @@ py-zabbix==1.1.7 # homeassistant.components.seventeentrack py17track==2021.12.2 +# homeassistant.components.atome +pyAtome==0.1.1 + # homeassistant.components.hdmi_cec pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 -# homeassistant.components.met_eireann -pyMetEireann==2021.8.0 +# homeassistant.components.eight_sleep +pyEight==0.3.2 -# homeassistant.components.met -# homeassistant.components.norway_air -pyMetno==0.10.0 +# homeassistant.components.electrasmart +pyElectra==1.2.0 + +# homeassistant.components.emby +pyEmby==1.8 + +# homeassistant.components.hikvision +pyHik==0.3.2 # homeassistant.components.rfxtrx pyRFXtrx==0.30.1 -# homeassistant.components.switchmate -pySwitchmate==0.5.1 +# homeassistant.components.sony_projector +pySDCP==1 # homeassistant.components.tibber pyTibber==0.27.2 @@ -1493,9 +1545,6 @@ pyW215==0.7.0 # homeassistant.components.w800rf32 pyW800rf32==0.1 -# homeassistant.components.nextbus -py_nextbusnext==0.1.5 - # homeassistant.components.ads pyads==3.2.2 @@ -1518,9 +1567,6 @@ pyatag==0.3.5.3 # homeassistant.components.netatmo pyatmo==7.5.0 -# homeassistant.components.atome -pyatome==0.1.1 - # homeassistant.components.apple_tv pyatv==0.12.0 @@ -1536,9 +1582,6 @@ pybbox==0.0.5-alpha # homeassistant.components.blackbird pyblackbird==0.6 -# homeassistant.components.bluetooth_tracker -# pybluez==0.22 - # homeassistant.components.neato pybotvac==0.0.23 @@ -1554,9 +1597,6 @@ pycfdns==2.0.1 # homeassistant.components.channels pychannels==1.2.3 -# homeassistant.components.cast -pychromecast==13.0.7 - # homeassistant.components.pocketcasts pycketcasts==1.0.1 @@ -1611,15 +1651,6 @@ pyedimax==0.2.1 # homeassistant.components.efergy pyefergy==22.1.1 -# homeassistant.components.eight_sleep -pyeight==0.3.2 - -# homeassistant.components.electrasmart -pyelectra==1.2.0 - -# homeassistant.components.emby -pyemby==1.8 - # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1647,9 +1678,6 @@ pyfireservicerota==0.0.43 # homeassistant.components.flic pyflic==2.0.3 -# homeassistant.components.flume -pyflume==0.6.5 - # homeassistant.components.futurenow pyfnip==0.2 @@ -1662,9 +1690,6 @@ pyfreedompro==1.1.0 # homeassistant.components.fritzbox pyfritzhome==0.6.8 -# homeassistant.components.fronius -pyfronius==0.7.1 - # homeassistant.components.ifttt pyfttt==0.3 @@ -1683,9 +1708,6 @@ pyhaversion==22.8.0 # homeassistant.components.heos pyheos==0.7.2 -# homeassistant.components.hikvision -pyhik==0.3.2 - # homeassistant.components.hive pyhiveapi==0.5.14 @@ -1827,9 +1849,6 @@ pynetgear==0.10.9 # homeassistant.components.netio pynetio==0.1.9.1 -# homeassistant.components.nina -pynina==0.3.0 - # homeassistant.components.nobo_hub pynobo==1.6.0 @@ -1949,9 +1968,6 @@ pysabnzbd==1.1.1 # homeassistant.components.saj pysaj==0.0.16 -# homeassistant.components.sony_projector -pysdcp==1 - # homeassistant.components.sensibo pysensibo==1.0.28 @@ -1969,9 +1985,6 @@ pyserial==3.5 # homeassistant.components.sesame pysesame2==1.0.1 -# homeassistant.components.goalfeed -pysher==1.0.7 - # homeassistant.components.sia pysiaalarm==3.1.1 @@ -2020,9 +2033,6 @@ pysuez==0.1.19 # homeassistant.components.switchbee pyswitchbee==1.8.0 -# homeassistant.components.syncthru -pysyncthru==0.7.10 - # homeassistant.components.tankerkoenig pytankerkoenig==0.0.6 @@ -2035,6 +2045,9 @@ pytfiac==0.4 # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 +# homeassistant.components.awair +python-awair==0.2.4 + # homeassistant.components.blockchain python-blockchain-api==0.0.2 @@ -2107,6 +2120,9 @@ python-mystrom==2.2.0 # homeassistant.components.nest python-nest==4.2.0 +# homeassistant.components.swiss_public_transport +python-opendata-transport==0.3.0 + # homeassistant.components.opensky python-opensky==0.0.7 @@ -2141,12 +2157,6 @@ python-telegram-bot==13.1 # homeassistant.components.vlc python-vlc==1.1.2 -# homeassistant.components.awair -python_awair==0.2.4 - -# homeassistant.components.swiss_public_transport -python_opendata_transport==0.3.0 - # homeassistant.components.egardia pythonegardia==1.0.52 @@ -2233,9 +2243,6 @@ qnapstats==0.4.0 # homeassistant.components.quantum_gateway quantum-gateway==0.0.8 -# homeassistant.components.rachio -rachiopy==1.0.3 - # homeassistant.components.radio_browser radios==0.1.1 @@ -2260,9 +2267,6 @@ renault-api==0.1.13 # homeassistant.components.reolink reolink-aio==0.5.16 -# homeassistant.components.python_script -restrictedpython==6.0 - # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2270,7 +2274,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring_doorbell==0.7.2 +ring-doorbell==0.7.2 # homeassistant.components.fleetgo ritassist==0.9.2 @@ -2299,12 +2303,12 @@ rpi-bad-power==0.1.0 # homeassistant.components.rtsp_to_webrtc rtsp-to-webrtc==0.5.1 +# homeassistant.components.russound_rio +russound-rio==0.1.8 + # homeassistant.components.russound_rnet russound==0.1.9 -# homeassistant.components.russound_rio -russound_rio==0.1.8 - # homeassistant.components.ruuvitag_ble ruuvitag-ble==0.1.1 @@ -2318,7 +2322,7 @@ samsungctl[websocket]==0.7.1 samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.satel_integra -satel_integra==0.3.7 +satel-integra==0.3.7 # homeassistant.components.dhcp scapy==2.5.0 @@ -2335,8 +2339,10 @@ securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 -# homeassistant.components.emulated_kasa # homeassistant.components.sense +sense-energy==0.11.2 + +# homeassistant.components.emulated_kasa sense_energy==0.11.2 # homeassistant.components.sensirion_ble @@ -2358,7 +2364,7 @@ sfrbox-api==0.0.6 sharkiq==1.0.2 # homeassistant.components.aquostv -sharp_aquos_rc==0.3.2 +sharp-aquos-rc==0.3.2 # homeassistant.components.shodan shodan==1.28.0 @@ -2420,10 +2426,6 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 -# homeassistant.components.recorder -# homeassistant.components.sql -sqlalchemy==2.0.15 - # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2479,7 +2481,7 @@ systembridgeconnector==3.4.8 tailscale==0.2.0 # homeassistant.components.tank_utility -tank_utility==1.4.1 +tank-utility==1.4.1 # homeassistant.components.tapsaff tapsaff==0.2.1 @@ -2518,7 +2520,7 @@ thermobeacon-ble==0.6.0 thermopro-ble==0.4.5 # homeassistant.components.thermoworks_smoke -thermoworks_smoke==0.1.8 +thermoworks-smoke==0.1.8 # homeassistant.components.thingspeak thingspeak==1.0.0 @@ -2542,13 +2544,13 @@ tololib==0.1.0b4 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2023.2 +total-connect-client==2023.2 # homeassistant.components.tplink_lte tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.2.4 +tplink_omada_client==1.2.4 # homeassistant.components.transmission transmission-rpc==4.1.5 @@ -2581,7 +2583,7 @@ unifi-discovery==1.1.7 unifiled==0.11 # homeassistant.components.upb -upb_lib==0.5.4 +upb-lib==0.5.4 # homeassistant.components.upcloud upcloud-api==2.0.0 @@ -2671,7 +2673,7 @@ withings-api==2.4.0 wled==0.16.0 # homeassistant.components.wolflink -wolf_smartset==0.1.11 +wolf-smartset==0.1.11 # homeassistant.components.wyoming wyoming==0.0.1 @@ -2722,7 +2724,7 @@ yolink-api==0.2.9 youless-api==1.0.1 # homeassistant.components.media_extractor -youtube_dl==2021.12.17 +youtube-dl==2021.12.17 # homeassistant.components.zamg zamg==0.2.2 @@ -2740,7 +2742,7 @@ zeversolar==0.3.1 zha-quirks==0.0.100 # homeassistant.components.zhong_hong -zhong_hong_hvac==1.0.9 +zhong-hong-hvac==1.0.9 # homeassistant.components.ziggo_mediabox_xl ziggo-mediabox-xl==1.1.0 @@ -2767,4 +2769,4 @@ zm-py==0.5.2 zwave-js-server-python==0.49.0 # homeassistant.components.zwave_me -zwave_me_ws==0.4.3 +zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee5053df08e..8e40547a5dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -9,18 +9,68 @@ AEMET-OpenData==0.2.2 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.56 +# homeassistant.components.honeywell +AIOSomecomfort==0.0.14 + # homeassistant.components.adax Adax-local==0.1.5 +# homeassistant.components.ambiclimate +Ambiclimate==0.2.1 + +# homeassistant.components.co2signal +CO2Signal==0.4.2 + +# homeassistant.components.doorbird +DoorBirdPy==2.1.0 + # homeassistant.components.homekit HAP-python==4.6.0 +# homeassistant.components.tasmota +HATasmota==0.6.5 + +# homeassistant.components.doods +# homeassistant.components.generic +# homeassistant.components.image_upload +# homeassistant.components.proxy +# homeassistant.components.qrcode +# homeassistant.components.seven_segments +# homeassistant.components.sighthound +# homeassistant.components.tensorflow +Pillow==9.5.0 + +# homeassistant.components.plex +PlexAPI==4.13.2 + +# homeassistant.components.progettihwsw +ProgettiHWSW==0.1.1 + +# homeassistant.components.cast +PyChromecast==13.0.7 + # homeassistant.components.flick_electric PyFlick==0.0.2 +# homeassistant.components.flume +PyFlume==0.6.5 + +# homeassistant.components.fronius +PyFronius==0.7.1 + +# homeassistant.components.met_eireann +PyMetEireann==2021.8.0 + +# homeassistant.components.met +# homeassistant.components.norway_air +PyMetno==0.10.0 + # homeassistant.components.keymitt_ble PyMicroBot==0.0.9 +# homeassistant.components.nina +PyNINA==0.3.0 + # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 @@ -38,6 +88,9 @@ PySocks==1.7.1 # homeassistant.components.switchbot PySwitchbot==0.37.6 +# homeassistant.components.syncthru +PySyncThru==0.7.10 + # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -51,9 +104,19 @@ PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 +# homeassistant.components.rachio +RachioPy==1.0.3 + +# homeassistant.components.python_script +RestrictedPython==6.0 + # homeassistant.components.remember_the_milk RtmAPI==0.7.2 +# homeassistant.components.recorder +# homeassistant.components.sql +SQLAlchemy==2.0.15 + # homeassistant.components.onvif WSDiscovery==2.0.0 @@ -76,7 +139,7 @@ adext==0.4.2 adguardhome==0.6.1 # homeassistant.components.advantage_air -advantage_air==0.4.4 +advantage-air==0.4.4 # homeassistant.components.frontier_silicon afsapi==0.2.7 @@ -85,22 +148,22 @@ afsapi==0.2.7 agent-py==0.0.23 # homeassistant.components.geo_json_events -aio_geojson_generic_client==0.3 +aio-geojson-generic-client==0.3 # homeassistant.components.geonetnz_quakes -aio_geojson_geonetnz_quakes==0.15 +aio-geojson-geonetnz-quakes==0.15 # homeassistant.components.geonetnz_volcano -aio_geojson_geonetnz_volcano==0.8 +aio-geojson-geonetnz-volcano==0.8 # homeassistant.components.nsw_rural_fire_service_feed -aio_geojson_nsw_rfs_incidents==0.6 +aio-geojson-nsw-rfs-incidents==0.6 # homeassistant.components.usgs_earthquakes_feed -aio_geojson_usgs_earthquakes==0.2 +aio-geojson-usgs-earthquakes==0.2 # homeassistant.components.gdacs -aio_georss_gdacs==0.8 +aio-georss-gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 @@ -168,7 +231,7 @@ aiohomekit==2.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp_cors==0.7.0 +aiohttp-cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -179,15 +242,15 @@ aioimaplib==1.0.1 # homeassistant.components.apache_kafka aiokafka==0.7.2 +# homeassistant.components.lifx +aiolifx-effects==0.3.2 + +# homeassistant.components.lifx +aiolifx-themes==0.4.5 + # homeassistant.components.lifx aiolifx==0.8.10 -# homeassistant.components.lifx -aiolifx_effects==0.3.2 - -# homeassistant.components.lifx -aiolifx_themes==0.4.5 - # homeassistant.components.livisi aiolivisi==0.0.19 @@ -259,9 +322,6 @@ aioskybell==22.7.0 # homeassistant.components.slimproto aioslimproto==2.1.1 -# homeassistant.components.honeywell -aiosomecomfort==0.0.14 - # homeassistant.components.steamist aiosteamist==0.3.2 @@ -296,7 +356,7 @@ airly==1.1.0 airthings-ble==0.5.3 # homeassistant.components.airthings -airthings_cloud==0.1.0 +airthings-cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 @@ -304,9 +364,6 @@ airtouch4pyapi==1.0.5 # homeassistant.components.amberelectric amberelectric==1.0.4 -# homeassistant.components.ambiclimate -ambiclimate==0.2.1 - # homeassistant.components.androidtv androidtv[async]==0.0.70 @@ -367,7 +424,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.6 +bimmer-connected==0.13.6 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 @@ -376,7 +433,7 @@ bleak-retry-connector==3.0.2 bleak==0.20.2 # homeassistant.components.blebox -blebox_uniapi==2.1.4 +blebox-uniapi==2.1.4 # homeassistant.components.blink blinkpy==0.21.0 @@ -423,9 +480,6 @@ buienradar==1.0.5 # homeassistant.components.caldav caldav==1.2.0 -# homeassistant.components.co2signal -co2signal==0.4.2 - # homeassistant.components.coinbase coinbase==2.1.0 @@ -490,23 +544,20 @@ directv==0.4.0 # homeassistant.components.steamist discovery30303==0.2.1 -# homeassistant.components.doorbird -doorbirdpy==2.1.0 - # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr_parser==0.33 +dsmr-parser==0.33 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 # homeassistant.components.dynalite -dynalite_devices==0.1.47 +dynalite-devices==0.1.47 # homeassistant.components.dynalite -dynalite_panel==0.0.4 +dynalite-panel==0.0.4 # homeassistant.components.rainforest_eagle eagle100==0.1.1 @@ -521,10 +572,10 @@ elgato==4.0.1 elkm1-lib==2.2.2 # homeassistant.components.elmax -elmax_api==0.0.4 +elmax-api==0.0.4 # homeassistant.components.emulated_roku -emulated_roku==0.2.1 +emulated-roku==0.2.1 # homeassistant.components.huisbaasje energyflip-client==0.2.2 @@ -536,10 +587,10 @@ energyzero==0.4.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.34 +env-canada==0.5.34 # homeassistant.components.enphase_envoy -envoy_reader==0.20.1 +envoy-reader==0.20.1 # homeassistant.components.season ephem==4.1.2 @@ -551,7 +602,7 @@ epson-projector==0.5.0 esphome-dashboard-api==1.2.3 # homeassistant.components.eufylife_ble -eufylife_ble_client==0.1.7 +eufylife-ble-client==0.1.7 # homeassistant.components.faa_delays faadelays==0.0.7 @@ -572,17 +623,17 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.37 +flux-led==0.28.37 # homeassistant.components.homekit # homeassistant.components.recorder fnv-hash-fast==0.3.1 # homeassistant.components.foobot -foobot_async==1.0.0 +foobot-async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==3.0.0 +forecast-solar==3.0.0 # homeassistant.components.freebox freebox-api==1.1.0 @@ -607,13 +658,13 @@ geocachingapi==0.2.1 geopy==2.3.0 # homeassistant.components.geo_rss_events -georss_generic_client==0.6 +georss-generic-client==0.6 # homeassistant.components.ign_sismologia -georss_ign_sismologia_client==0.6 +georss-ign-sismologia-client==0.6 # homeassistant.components.qld_bushfire -georss_qld_bushfire_alert_client==0.5 +georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef @@ -627,7 +678,7 @@ getmac==0.8.2 gios==3.1.0 # homeassistant.components.glances -glances_api==0.4.2 +glances-api==0.4.2 # homeassistant.components.goalzero goalzero==0.2.1 @@ -658,7 +709,7 @@ govee-ble==0.23.0 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye_monitor==3.0.3 +greeneye-monitor==3.0.3 # homeassistant.components.pure_energie gridnet==4.2.0 @@ -697,17 +748,14 @@ hass-nabucasa==0.67.1 # homeassistant.components.conversation hassil==1.0.6 -# homeassistant.components.tasmota -hatasmota==0.6.5 - # homeassistant.components.jewish_calendar hdate==0.10.4 # homeassistant.components.here_travel_time -here_routing==0.2.0 +here-routing==0.2.0 # homeassistant.components.here_travel_time -here_transit==1.2.0 +here-transit==1.2.0 # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 @@ -746,7 +794,7 @@ hyperion-py==0.7.5 iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==1.0.1 +ibeacon-ble==1.0.1 # homeassistant.components.local_calendar ical==4.5.1 @@ -794,7 +842,7 @@ justnimbus==0.6.0 kegtron-ble==0.4.0 # homeassistant.components.knx -knx_frontend==2023.5.31.141540 +knx-frontend==2023.5.31.141540 # homeassistant.components.konnected konnected==1.2.0 @@ -806,7 +854,7 @@ krakenex==2.1.0 lacrosse-view==1.0.1 # homeassistant.components.laundrify -laundrify_aio==1.1.2 +laundrify-aio==1.1.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -827,7 +875,7 @@ libsoundtouch==0.8 life360==5.5.0 # homeassistant.components.logi_circle -logi_circle==0.2.3 +logi-circle==0.2.3 # homeassistant.components.luftdaten luftdaten==0.7.4 @@ -878,7 +926,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka_iot_ble==0.4.1 +mopeka-iot-ble==0.4.1 # homeassistant.components.motion_blinds motionblinds==0.6.18 @@ -896,7 +944,7 @@ mutagen==1.46.0 mutesync==0.0.1 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.1.2 +ndms2-client==0.1.2 # homeassistant.components.ness_alarm nessclient==0.10.0 @@ -992,7 +1040,7 @@ p1monitor==2.1.1 paho-mqtt==1.6.1 # homeassistant.components.panasonic_viera -panasonic_viera==0.3.6 +panasonic-viera==0.3.6 # homeassistant.components.dunehd pdunehd==1.3.2 @@ -1010,24 +1058,11 @@ pescea==1.0.12 pexpect==4.6.0 # homeassistant.components.modem_callerid -phone_modem==0.1.1 +phone-modem==0.1.1 # homeassistant.components.pilight pilight==0.1.1 -# homeassistant.components.doods -# homeassistant.components.generic -# homeassistant.components.image_upload -# homeassistant.components.proxy -# homeassistant.components.qrcode -# homeassistant.components.seven_segments -# homeassistant.components.sighthound -# homeassistant.components.tensorflow -pillow==9.5.0 - -# homeassistant.components.plex -plexapi==4.13.2 - # homeassistant.components.plex plexauth==0.0.6 @@ -1047,13 +1082,10 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer_times_calculator==0.0.6 - -# homeassistant.components.progettihwsw -progettihwsw==0.1.1 +prayer-times-calculator==0.0.6 # homeassistant.components.prometheus -prometheus_client==0.7.1 +prometheus-client==0.7.1 # homeassistant.components.hardware # homeassistant.components.recorder @@ -1066,7 +1098,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover_complete==1.1.1 +pushover-complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1083,6 +1115,9 @@ py-dormakaba-dkey==1.0.4 # homeassistant.components.melissa py-melissa-climate==2.1.4 +# homeassistant.components.nextbus +py-nextbusnext==0.1.5 + # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1098,12 +1133,11 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 -# homeassistant.components.met_eireann -pyMetEireann==2021.8.0 +# homeassistant.components.eight_sleep +pyEight==0.3.2 -# homeassistant.components.met -# homeassistant.components.norway_air -pyMetno==0.10.0 +# homeassistant.components.electrasmart +pyElectra==1.2.0 # homeassistant.components.rfxtrx pyRFXtrx==0.30.1 @@ -1114,9 +1148,6 @@ pyTibber==0.27.2 # homeassistant.components.dlink pyW215==0.7.0 -# homeassistant.components.nextbus -py_nextbusnext==0.1.5 - # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 @@ -1154,9 +1185,6 @@ pybravia==0.3.3 # homeassistant.components.cloudflare pycfdns==2.0.1 -# homeassistant.components.cast -pychromecast==13.0.7 - # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 @@ -1184,12 +1212,6 @@ pyeconet==0.1.20 # homeassistant.components.efergy pyefergy==22.1.1 -# homeassistant.components.eight_sleep -pyeight==0.3.2 - -# homeassistant.components.electrasmart -pyelectra==1.2.0 - # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1211,9 +1233,6 @@ pyfireservicerota==0.0.43 # homeassistant.components.flic pyflic==2.0.3 -# homeassistant.components.flume -pyflume==0.6.5 - # homeassistant.components.forked_daapd pyforked-daapd==0.1.14 @@ -1223,9 +1242,6 @@ pyfreedompro==1.1.0 # homeassistant.components.fritzbox pyfritzhome==0.6.8 -# homeassistant.components.fronius -pyfronius==0.7.1 - # homeassistant.components.ifttt pyfttt==0.3 @@ -1343,9 +1359,6 @@ pymysensors==0.24.0 # homeassistant.components.netgear pynetgear==0.10.9 -# homeassistant.components.nina -pynina==0.3.0 - # homeassistant.components.nobo_hub pynobo==1.6.0 @@ -1494,15 +1507,15 @@ pysqueezebox==0.6.3 # homeassistant.components.switchbee pyswitchbee==1.8.0 -# homeassistant.components.syncthru -pysyncthru==0.7.10 - # homeassistant.components.tankerkoenig pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.awair +python-awair==0.2.4 + # homeassistant.components.bsblan python-bsblan==0.5.11 @@ -1558,9 +1571,6 @@ python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 -# homeassistant.components.awair -python_awair==0.2.4 - # homeassistant.components.tile pytile==2023.04.0 @@ -1620,9 +1630,6 @@ pyzerproc==0.4.8 # homeassistant.components.qingping qingping-ble==0.8.2 -# homeassistant.components.rachio -rachiopy==1.0.3 - # homeassistant.components.radio_browser radios==0.1.1 @@ -1641,14 +1648,11 @@ renault-api==0.1.13 # homeassistant.components.reolink reolink-aio==0.5.16 -# homeassistant.components.python_script -restrictedpython==6.0 - # homeassistant.components.rflink rflink==0.0.65 # homeassistant.components.ring -ring_doorbell==0.7.2 +ring-doorbell==0.7.2 # homeassistant.components.roku rokuecp==0.18.0 @@ -1686,8 +1690,10 @@ screenlogicpy==0.8.2 # homeassistant.components.backup securetar==2023.3.0 -# homeassistant.components.emulated_kasa # homeassistant.components.sense +sense-energy==0.11.2 + +# homeassistant.components.emulated_kasa sense_energy==0.11.2 # homeassistant.components.sensirion_ble @@ -1756,10 +1762,6 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 -# homeassistant.components.recorder -# homeassistant.components.sql -sqlalchemy==2.0.15 - # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1836,10 +1838,10 @@ tololib==0.1.0b4 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2023.2 +total-connect-client==2023.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.2.4 +tplink_omada_client==1.2.4 # homeassistant.components.transmission transmission-rpc==4.1.5 @@ -1869,7 +1871,7 @@ ultraheat-api==0.5.1 unifi-discovery==1.1.7 # homeassistant.components.upb -upb_lib==0.5.4 +upb-lib==0.5.4 # homeassistant.components.upcloud upcloud-api==2.0.0 @@ -1941,7 +1943,7 @@ withings-api==2.4.0 wled==0.16.0 # homeassistant.components.wolflink -wolf_smartset==0.1.11 +wolf-smartset==0.1.11 # homeassistant.components.wyoming wyoming==0.0.1 @@ -2016,4 +2018,4 @@ zigpy==0.55.0 zwave-js-server-python==0.49.0 # homeassistant.components.zwave_me -zwave_me_ws==0.4.3 +zwave-me-ws==0.4.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b51ddb46307..e7356d710c0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -20,17 +20,17 @@ else: import tomli as tomllib COMMENT_REQUIREMENTS = ( - "Adafruit_BBIO", + "Adafruit-BBIO", "atenpdu", # depends on pysnmp which is not maintained at this time "avea", # depends on bluepy "avion", "beacontools", - "beewi_smartclim", # depends on bluepy + "beewi-smartclim", # depends on bluepy "bluepy", "decora", - "decora_wifi", + "decora-wifi", "evdev", - "face_recognition", + "face-recognition", "opencv-python-headless", "pybluez", "pycups", @@ -438,7 +438,8 @@ def gather_constraints() -> str: *core_requirements(), *gather_recursive_requirements("default_config"), *gather_recursive_requirements("mqtt"), - } + }, + key=lambda name: name.lower(), ) + [""] ) From 938ff679deb4260565700100772148073f418367 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 31 May 2023 21:14:59 -0400 Subject: [PATCH 013/857] Bump frontend to 20230601.0 (#93884) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 12f98ef39f0..bde1977b1c1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230531.0"] + "requirements": ["home-assistant-frontend==20230601.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6f90157f65e..57b36d1807f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230531.0 +home-assistant-frontend==20230601.0 home-assistant-intents==2023.5.30 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f76656b4127..9ca4b9192a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -987,7 +987,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230531.0 +home-assistant-frontend==20230601.0 # homeassistant.components.conversation home-assistant-intents==2023.5.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e40547a5dc..ae8f7a56e12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -767,7 +767,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230531.0 +home-assistant-frontend==20230601.0 # homeassistant.components.conversation home-assistant-intents==2023.5.30 From f73754ff582e9e65c8413ffde0921892dde26747 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Thu, 1 Jun 2023 08:50:35 +0200 Subject: [PATCH 014/857] Restructure Insteon start-up (#92818) * Restructure startup * Code review * Further typing * Fix circular import --- .../components/insteon/binary_sensor.py | 9 +++- homeassistant/components/insteon/climate.py | 9 +++- homeassistant/components/insteon/cover.py | 9 +++- homeassistant/components/insteon/fan.py | 9 +++- homeassistant/components/insteon/ipdb.py | 15 +++--- homeassistant/components/insteon/light.py | 9 +++- homeassistant/components/insteon/lock.py | 9 +++- homeassistant/components/insteon/switch.py | 9 +++- homeassistant/components/insteon/utils.py | 51 ++++++++++++++----- 9 files changed, 97 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index 9d1ec352bed..f895b9c7f6a 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities SENSOR_TYPES = { OPEN_CLOSE_SENSOR: BinarySensorDeviceClass.OPENING, @@ -62,7 +62,12 @@ async def async_setup_entry( signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.BINARY_SENSOR}" async_dispatcher_connect(hass, signal, async_add_insteon_binary_sensor_entities) - async_add_insteon_binary_sensor_entities() + async_add_insteon_devices( + hass, + Platform.BINARY_SENSOR, + InsteonBinarySensorEntity, + async_add_entities, + ) class InsteonBinarySensorEntity(InsteonEntity, BinarySensorEntity): diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index cf5f4ac2c0c..48ff898d6aa 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities FAN_ONLY = "fan_only" @@ -71,7 +71,12 @@ async def async_setup_entry( signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.CLIMATE}" async_dispatcher_connect(hass, signal, async_add_insteon_climate_entities) - async_add_insteon_climate_entities() + async_add_insteon_devices( + hass, + Platform.CLIMATE, + InsteonClimateEntity, + async_add_entities, + ) class InsteonClimateEntity(InsteonEntity, ClimateEntity): diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 69a66d304ce..0756e603579 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( @@ -34,7 +34,12 @@ async def async_setup_entry( signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.COVER}" async_dispatcher_connect(hass, signal, async_add_insteon_cover_entities) - async_add_insteon_cover_entities() + async_add_insteon_devices( + hass, + Platform.COVER, + InsteonCoverEntity, + async_add_entities, + ) class InsteonCoverEntity(InsteonEntity, CoverEntity): diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index b0d664a821b..92f56098a91 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -17,7 +17,7 @@ from homeassistant.util.percentage import ( from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities SPEED_RANGE = (1, 255) # off is not included @@ -38,7 +38,12 @@ async def async_setup_entry( signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.FAN}" async_dispatcher_connect(hass, signal, async_add_insteon_fan_entities) - async_add_insteon_fan_entities() + async_add_insteon_devices( + hass, + Platform.FAN, + InsteonFanEntity, + async_add_entities, + ) class InsteonFanEntity(InsteonEntity, FanEntity): diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index ee799e103f9..de3ba7d55f2 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -1,4 +1,7 @@ """Utility methods for the Insteon platform.""" +from collections.abc import Iterable + +from pyinsteon.device_types.device_base import Device from pyinsteon.device_types.ipdb import ( AccessControl_Morningstar, ClimateControl_Thermostat, @@ -44,7 +47,7 @@ from pyinsteon.device_types.ipdb import ( from homeassistant.const import Platform -DEVICE_PLATFORM = { +DEVICE_PLATFORM: dict[Device, dict[Platform, Iterable[int]]] = { AccessControl_Morningstar: {Platform.LOCK: [1]}, DimmableLightingControl: {Platform.LIGHT: [1]}, DimmableLightingControl_Dial: {Platform.LIGHT: [1]}, @@ -101,11 +104,11 @@ DEVICE_PLATFORM = { } -def get_device_platforms(device): +def get_device_platforms(device) -> dict[Platform, Iterable[int]]: """Return the HA platforms for a device type.""" - return DEVICE_PLATFORM.get(type(device), {}).keys() + return DEVICE_PLATFORM.get(type(device), {}) -def get_platform_groups(device, domain) -> dict: - """Return the platforms that a device belongs in.""" - return DEVICE_PLATFORM.get(type(device), {}).get(domain, {}) # type: ignore[attr-defined] +def get_device_platform_groups(device: Device, platform: Platform) -> Iterable[int]: + """Return the list of device groups for a platform.""" + return get_device_platforms(device).get(platform, []) diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 44574c696b4..1c12bc794f9 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities MAX_BRIGHTNESS = 255 @@ -37,7 +37,12 @@ async def async_setup_entry( signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LIGHT}" async_dispatcher_connect(hass, signal, async_add_insteon_light_entities) - async_add_insteon_light_entities() + async_add_insteon_devices( + hass, + Platform.LIGHT, + InsteonDimmerEntity, + async_add_entities, + ) class InsteonDimmerEntity(InsteonEntity, LightEntity): diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py index 75487e7696c..27fb0fd42d8 100644 --- a/homeassistant/components/insteon/lock.py +++ b/homeassistant/components/insteon/lock.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( @@ -30,7 +30,12 @@ async def async_setup_entry( signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LOCK}" async_dispatcher_connect(hass, signal, async_add_insteon_lock_entities) - async_add_insteon_lock_entities() + async_add_insteon_devices( + hass, + Platform.LOCK, + InsteonLockEntity, + async_add_entities, + ) class InsteonLockEntity(InsteonEntity, LockEntity): diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index 8f7c396f213..8acde0429cd 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( @@ -33,7 +33,12 @@ async def async_setup_entry( signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.SWITCH}" async_dispatcher_connect(hass, signal, async_add_insteon_switch_entities) - async_add_insteon_switch_entities() + async_add_insteon_devices( + hass, + Platform.SWITCH, + InsteonSwitchEntity, + async_add_entities, + ) class InsteonSwitchEntity(InsteonEntity, SwitchEntity): diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 58b2430092c..2ef9913ab8c 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -1,7 +1,10 @@ """Utilities used by insteon component.""" +from __future__ import annotations + import asyncio from collections.abc import Callable import logging +from typing import TYPE_CHECKING, Any from pyinsteon import devices from pyinsteon.address import Address @@ -30,6 +33,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_PLATFORM, ENTITY_MATCH_ALL, + Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr @@ -38,6 +42,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_CAT, @@ -78,7 +83,7 @@ from .const import ( SRV_X10_ALL_LIGHTS_ON, SRV_X10_ALL_UNITS_OFF, ) -from .ipdb import get_device_platforms, get_platform_groups +from .ipdb import get_device_platform_groups, get_device_platforms from .schemas import ( ADD_ALL_LINK_SCHEMA, ADD_DEFAULT_LINKS_SCHEMA, @@ -89,6 +94,9 @@ from .schemas import ( X10_HOUSECODE_SCHEMA, ) +if TYPE_CHECKING: + from .insteon_entity import InsteonEntity + _LOGGER = logging.getLogger(__name__) @@ -160,6 +168,7 @@ def register_new_device_callback(hass): for platform in platforms: signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" dispatcher_send(hass, signal, {"address": device.address}) + add_insteon_events(hass, device) devices.subscribe(async_new_insteon_device, force_strong_ref=True) @@ -383,20 +392,38 @@ def print_aldb_to_log(aldb): @callback def async_add_insteon_entities( - hass, platform, entity_type, async_add_entities, discovery_info -): - """Add Insteon devices to a platform.""" - new_entities = [] - device_list = [discovery_info.get("address")] if discovery_info else devices - - for address in device_list: - device = devices[address] - groups = get_platform_groups(device, platform) - for group in groups: - new_entities.append(entity_type(device, group)) + hass: HomeAssistant, + platform: Platform, + entity_type: type[InsteonEntity], + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any], +) -> None: + """Add an Insteon group to a platform.""" + address = discovery_info["address"] + device = devices[address] + new_entities = [ + entity_type(device=device, group=group) for group in discovery_info["groups"] + ] async_add_entities(new_entities) +@callback +def async_add_insteon_devices( + hass: HomeAssistant, + platform: Platform, + entity_type: type[InsteonEntity], + async_add_entities: AddEntitiesCallback, +) -> None: + """Add all entities to a platform.""" + for address in devices: + device = devices[address] + groups = get_device_platform_groups(device, platform) + discovery_info = {"address": address, "groups": groups} + async_add_insteon_entities( + hass, platform, entity_type, async_add_entities, discovery_info + ) + + def get_usb_ports() -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" ports = list_ports.comports() From e0db9ab0410a28638de11f6da4c9865d4aa90d93 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 09:51:26 +0200 Subject: [PATCH 015/857] Add CONFIG_SCHEMA to broadlink (#93854) * Add CONFIG_SCHEMA to broadlink * Simplify error message * Rename to no_yaml_config_schema * Add tests * Raise issue * Tweak repairs issue description and title * Update homeassistant/helpers/config_validation.py * Add link * Update homeassistant/components/homeassistant/strings.json Co-authored-by: Franck Nijhof * Fix logic, add test --------- Co-authored-by: Franck Nijhof --- .../components/broadlink/__init__.py | 3 + .../components/homeassistant/strings.json | 4 +- homeassistant/helpers/config_validation.py | 50 ++++++++++++++++ tests/helpers/test_config_validation.py | 58 ++++++++++++++++++- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 3b1312a64c5..f9c00b8d4d5 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -5,12 +5,15 @@ from dataclasses import dataclass, field from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .device import BroadlinkDevice from .heartbeat import BroadlinkHeartbeat +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + @dataclass class BroadlinkData: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 55a40e7ba9d..6ea8a214dda 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -13,8 +13,8 @@ "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." }, "integration_key_no_support": { - "title": "This integration does not support YAML configuration", - "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but the configuration settings defined in YAML are not actually applied. \n\nTo resolve this: 1. Please remove this integration from your YAML configuration file.\n\n2. Restart Home Assistant." + "title": "The {domain} integration does not support YAML configuration", + "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but any configuration settings defined in YAML are not actually applied.\n\nTo resolve this:\n\n1. If you've not already done so, [set up the integration]({add_integration}).\n\n2. Remove `{domain}:` from your YAML configuration file.\n\n3. Restart Home Assistant." } }, "system_health": { diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 6551b7e4709..da39913b491 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -86,6 +86,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, async_get_hass, split_entity_id, @@ -1074,6 +1075,55 @@ def empty_config_schema(domain: str) -> Callable[[dict], dict]: return validator +def no_yaml_config_schema(domain: str) -> Callable[[dict], dict]: + """Return a config schema which logs if attempted to setup from YAML.""" + + module = inspect.getmodule(inspect.stack(context=0)[2].frame) + if module is not None: + module_name = module.__name__ + else: + # If Python is unable to access the sources files, the call stack frame + # will be missing information, so let's guard. + # https://github.com/home-assistant/core/issues/24982 + module_name = __name__ + logger_func = logging.getLogger(module_name).error + + def raise_issue() -> None: + # pylint: disable-next=import-outside-toplevel + from .issue_registry import IssueSeverity, async_create_issue + + add_integration = f"/_my_redirect/config_flow_start?domain={domain}" + with contextlib.suppress(LookupError): + hass = async_get_hass() + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"integration_key_no_support_{domain}", + is_fixable=False, + issue_domain=domain, + severity=IssueSeverity.ERROR, + translation_key="integration_key_no_support", + translation_placeholders={ + "domain": domain, + "add_integration": add_integration, + }, + ) + + def validator(config: dict) -> dict: + if domain in config: + logger_func( + ( + "The %s integration does not support YAML setup, please remove it " + "from your configuration file" + ), + domain, + ) + raise_issue() + return config + + return validator + + PLATFORM_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): string, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 58082838841..fb8cbc2f46b 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -11,8 +11,13 @@ import pytest import voluptuous as vol import homeassistant -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + selector, + template, +) def test_boolean() -> None: @@ -1475,7 +1480,7 @@ def test_positive_time_period_template() -> None: def test_empty_schema(caplog: pytest.LogCaptureFixture) -> None: - """Test if the current module cannot be inspected.""" + """Test empty_config_schema.""" expected_message = ( "The test_domain integration does not support any configuration parameters" ) @@ -1494,3 +1499,50 @@ def test_empty_schema_cant_find_module() -> None: """Test if the current module cannot be inspected.""" with patch("inspect.getmodule", return_value=None): cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + + +def test_no_yaml_schema(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test no_yaml_config_schema.""" + expected_issue = "integration_key_no_support_test_domain" + expected_message = ( + "The test_domain integration does not support YAML setup, please remove " + "it from your configuration" + ) + issue_registry = ir.async_get(hass) + + cv.no_yaml_config_schema("test_domain")({}) + assert expected_message not in caplog.text + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + cv.no_yaml_config_schema("test_domain")({"test_domain": {}}) + assert expected_message in caplog.text + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + issue_registry.async_delete(HOMEASSISTANT_DOMAIN, expected_issue) + + cv.no_yaml_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + assert expected_message in caplog.text + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + +def test_no_yaml_schema_cant_find_module() -> None: + """Test if the current module cannot be inspected.""" + with patch("inspect.getmodule", return_value=None): + cv.no_yaml_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + + +def test_no_yaml_schema_no_hass( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test if the the hass context var is not set in our context.""" + with patch( + "homeassistant.helpers.config_validation.async_get_hass", + side_effect=LookupError, + ): + cv.no_yaml_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + expected_message = ( + "The test_domain integration does not support YAML setup, please remove " + "it from your configuration" + ) + assert expected_message in caplog.text + issue_registry = ir.async_get(hass) + assert not issue_registry.issues From ba76bbee44a553795704ea41355bb3088042c1f3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 11:33:37 +0200 Subject: [PATCH 016/857] Remove async_setup from zerproc (#93903) --- homeassistant/components/zerproc/__init__.py | 12 +----------- tests/components/zerproc/test_config_flow.py | 9 --------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/homeassistant/components/zerproc/__init__.py b/homeassistant/components/zerproc/__init__.py index 43a768c3844..edd41d1a8e3 100644 --- a/homeassistant/components/zerproc/__init__.py +++ b/homeassistant/components/zerproc/__init__.py @@ -1,23 +1,13 @@ """Zerproc lights integration.""" -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN PLATFORMS = [Platform.LIGHT] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Zerproc platform.""" - hass.async_create_task( - hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Zerproc from a config entry.""" if DOMAIN not in hass.data: diff --git a/tests/components/zerproc/test_config_flow.py b/tests/components/zerproc/test_config_flow.py index 43988836416..0a493929b67 100644 --- a/tests/components/zerproc/test_config_flow.py +++ b/tests/components/zerproc/test_config_flow.py @@ -21,8 +21,6 @@ async def test_flow_success(hass: HomeAssistant) -> None: "homeassistant.components.zerproc.config_flow.pyzerproc.discover", return_value=["Light1", "Light2"], ), patch( - "homeassistant.components.zerproc.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.zerproc.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -36,7 +34,6 @@ async def test_flow_success(hass: HomeAssistant) -> None: assert result2["title"] == "Zerproc" assert result2["data"] == {} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -53,8 +50,6 @@ async def test_flow_no_devices_found(hass: HomeAssistant) -> None: "homeassistant.components.zerproc.config_flow.pyzerproc.discover", return_value=[], ), patch( - "homeassistant.components.zerproc.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.zerproc.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -66,7 +61,6 @@ async def test_flow_no_devices_found(hass: HomeAssistant) -> None: assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 @@ -83,8 +77,6 @@ async def test_flow_exceptions_caught(hass: HomeAssistant) -> None: "homeassistant.components.zerproc.config_flow.pyzerproc.discover", side_effect=pyzerproc.ZerprocException("TEST"), ), patch( - "homeassistant.components.zerproc.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.zerproc.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -96,5 +88,4 @@ async def test_flow_exceptions_caught(hass: HomeAssistant) -> None: assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 From 08bfe8f5cc5fd586a2ce155958f169e2c8f5c996 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 11:34:18 +0200 Subject: [PATCH 017/857] Remove async_setup from ring (#93902) --- homeassistant/components/ring/__init__.py | 18 ------------------ tests/components/ring/test_config_flow.py | 3 --- tests/components/ring/test_init.py | 9 --------- 3 files changed, 30 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index fb037eca05d..56aad1a845b 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -6,7 +6,6 @@ from collections.abc import Callable from datetime import timedelta from functools import partial import logging -from pathlib import Path from typing import Any from oauthlib.oauth2 import AccessDeniedError @@ -18,7 +17,6 @@ from homeassistant.const import Platform, __version__ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -41,22 +39,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Ring component.""" - if DOMAIN not in config: - return True - - def legacy_cleanup(): - """Clean up old tokens.""" - old_cache = Path(hass.config.path(".ring_cache.pickle")) - if old_cache.is_file(): - old_cache.unlink() - - await hass.async_add_executor_job(legacy_cleanup) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index b8e06fc7fd3..3e0c354e8fa 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -22,8 +22,6 @@ async def test_form(hass: HomeAssistant) -> None: fetch_token=Mock(return_value={"access_token": "mock-token"}) ), ), patch( - "homeassistant.components.ring.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.ring.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -39,7 +37,6 @@ async def test_form(hass: HomeAssistant) -> None: "username": "hello@home-assistant.io", "token": {"access_token": "mock-token"}, } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 68f50e3ee8f..7e3f5344f73 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -1,5 +1,4 @@ """The tests for the Ring component.""" -from datetime import timedelta import requests_mock @@ -9,12 +8,6 @@ from homeassistant.setup import async_setup_component from tests.common import load_fixture -ATTRIBUTION = "Data provided by Ring.com" - -VALID_CONFIG = { - "ring": {"username": "foo", "password": "bar", "scan_interval": timedelta(10)} -} - async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the setup.""" @@ -39,5 +32,3 @@ async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) - "https://api.ring.com/clients_api/doorbots/987652/health", text=load_fixture("doorboot_health_attrs.json", "ring"), ) - - assert await ring.async_setup(hass, VALID_CONFIG) From f2ea2a886cb1a688431b1503eb60e7dd381b7eb4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 11:36:20 +0200 Subject: [PATCH 018/857] Remove setup from remote_rpi_gpio (#93901) --- homeassistant/components/remote_rpi_gpio/__init__.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/remote_rpi_gpio/__init__.py b/homeassistant/components/remote_rpi_gpio/__init__.py index c77ae7fde9c..1654cc0c01d 100644 --- a/homeassistant/components/remote_rpi_gpio/__init__.py +++ b/homeassistant/components/remote_rpi_gpio/__init__.py @@ -2,9 +2,6 @@ from gpiozero import LED, DigitalInputDevice from gpiozero.pins.pigpio import PiGPIOFactory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType - CONF_BOUNCETIME = "bouncetime" CONF_INVERT_LOGIC = "invert_logic" CONF_PULL_MODE = "pull_mode" @@ -16,11 +13,6 @@ DEFAULT_PULL_MODE = "UP" DOMAIN = "remote_rpi_gpio" -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Raspberry Pi Remote GPIO component.""" - return True - - def setup_output(address, port, invert_logic): """Set up a GPIO as output.""" From a4b8b4f7c27ad9efa4752cec40170208cf12055d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 11:42:14 +0200 Subject: [PATCH 019/857] Add config entry only config schema to integrations a-r (#93899) --- homeassistant/components/discord/__init__.py | 4 +++- homeassistant/components/flux_led/__init__.py | 8 +++++++- homeassistant/components/google_assistant_sdk/__init__.py | 2 ++ homeassistant/components/google_mail/__init__.py | 4 +++- homeassistant/components/homekit_controller/__init__.py | 6 ++++-- homeassistant/components/mjpeg/__init__.py | 5 ++++- homeassistant/components/plex/__init__.py | 8 +++++++- homeassistant/components/ps4/__init__.py | 2 ++ homeassistant/components/pushbullet/__init__.py | 4 +++- homeassistant/components/pushover/__init__.py | 4 +++- 10 files changed, 38 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py index a52c079ac8e..be6907c4690 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -6,13 +6,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Discord component.""" diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 94f50caa1a2..8f141564884 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -14,7 +14,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -71,6 +75,8 @@ NAME_TO_WHITE_CHANNEL_TYPE: Final = { option.name.lower(): option for option in WhiteChannelType } +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + @callback def async_wifi_bulb_for_host( diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 7a9ca70bf14..1542de35e01 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -44,6 +44,8 @@ SERVICE_SEND_TEXT_COMMAND_SCHEMA = vol.All( }, ) +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Google Assistant SDK component.""" diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index a24d5c17874..c6053275a6e 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -21,6 +21,8 @@ from .services import async_setup_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Google Mail platform.""" diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index ecd8113a2bb..93cc1d5a6ff 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -16,16 +16,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_IDENTIFIERS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .config_flow import normalize_hkid from .connection import HKDevice -from .const import KNOWN_DEVICES +from .const import DOMAIN, KNOWN_DEVICES from .utils import async_get_controller _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a HomeKit connection on a config entry.""" diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py index 27131d9d18f..9492b595adb 100644 --- a/homeassistant/components/mjpeg/__init__.py +++ b/homeassistant/components/mjpeg/__init__.py @@ -2,10 +2,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .camera import MjpegCamera -from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, PLATFORMS +from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, PLATFORMS from .util import filter_urllib3_logging __all__ = [ @@ -15,6 +16,8 @@ __all__ = [ "filter_urllib3_logging", ] +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MJPEG IP Camera integration.""" diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 59ae14b8ca9..3c49c0112c0 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -18,7 +18,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( @@ -54,6 +58,8 @@ from .view import PlexImageView _LOGGER = logging.getLogger(__package__) +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + def is_plex_media_id(media_content_id): """Return whether the media_content_id is a valid Plex media_id.""" diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 0f5c57c5e4c..30c7d475196 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -53,6 +53,8 @@ PS4_COMMAND_SCHEMA = vol.Schema( PLATFORMS = [Platform.MEDIA_PLAYER] +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + class PS4Data: """Init Data Class.""" diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index bed0e94ccd9..276842df56c 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .api import PushBulletNotificationProvider @@ -24,6 +24,8 @@ PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the pushbullet component.""" diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index 551e374fbb6..77a6ffbb1ba 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -7,13 +7,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import CONF_USER_KEY, DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the pushover component.""" From a6b6b03f2e617bea0ec98d1a160f6f96c88b603e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 1 Jun 2023 11:46:59 +0200 Subject: [PATCH 020/857] Add video id to youtube sensor state attributes (#93668) * Add video id to state attributes * Make extra state attributes not optional * Revert "Make extra state attributes not optional" This reverts commit d2f9e936c809dd50a5e4bbdaa181c9c9ddd3d217. --- homeassistant/components/youtube/sensor.py | 13 +++++++++++++ tests/components/youtube/test_sensor.py | 1 + 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 6c75ef3bf8c..7f92ec0786a 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -18,6 +18,7 @@ from .const import ( ATTR_SUBSCRIBER_COUNT, ATTR_THUMBNAIL, ATTR_TITLE, + ATTR_VIDEO_ID, COORDINATOR, DOMAIN, ) @@ -30,6 +31,7 @@ class YouTubeMixin: value_fn: Callable[[Any], StateType] entity_picture_fn: Callable[[Any], str] + attributes_fn: Callable[[Any], dict[str, Any]] | None @dataclass @@ -44,6 +46,9 @@ SENSOR_TYPES = [ icon="mdi:youtube", value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE], entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL], + attributes_fn=lambda channel: { + ATTR_VIDEO_ID: channel[ATTR_LATEST_VIDEO][ATTR_VIDEO_ID] + }, ), YouTubeSensorEntityDescription( key="subscribers", @@ -52,6 +57,7 @@ SENSOR_TYPES = [ native_unit_of_measurement="subscribers", value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT], entity_picture_fn=lambda channel: channel[ATTR_ICON], + attributes_fn=None, ), ] @@ -84,3 +90,10 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): def entity_picture(self) -> str: """Return the value reported by the sensor.""" return self.entity_description.entity_picture_fn(self._channel) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the extra state attributes.""" + if self.entity_description.attributes_fn: + return self.entity_description.attributes_fn(self._channel) + return None diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index f4dbd9cc3a5..1363a4468a7 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -25,6 +25,7 @@ async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> state.attributes["entity_picture"] == "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg" ) + assert state.attributes["video_id"] == "wysukDrMdqU" state = hass.states.get("sensor.google_for_developers_subscribers") assert state From fd08cfb0748f2ec3e276ba5eeec1e0f31155aab6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 12:17:55 +0200 Subject: [PATCH 021/857] Add empty config schema to mobile_app (#93900) --- homeassistant/components/mobile_app/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 70c23da66e2..3d33af38761 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -10,7 +10,11 @@ from homeassistant.components.webhook import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, discovery +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery, +) from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -36,6 +40,8 @@ from .webhook import handle_webhook PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the mobile app component.""" From c5dd540ffc96b20346f4253a540e3a3e82d18d43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 12:18:54 +0200 Subject: [PATCH 022/857] Remove async_setup from econet (#93892) --- homeassistant/components/econet/__init__.py | 10 +--------- tests/components/econet/test_config_flow.py | 16 ++++------------ 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 6fa54fc70fb..afba9ba6837 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -19,7 +19,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from .const import API_CLIENT, DOMAIN, EQUIPMENT @@ -36,14 +35,6 @@ PUSH_UPDATE = "econet.push_update" INTERVAL = timedelta(minutes=60) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the EcoNet component.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][API_CLIENT] = {} - hass.data[DOMAIN][EQUIPMENT] = {} - return True - - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up EcoNet as config entry.""" @@ -65,6 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) except (ClientError, GenericHTTPError, InvalidResponseFormat) as err: raise ConfigEntryNotReady from err + hass.data.setdefault(DOMAIN, {API_CLIENT: {}, EQUIPMENT: {}}) hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api hass.data[DOMAIN][EQUIPMENT][config_entry.entry_id] = equipment diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py index d01d6163285..3444cc83834 100644 --- a/tests/components/econet/test_config_flow.py +++ b/tests/components/econet/test_config_flow.py @@ -25,9 +25,7 @@ async def test_bad_credentials(hass: HomeAssistant) -> None: with patch( "pyeconet.EcoNetApiInterface.login", side_effect=InvalidCredentialsError(), - ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( - "homeassistant.components.econet.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.econet.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -55,9 +53,7 @@ async def test_generic_error_from_library(hass: HomeAssistant) -> None: with patch( "pyeconet.EcoNetApiInterface.login", side_effect=PyeconetError(), - ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( - "homeassistant.components.econet.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.econet.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -85,9 +81,7 @@ async def test_auth_worked(hass: HomeAssistant) -> None: with patch( "pyeconet.EcoNetApiInterface.login", return_value=EcoNetApiInterface, - ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( - "homeassistant.components.econet.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.econet.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -122,9 +116,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: with patch( "pyeconet.EcoNetApiInterface.login", return_value=EcoNetApiInterface, - ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( - "homeassistant.components.econet.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.econet.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ From 4e5902c15bb957e5be75e719c71ed085995e15f3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 12:19:14 +0200 Subject: [PATCH 023/857] Remove async_setup from gpslogger (#93893) --- homeassistant/components/gpslogger/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 5331f6e7029..9f00e2cb52d 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ACCURACY, @@ -55,12 +54,6 @@ WEBHOOK_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up the GPSLogger component.""" - hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} - return True - - async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook with GPSLogger request.""" try: @@ -95,6 +88,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure based on config entry.""" + hass.data.setdefault(DOMAIN, {"devices": set(), "unsub_device_tracker": {}}) webhook.async_register( hass, DOMAIN, "GPSLogger", entry.data[CONF_WEBHOOK_ID], handle_webhook ) From 4f153a8f9093f8a66713fd38f277b6f33089cfba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 12:20:04 +0200 Subject: [PATCH 024/857] Remove async_setup from hyperion (#93894) --- homeassistant/components/hyperion/__init__.py | 8 +------- tests/components/hyperion/test_config_flow.py | 6 +----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 6c2842c190e..ea038b3b408 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -19,7 +19,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_INSTANCE_CLIENTS, @@ -104,12 +103,6 @@ async def async_create_connect_hyperion_client( return hyperion_client -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Hyperion component.""" - hass.data[DOMAIN] = {} - return True - - @callback def listen_for_instance_updates( hass: HomeAssistant, @@ -191,6 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # We need 1 root client (to manage instances being removed/added) and then 1 client # per Hyperion server instance which is shared for all entities associated with # that instance. + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { CONF_ROOT_CLIENT: hyperion_client, CONF_INSTANCE_CLIENTS: {}, diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index ad71f392bc6..635fd24a795 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -150,8 +150,6 @@ async def _configure_flow( user_input = user_input or {} with patch( - "homeassistant.components.hyperion.async_setup", return_value=True - ), patch( "homeassistant.components.hyperion.async_setup_entry", return_value=True, ): @@ -836,9 +834,7 @@ async def test_reauth_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.hyperion.client.HyperionClient", return_value=client - ), patch("homeassistant.components.hyperion.async_setup", return_value=True), patch( - "homeassistant.components.hyperion.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.hyperion.async_setup_entry", return_value=True): result = await _init_flow( hass, source=SOURCE_REAUTH, From 15e5cf01bbb72b553874ed75a54079b60c4b1707 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 12:32:14 +0200 Subject: [PATCH 025/857] Add `silabs_multiprotocol` platform (#92904) * Add silabs_multiprotocol platform * Add new files * Add ZHA tests * Prevent ZHA from creating database during tests * Add delay parameter to async_change_channel * Add the updated dataset to the dataset store * Allow MultipanProtocol.async_change_channel to return a task * Notify user about the duration of migration * Update tests --- .../silabs_multiprotocol_addon.py | 261 ++++++++++++++++-- .../homeassistant_hardware/strings.json | 19 ++ .../homeassistant_sky_connect/strings.json | 19 ++ .../homeassistant_yellow/strings.json | 19 ++ .../components/otbr/silabs_multiprotocol.py | 87 ++++++ homeassistant/components/otbr/util.py | 50 ++-- homeassistant/components/zha/config_flow.py | 2 +- .../components/zha/silabs_multiprotocol.py | 81 ++++++ .../homeassistant_hardware/conftest.py | 13 +- .../test_silabs_multiprotocol_addon.py | 209 +++++++++++++- .../homeassistant_sky_connect/conftest.py | 13 +- .../homeassistant_yellow/conftest.py | 13 +- tests/components/otbr/conftest.py | 12 +- tests/components/otbr/test_config_flow.py | 17 +- tests/components/otbr/test_init.py | 30 +- .../otbr/test_silabs_multiprotocol.py | 175 ++++++++++++ tests/components/otbr/test_util.py | 55 +--- tests/components/otbr/test_websocket_api.py | 27 +- .../zha/test_silabs_multiprotocol.py | 118 ++++++++ 19 files changed, 1072 insertions(+), 148 deletions(-) create mode 100644 homeassistant/components/otbr/silabs_multiprotocol.py create mode 100644 homeassistant/components/zha/silabs_multiprotocol.py create mode 100644 tests/components/otbr/test_silabs_multiprotocol.py create mode 100644 tests/components/zha/test_silabs_multiprotocol.py diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 8c502f080f6..34ab9a3cedb 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod import asyncio import dataclasses import logging -from typing import Any +from typing import Any, Protocol import voluptuous as vol import yarl @@ -19,12 +19,19 @@ from homeassistant.components.hassio import ( hostname_from_addon_slug, is_hassio, ) -from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN -from homeassistant.components.zha.radio_manager import ZhaMultiPANMigrationHelper from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -39,17 +46,144 @@ CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware" CONF_ADDON_DEVICE = "device" CONF_ENABLE_MULTI_PAN = "enable_multi_pan" +DEFAULT_CHANNEL = 15 +DEFAULT_CHANNEL_CHANGE_DELAY = 5 * 60 # Thread recommendation + +STORAGE_KEY = "homeassistant_hardware.silabs" +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 1 +SAVE_DELAY = 10 + @singleton(DATA_ADDON_MANAGER) -@callback -def get_addon_manager(hass: HomeAssistant) -> AddonManager: +async def get_addon_manager(hass: HomeAssistant) -> MultiprotocolAddonManager: """Get the add-on manager.""" - return AddonManager( - hass, - LOGGER, - "Silicon Labs Multiprotocol", - SILABS_MULTIPROTOCOL_ADDON_SLUG, - ) + manager = MultiprotocolAddonManager(hass) + await manager.async_setup() + return manager + + +class MultiprotocolAddonManager(AddonManager): + """Silicon Labs Multiprotocol add-on manager.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the manager.""" + super().__init__( + hass, + LOGGER, + "Silicon Labs Multiprotocol", + SILABS_MULTIPROTOCOL_ADDON_SLUG, + ) + self._channel: int | None = None + self._platforms: dict[str, MultipanProtocol] = {} + self._store: Store[dict[str, Any]] = Store( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def async_setup(self) -> None: + """Set up the manager.""" + await async_process_integration_platforms( + self._hass, "silabs_multiprotocol", self._register_multipan_platform + ) + await self.async_load() + + async def _register_multipan_platform( + self, hass: HomeAssistant, integration_domain: str, platform: MultipanProtocol + ) -> None: + """Register a multipan platform.""" + self._platforms[integration_domain] = platform + if self._channel is not None or not await platform.async_using_multipan(hass): + return + + new_channel = await platform.async_get_channel(hass) + if new_channel is None: + return + + _LOGGER.info( + "Setting multipan channel to %s (source: '%s')", + new_channel, + integration_domain, + ) + self.async_set_channel(new_channel) + + async def async_change_channel( + self, channel: int, delay: float + ) -> list[asyncio.Task]: + """Change the channel and notify platforms.""" + self.async_set_channel(channel) + + tasks = [] + + for platform in self._platforms.values(): + if not await platform.async_using_multipan(self._hass): + continue + task = await platform.async_change_channel(self._hass, channel, delay) + if not task: + continue + tasks.append(task) + + return tasks + + @callback + def async_get_channel(self) -> int | None: + """Get the channel.""" + return self._channel + + @callback + def async_set_channel(self, channel: int) -> None: + """Set the channel without notifying platforms. + + This must only be called when first initializing the manager. + """ + self._channel = channel + self.async_schedule_save() + + async def async_load(self) -> None: + """Load the store.""" + data = await self._store.async_load() + + if data is not None: + self._channel = data["channel"] + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the store.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: + """Return data to store in a file.""" + data: dict[str, Any] = {} + data["channel"] = self._channel + return data + + +class MultipanProtocol(Protocol): + """Define the format of multipan platforms.""" + + async def async_change_channel( + self, hass: HomeAssistant, channel: int, delay: float + ) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured or the multiprotocol add-on is not used. + """ + + async def async_get_channel(self, hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured or the multiprotocol add-on is not used. + """ + + async def async_using_multipan(self, hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ @dataclasses.dataclass @@ -82,6 +216,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Set up the options flow.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) + # If we install the add-on we should uninstall it on entry remove. self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None @@ -117,7 +256,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -128,7 +267,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_set_addon_config(self, config: dict) -> None: """Set Silicon Labs Multiprotocol add-on config.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_set_addon_options(config) except AddonError as err: @@ -137,7 +276,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_install_addon(self) -> None: """Install the Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_schedule_install_addon() finally: @@ -213,6 +352,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure the Silicon Labs Multiprotocol add-on.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.silabs_multiprotocol import ( + async_get_channel as async_get_zha_channel, + ) + addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -224,6 +376,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): **dataclasses.asdict(serial_port_settings), } + multipan_channel = DEFAULT_CHANNEL + # Initiate ZHA migration zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN) @@ -247,6 +401,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): _LOGGER.exception("Unexpected exception during ZHA migration") raise AbortFlow("zha_migration_failed") from err + if (zha_channel := await async_get_zha_channel(self.hass)) is not None: + multipan_channel = zha_channel + + # Initialize the shared channel + multipan_manager = await get_addon_manager(self.hass) + multipan_manager.async_set_channel(multipan_channel) + if new_addon_config != addon_config: # Copy the add-on config to keep the objects separate. self.original_addon_config = dict(addon_config) @@ -283,7 +444,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_start_addon(self) -> None: """Start Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_schedule_start_addon() finally: @@ -319,9 +480,73 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): serial_device = (await self._async_serial_port_settings()).device if addon_info.options.get(CONF_ADDON_DEVICE) == serial_device: - return await self.async_step_show_revert_guide() + return await self.async_step_show_addon_menu() return await self.async_step_addon_installed_other_device() + async def async_step_show_addon_menu( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show menu options for the addon.""" + return self.async_show_menu( + step_id="addon_menu", + menu_options=[ + "reconfigure_addon", + "uninstall_addon", + ], + ) + + async def async_step_reconfigure_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reconfigure the addon.""" + multipan_manager = await get_addon_manager(self.hass) + + if user_input is None: + channels = [str(x) for x in range(11, 27)] + suggested_channel = DEFAULT_CHANNEL + if (channel := multipan_manager.async_get_channel()) is not None: + suggested_channel = channel + data_schema = vol.Schema( + { + vol.Required( + "channel", + description={"suggested_value": str(suggested_channel)}, + ): SelectSelector( + SelectSelectorConfig( + options=channels, mode=SelectSelectorMode.DROPDOWN + ) + ) + } + ) + return self.async_show_form( + step_id="reconfigure_addon", data_schema=data_schema + ) + + # Change the shared channel + await multipan_manager.async_change_channel( + int(user_input["channel"]), DEFAULT_CHANNEL_CHANGE_DELAY + ) + return await self.async_step_notify_channel_change() + + async def async_step_notify_channel_change( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Notify that the channel change will take about five minutes.""" + if user_input is None: + return self.async_show_form( + step_id="notify_channel_change", + description_placeholders={ + "delay_minutes": str(DEFAULT_CHANNEL_CHANGE_DELAY // 60) + }, + ) + return self.async_create_entry(title="", data={}) + + async def async_step_uninstall_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Uninstall the addon (not implemented).""" + return await self.async_step_show_revert_guide() + async def async_step_show_revert_guide( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -348,7 +573,7 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: if not is_hassio(hass): return - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -375,7 +600,7 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> if not is_hassio(hass): return False - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) addon_info: AddonInfo = await addon_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 47549794fc8..60501397557 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -12,15 +12,34 @@ "addon_installed_other_device": { "title": "Multiprotocol support is already enabled for another device" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + }, "install_addon": { "title": "The Silicon Labs Multiprotocol add-on installation has started" }, + "notify_channel_change": { + "title": "Channel change initiated", + "description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes." + }, + "reconfigure_addon": { + "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support", + "data": { + "channel": "Channel" + } + }, "show_revert_guide": { "title": "Multiprotocol support is enabled for this device", "description": "If you want to change to Zigbee only firmware, please complete the following manual steps:\n\n * Remove the Silicon Labs Multiprotocol addon\n\n * Flash the Zigbee only firmware, follow the guide at https://github.com/NabuCasa/silabs-firmware/wiki/Flash-Silicon-Labs-radio-firmware-manually.\n\n * Reconfigure ZHA to migrate settings to the reflashed radio" }, "start_addon": { "title": "The Silicon Labs Multiprotocol add-on is starting." + }, + "uninstall_addon": { + "title": "Remove IEEE 802.15.4 radio multiprotocol support." } }, "error": { diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 970f9d97a4c..415df2092a1 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -11,15 +11,34 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" + } + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" + }, + "uninstall_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "error": { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index d97b01c7c84..c1069a7e755 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,6 +11,12 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, "hardware_settings": { "title": "Configure hardware settings", "data": { @@ -22,6 +28,10 @@ "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, "main_menu": { "menu_options": { "hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]", @@ -36,12 +46,21 @@ "reboot_now": "Reboot now" } }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" + } + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" + }, + "uninstall_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "error": { diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py new file mode 100644 index 00000000000..9a462c4610b --- /dev/null +++ b/homeassistant/components/otbr/silabs_multiprotocol.py @@ -0,0 +1,87 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import logging + +import aiohttp +from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.components.thread import async_add_dataset +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import DOMAIN +from .util import OTBRData + +_LOGGER = logging.getLogger(__name__) + + +async def async_change_channel(hass: HomeAssistant, channel: int, delay: float) -> None: + """Set the channel to be used. + + Does nothing if not configured. + """ + if DOMAIN not in hass.data: + return + + data: OTBRData = hass.data[DOMAIN] + await data.set_channel(channel, delay) + + # Import the new dataset + dataset_tlvs = await data.get_pending_dataset_tlvs() + if dataset_tlvs is None: + # The activation timer may have expired already + dataset_tlvs = await data.get_active_dataset_tlvs() + if dataset_tlvs is None: + # Don't try to import a None dataset + return + + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + dataset.pop(MeshcopTLVType.DELAYTIMER, None) + dataset.pop(MeshcopTLVType.PENDINGTIMESTAMP, None) + dataset_tlvs_str = tlv_parser.encode_tlv(dataset) + await async_add_dataset(hass, DOMAIN, dataset_tlvs_str) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + if DOMAIN not in hass.data: + return None + + data: OTBRData = hass.data[DOMAIN] + + try: + dataset = await data.get_active_dataset() + except ( + HomeAssistantError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as err: + _LOGGER.warning("Failed to communicate with OTBR %s", err) + return None + + if dataset is None: + return None + + return dataset.channel + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + if DOMAIN not in hass.data: + return False + + data: OTBRData = hass.data[DOMAIN] + return is_multiprotocol_url(data.url) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 5541ecb6874..5caebba5eb5 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -2,22 +2,22 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -import contextlib import dataclasses from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api -from python_otbr_api import tlv_parser +from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser from python_otbr_api.pskc import compute_pskc from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + MultiprotocolAddonManager, + get_addon_manager, is_multiprotocol_url, multi_pan_addon_using_device, ) from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO -from homeassistant.components.zha import api as zha_api from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -73,11 +73,21 @@ class OTBRData: """Enable or disable the router.""" return await self.api.set_enabled(enabled) + @_handle_otbr_error + async def get_active_dataset(self) -> python_otbr_api.ActiveDataSet | None: + """Get current active operational dataset, or None.""" + return await self.api.get_active_dataset() + @_handle_otbr_error async def get_active_dataset_tlvs(self) -> bytes | None: """Get current active operational dataset in TLVS format, or None.""" return await self.api.get_active_dataset_tlvs() + @_handle_otbr_error + async def get_pending_dataset_tlvs(self) -> bytes | None: + """Get current pending operational dataset in TLVS format, or None.""" + return await self.api.get_pending_dataset_tlvs() + @_handle_otbr_error async def create_active_dataset( self, dataset: python_otbr_api.ActiveDataSet @@ -90,43 +100,27 @@ class OTBRData: """Set current active operational dataset in TLVS format.""" await self.api.set_active_dataset_tlvs(dataset) + @_handle_otbr_error + async def set_channel( + self, channel: int, delay: float = PENDING_DATASET_DELAY_TIMER / 1000 + ) -> None: + """Set current channel.""" + await self.api.set_channel(channel, delay=int(delay * 1000)) + @_handle_otbr_error async def get_extended_address(self) -> bytes: """Get extended address (EUI-64).""" return await self.api.get_extended_address() -def _get_zha_url(hass: HomeAssistant) -> str | None: - """Get ZHA radio path, or None if there's no ZHA config entry.""" - with contextlib.suppress(ValueError): - return zha_api.async_get_radio_path(hass) - return None - - -async def _get_zha_channel(hass: HomeAssistant) -> int | None: - """Get ZHA channel, or None if there's no ZHA config entry.""" - zha_network_settings: zha_api.NetworkBackup | None - with contextlib.suppress(ValueError): - zha_network_settings = await zha_api.async_get_network_settings(hass) - if not zha_network_settings: - return None - channel: int = zha_network_settings.network_info.channel - # ZHA uses channel 0 when no channel is set - return channel or None - - async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" if not is_multiprotocol_url(otbr_url): # The OTBR is not sharing the radio, no restriction return None - zha_url = _get_zha_url(hass) - if not zha_url or not is_multiprotocol_url(zha_url): - # ZHA is not configured or not sharing the radio with this OTBR, no restriction - return None - - return await _get_zha_channel(hass) + addon_manager: MultiprotocolAddonManager = await get_addon_manager(hass) + return addon_manager.async_get_channel() async def _warn_on_channel_collision( diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index d0f124a0838..91bc2ac42a2 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -96,7 +96,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: yellow_radio.manufacturer = "Nabu Casa" # Present the multi-PAN addon as a setup option, if it's available - addon_manager = silabs_multiprotocol_addon.get_addon_manager(hass) + addon_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) try: addon_info = await addon_manager.async_get_addon_info() diff --git a/homeassistant/components/zha/silabs_multiprotocol.py b/homeassistant/components/zha/silabs_multiprotocol.py new file mode 100644 index 00000000000..aec52b4ac75 --- /dev/null +++ b/homeassistant/components/zha/silabs_multiprotocol.py @@ -0,0 +1,81 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import contextlib + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.core import HomeAssistant + +from . import api + +# The approximate time it takes ZHA to change channels on SiLabs coordinators +ZHA_CHANNEL_CHANGE_TIME_S = 10.27 + + +def _get_zha_url(hass: HomeAssistant) -> str | None: + """Return the ZHA radio path, or None if there's no ZHA config entry.""" + with contextlib.suppress(ValueError): + return api.async_get_radio_path(hass) + return None + + +async def _get_zha_channel(hass: HomeAssistant) -> int | None: + """Get ZHA channel, or None if there's no ZHA config entry.""" + zha_network_settings: api.NetworkBackup | None + with contextlib.suppress(ValueError): + zha_network_settings = await api.async_get_network_settings(hass) + if not zha_network_settings: + return None + channel: int = zha_network_settings.network_info.channel + # ZHA uses channel 0 when no channel is set + return channel or None + + +async def async_change_channel( + hass: HomeAssistant, channel: int, delay: float = 0 +) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + async def finish_migration() -> None: + """Finish the channel migration.""" + await asyncio.sleep(max(0, delay - ZHA_CHANNEL_CHANGE_TIME_S)) + return await api.async_change_channel(hass, channel) + + return hass.async_create_task(finish_migration()) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + return await _get_zha_channel(hass) + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return False + + return is_multiprotocol_url(zha_url) diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index 4add48781a9..60c766c7204 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for the Home Assistant Hardware integration.""" from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -32,6 +32,17 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index a195899136d..83702adcc3a 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -11,12 +11,16 @@ from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.setup import ATTR_COMPONENT from tests.common import ( MockConfigEntry, MockModule, + MockPlatform, + flush_store, mock_config_flow, mock_integration, mock_platform, @@ -96,6 +100,54 @@ def config_flow_handler( yield +class MockMultiprotocolPlatform(MockPlatform): + """A mock multiprotocol platform.""" + + channel = 15 + using_multipan = True + + def __init__(self, **kwargs: Any) -> None: + """Initialize.""" + super().__init__(**kwargs) + self.change_channel_calls = [] + + async def async_change_channel( + self, hass: HomeAssistant, channel: int, delay: float + ) -> None: + """Set the channel to be used.""" + self.change_channel_calls.append((channel, delay)) + + async def async_get_channel(self, hass: HomeAssistant) -> int | None: + """Return the channel.""" + return self.channel + + async def async_using_multipan(self, hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used.""" + return self.using_multipan + + +@pytest.fixture +def mock_multiprotocol_platform( + hass: HomeAssistant, +) -> Generator[FakeConfigFlow, None, None]: + """Fixture for a test silabs multiprotocol platform.""" + hass.config.components.add(TEST_DOMAIN) + platform = MockMultiprotocolPlatform() + mock_platform(hass, f"{TEST_DOMAIN}.silabs_multiprotocol", platform) + return platform + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema: + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + async def test_option_flow_install_multi_pan_addon( hass: HomeAssistant, addon_store_info, @@ -215,7 +267,13 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "configure_addon" install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + assert multipan_manager._channel is None + with patch( + "homeassistant.components.zha.silabs_multiprotocol.async_get_channel", + return_value=11, + ): + result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" set_addon_options.assert_called_once_with( @@ -230,6 +288,8 @@ async def test_option_flow_install_multi_pan_addon_zha( } }, ) + # Check the channel is initialized from ZHA + assert multipan_manager._channel == 11 # Check the ZHA config entry data is updated assert zha_config_entry.data == { "device": { @@ -393,7 +453,64 @@ async def test_option_flow_addon_installed_other_device( assert result["type"] == FlowResultType.CREATE_ENTRY -async def test_option_flow_addon_installed_same_device( +@pytest.mark.parametrize( + ("configured_channel", "suggested_channel"), [(None, "15"), (11, "11")] +) +async def test_option_flow_addon_installed_same_device_reconfigure( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + mock_multiprotocol_platform: MockMultiprotocolPlatform, + configured_channel: int | None, + suggested_channel: int, +) -> None: + """Test installing the multi pan addon.""" + mock_integration(hass, MockModule("hassio")) + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager._channel = configured_channel + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", + side_effect=Mock(return_value=True), + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "reconfigure_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure_addon" + assert get_suggested(result["data_schema"].schema, "channel") == suggested_channel + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"channel": "14"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "notify_channel_change" + assert result["description_placeholders"] == {"delay_minutes": "5"} + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.CREATE_ENTRY + + assert mock_multiprotocol_platform.change_channel_calls == [(14, 300)] + + +async def test_option_flow_addon_installed_same_device_uninstall( hass: HomeAssistant, addon_info, addon_store_info, @@ -417,8 +534,15 @@ async def test_option_flow_addon_installed_same_device( side_effect=Mock(return_value=True), ): result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "show_revert_guide" + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "show_revert_guide" result = await hass.config_entries.options.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -806,3 +930,80 @@ def test_is_multiprotocol_url() -> None: "http://core-silabs-multiprotocol:8081" ) assert not silabs_multiprotocol_addon.is_multiprotocol_url("/dev/ttyAMA1") + + +@pytest.mark.parametrize( + ( + "initial_multipan_channel", + "platform_using_multipan", + "platform_channel", + "new_multipan_channel", + ), + [ + (None, True, 15, 15), + (None, False, 15, None), + (11, True, 15, 11), + (None, True, None, None), + ], +) +async def test_import_channel( + hass: HomeAssistant, + initial_multipan_channel: int | None, + platform_using_multipan: bool, + platform_channel: int | None, + new_multipan_channel: int | None, +) -> None: + """Test channel is initialized from first platform.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager._channel = initial_multipan_channel + + mock_multiprotocol_platform = MockMultiprotocolPlatform() + mock_multiprotocol_platform.channel = platform_channel + mock_multiprotocol_platform.using_multipan = platform_using_multipan + + hass.config.components.add(TEST_DOMAIN) + mock_platform( + hass, f"{TEST_DOMAIN}.silabs_multiprotocol", mock_multiprotocol_platform + ) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: TEST_DOMAIN}) + await hass.async_block_till_done() + + assert multipan_manager.async_get_channel() == new_multipan_channel + + +@pytest.mark.parametrize( + ( + "platform_using_multipan", + "expected_calls", + ), + [ + (True, [(15, 10)]), + (False, []), + ], +) +async def test_change_channel( + hass: HomeAssistant, + mock_multiprotocol_platform: MockMultiprotocolPlatform, + platform_using_multipan: bool, + expected_calls: list[int], +) -> None: + """Test channel is initialized from first platform.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + mock_multiprotocol_platform.using_multipan = platform_using_multipan + + await multipan_manager.async_change_channel(15, 10) + assert mock_multiprotocol_platform.change_channel_calls == expected_calls + + +async def test_load_preferences(hass: HomeAssistant) -> None: + """Make sure that we can load/save data correctly.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + assert multipan_manager._channel != 11 + multipan_manager.async_set_channel(11) + + await flush_store(multipan_manager._store) + + multipan_manager2 = silabs_multiprotocol_addon.MultiprotocolAddonManager(hass) + await multipan_manager2.async_setup() + + assert multipan_manager._channel == multipan_manager2._channel diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 7fcc1f86880..3677b4ea8f1 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for the Home Assistant SkyConnect integration.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -34,6 +34,17 @@ def mock_zha(): yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index bc48c6b01fd..e4a666f9f04 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for the Home Assistant Yellow integration.""" from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -32,6 +32,17 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index f0b3ca0a18d..bb3b474519e 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -1,9 +1,10 @@ """Test fixtures for the Open Thread Border Router integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from homeassistant.components import otbr +from homeassistant.core import HomeAssistant from . import CONFIG_ENTRY_DATA, DATASET_CH16 @@ -31,3 +32,12 @@ async def otbr_config_entry_fixture(hass): @pytest.fixture(autouse=True) def use_mocked_zeroconf(mock_async_zeroconf): """Mock zeroconf in all tests.""" + + +@pytest.fixture(name="multiprotocol_addon_manager_mock") +def multiprotocol_addon_manager_mock_fixture(hass: HomeAssistant): + """Mock the Silicon Labs Multiprotocol add-on manager.""" + mock_manager = Mock() + mock_manager.async_get_channel = Mock(return_value=None) + with patch.dict(hass.data, {"silabs_multiprotocol_addon_manager": mock_manager}): + yield mock_manager diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index faec90282df..cfb47a28bcf 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from http import HTTPStatus from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch import aiohttp import pytest @@ -309,7 +309,9 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -321,8 +323,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", @@ -330,13 +331,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( ), patch( "homeassistant.components.otbr.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA ) diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 3d646287ce1..990c015244f 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,7 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest @@ -59,7 +59,9 @@ async def test_import_dataset(hass: HomeAssistant) -> None: ) -async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None: +async def test_import_share_radio_channel_collision( + hass: HomeAssistant, multiprotocol_addon_manager_mock +) -> None: """Test the active dataset is imported at setup. This imports a dataset with different channel than ZHA when ZHA and OTBR share @@ -67,8 +69,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None """ issue_registry = ir.async_get(hass) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -81,13 +82,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) @@ -99,7 +94,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None @pytest.mark.parametrize("dataset", [DATASET_CH15, DATASET_NO_CHANNEL]) async def test_import_share_radio_no_channel_collision( - hass: HomeAssistant, dataset: bytes + hass: HomeAssistant, multiprotocol_addon_manager_mock, dataset: bytes ) -> None: """Test the active dataset is imported at setup. @@ -107,8 +102,7 @@ async def test_import_share_radio_no_channel_collision( """ issue_registry = ir.async_get(hass) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -121,13 +115,7 @@ async def test_import_share_radio_no_channel_collision( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex()) diff --git a/tests/components/otbr/test_silabs_multiprotocol.py b/tests/components/otbr/test_silabs_multiprotocol.py new file mode 100644 index 00000000000..8dd07db6f22 --- /dev/null +++ b/tests/components/otbr/test_silabs_multiprotocol.py @@ -0,0 +1,175 @@ +"""Test OTBR Silicon Labs Multiprotocol support.""" +from unittest.mock import patch + +import pytest +from python_otbr_api import ActiveDataSet, tlv_parser + +from homeassistant.components import otbr +from homeassistant.components.otbr import ( + silabs_multiprotocol as otbr_silabs_multiprotocol, +) +from homeassistant.components.thread import dataset_store +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import DATASET_CH16 + +OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" +OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" +DATASET_CH16_PENDING = ( + "0E080000000000020000" # ACTIVETIMESTAMP + "340400006699" # DELAYTIMER + "000300000F" # CHANNEL + "35060004001FFFE0" # CHANNELMASK + "0208F642646DA209B1C0" # EXTPANID + "0708FDF57B5A0FE2AAF6" # MESHLOCALPREFIX + "0510DE98B5BA1A528FEE049D4B4B01835375" # NETWORKKEY + "030D4F70656E546872656164204841" # NETWORKNAME + "010225A4" # PANID + "0410F5DD18371BFD29E1A601EF6FFAD94C03" # PSKC + "0C0402A0F7F8" # SECURITYPOLICY +) + + +async def test_async_change_channel(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_change_channel.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=bytes.fromhex(DATASET_CH16_PENDING), + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + pending_dataset = tlv_parser.parse_tlv(DATASET_CH16_PENDING) + pending_dataset.pop(tlv_parser.MeshcopTLVType.DELAYTIMER) + + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == tlv_parser.encode_tlv( + pending_dataset + ) + + +async def test_async_change_channel_no_pending( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_change_channel when the pending dataset already expired.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + return_value=bytes.fromhex(DATASET_CH16_PENDING), + ), patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=None, + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + pending_dataset = tlv_parser.parse_tlv(DATASET_CH16_PENDING) + pending_dataset.pop(tlv_parser.MeshcopTLVType.DELAYTIMER) + + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == tlv_parser.encode_tlv( + pending_dataset + ) + + +async def test_async_change_channel_no_update( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_change_channel when we didn't get a dataset from the OTBR.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + return_value=None, + ), patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=None, + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + +async def test_async_change_channel_no_otbr(hass: HomeAssistant) -> None: + """Test async_change_channel when otbr is not configured.""" + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel: + await otbr_silabs_multiprotocol.async_change_channel(hass, 16, delay=0) + mock_set_channel.assert_not_awaited() + + +async def test_async_get_channel(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=ActiveDataSet(channel=11), + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) == 11 + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_no_dataset( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=None, + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_error(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + side_effect=HomeAssistantError, + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_no_otbr(hass: HomeAssistant) -> None: + """Test test_async_get_channel when otbr is not configured.""" + + with patch("python_otbr_api.OTBR.get_active_dataset") as mock_get_active_dataset: + await otbr_silabs_multiprotocol.async_get_channel(hass) + mock_get_active_dataset.assert_not_awaited() + + +@pytest.mark.parametrize( + ("url", "expected"), + [(OTBR_MULTIPAN_URL, True), (OTBR_NON_MULTIPAN_URL, False)], +) +async def test_async_using_multipan( + hass: HomeAssistant, otbr_config_entry, url: str, expected: bool +) -> None: + """Test async_change_channel when otbr is not configured.""" + data: otbr.OTBRData = hass.data[otbr.DOMAIN] + data.url = url + + assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is expected + + +async def test_async_using_multipan_no_otbr(hass: HomeAssistant) -> None: + """Test async_change_channel when otbr is not configured.""" + + assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is False diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py index af5306b3581..f8ed79b91ee 100644 --- a/tests/components/otbr/test_util.py +++ b/tests/components/otbr/test_util.py @@ -1,5 +1,4 @@ """Test OTBR Utility functions.""" -from unittest.mock import Mock, patch from homeassistant.components import otbr from homeassistant.core import HomeAssistant @@ -8,51 +7,19 @@ OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" -async def test_get_allowed_channel(hass: HomeAssistant) -> None: +async def test_get_allowed_channel( + hass: HomeAssistant, multiprotocol_addon_manager_mock +) -> None: """Test get_allowed_channel.""" - zha_networksettings = Mock() - zha_networksettings.network_info.channel = 15 - - # OTBR multipan + No ZHA -> no restriction + # OTBR multipan + No configured channel -> no restriction + multiprotocol_addon_manager_mock.async_get_channel.return_value = None assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None - # OTBR multipan + ZHA multipan empty settings -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=None, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + # OTBR multipan + multipan using channel 15 -> 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 - # OTBR multipan + ZHA not multipan using channel 15 -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="/dev/ttyAMA1", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None - - # OTBR multipan + ZHA multipan using channel 15 -> 15 - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 - - # OTBR not multipan + ZHA multipan using channel 15 -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None + # OTBR no multipan + multipan using channel 15 -> no restriction + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 + assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index bfc3f09d6fe..1feebe9c02c 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,5 +1,5 @@ """Test OTBR Websocket API.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest import python_otbr_api @@ -273,6 +273,7 @@ async def test_set_network_no_entry( async def test_set_network_channel_conflict( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, otbr_config_entry, websocket_client, ) -> None: @@ -281,24 +282,16 @@ async def test_set_network_channel_conflict( dataset_store = await thread.dataset_store.async_get_store(hass) dataset_id = list(dataset_store.datasets)[0] - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): - await websocket_client.send_json_auto_id( - { - "type": "otbr/set_network", - "dataset_id": dataset_id, - } - ) + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) - msg = await websocket_client.receive_json() + msg = await websocket_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "channel_conflict" diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py new file mode 100644 index 00000000000..beae0230901 --- /dev/null +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -0,0 +1,118 @@ +"""Test ZHA Silicon Labs Multiprotocol support.""" +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import call, patch + +import pytest +import zigpy.backups +import zigpy.state + +from homeassistant.components import zha +from homeassistant.components.zha import api, silabs_multiprotocol +from homeassistant.core import HomeAssistant + +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + + +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only set up the required and required base platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", ()): + yield + + +async def test_async_get_channel_active(hass: HomeAssistant, setup_zha) -> None: + """Test reading channel with an active ZHA installation.""" + await setup_zha() + + assert await silabs_multiprotocol.async_get_channel(hass) == 15 + + +async def test_async_get_channel_missing( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test reading channel with an inactive ZHA installation, no valid channel.""" + await setup_zha() + + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) + + # Network settings were never loaded for whatever reason + zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() + zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() + + with patch( + "bellows.zigbee.application.ControllerApplication.__new__", + return_value=zigpy_app_controller, + ): + assert await silabs_multiprotocol.async_get_channel(hass) is None + + +async def test_async_get_channel_no_zha(hass: HomeAssistant) -> None: + """Test reading channel with no ZHA config entries and no database.""" + assert await silabs_multiprotocol.async_get_channel(hass) is None + + +async def test_async_using_multipan_active(hass: HomeAssistant, setup_zha) -> None: + """Test async_using_multipan with an active ZHA installation.""" + await setup_zha() + + assert await silabs_multiprotocol.async_using_multipan(hass) is False + + +async def test_async_using_multipan_no_zha(hass: HomeAssistant) -> None: + """Test async_using_multipan with no ZHA config entries and no database.""" + assert await silabs_multiprotocol.async_using_multipan(hass) is False + + +async def test_change_channel( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel: + task = await silabs_multiprotocol.async_change_channel(hass, 20) + await task + + assert mock_move_network_to_channel.mock_calls == [call(20)] + + +async def test_change_channel_no_zha( + hass: HomeAssistant, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel with no ZHA config entries and no database.""" + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel: + task = await silabs_multiprotocol.async_change_channel(hass, 20) + assert task is None + + assert mock_move_network_to_channel.mock_calls == [] + + +@pytest.mark.parametrize(("delay", "sleep"), [(0, 0), (5, 0), (15, 15 - 10.27)]) +async def test_change_channel_delay( + hass: HomeAssistant, + setup_zha, + zigpy_app_controller: ControllerApplication, + delay: float, + sleep: float, +) -> None: + """Test changing the channel with a delay.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel, patch( + "homeassistant.components.zha.silabs_multiprotocol.asyncio.sleep", autospec=True + ) as mock_sleep: + task = await silabs_multiprotocol.async_change_channel(hass, 20, delay=delay) + await task + + assert mock_move_network_to_channel.mock_calls == [call(20)] + assert mock_sleep.mock_calls == [call(sleep)] From e05c04fadb6a3ea28e6dbb3bf2165b0e7893d103 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Thu, 1 Jun 2023 13:01:57 +0200 Subject: [PATCH 026/857] Add Ezviz light entity (#93710) * Initial commit * Add ezviz light entity. * coveragerc * Apply suggestions from code review --------- Co-authored-by: Erik Montnemery --- .coveragerc | 1 + homeassistant/components/ezviz/__init__.py | 1 + homeassistant/components/ezviz/light.py | 125 +++++++++++++++++++ homeassistant/components/ezviz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/ezviz/light.py diff --git a/.coveragerc b/.coveragerc index c244617007f..e653b35b62d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -327,6 +327,7 @@ omit = homeassistant/components/ezviz/__init__.py homeassistant/components/ezviz/binary_sensor.py homeassistant/components/ezviz/camera.py + homeassistant/components/ezviz/light.py homeassistant/components/ezviz/coordinator.py homeassistant/components/ezviz/number.py homeassistant/components/ezviz/entity.py diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 2966c339f95..9386a407acb 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -35,6 +35,7 @@ PLATFORMS_BY_TYPE: dict[str, list] = { ATTR_TYPE_CLOUD: [ Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py new file mode 100644 index 00000000000..38007962e4e --- /dev/null +++ b/homeassistant/components/ezviz/light.py @@ -0,0 +1,125 @@ +"""Support for EZVIZ light entity.""" +from __future__ import annotations + +from typing import Any + +from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt +from pyezviz.exceptions import HTTPError, PyEzvizError + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 +BRIGHTNESS_RANGE = (1, 255) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ lights based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizLight(coordinator, camera) + for camera in coordinator.data + for capibility, value in coordinator.data[camera]["supportExt"].items() + if capibility == str(SupportExt.SupportAlarmLight.value) + if value == "1" + ) + + +class EzvizLight(EzvizEntity, LightEntity): + """Representation of a EZVIZ light.""" + + _attr_has_entity_name = True + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + ) -> None: + """Initialize the light.""" + super().__init__(coordinator, serial) + self.battery_cam_type = bool( + self.data["device_category"] + == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value + ) + self._attr_unique_id = f"{serial}_Light" + self._attr_name = "Light" + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + return round( + percentage_to_ranged_value( + BRIGHTNESS_RANGE, + self.coordinator.data[self._serial]["alarm_light_luminance"], + ) + ) + + @property + def is_on(self) -> bool: + """Return the state of the light.""" + return self.data["switches"][DeviceSwitchType.ALARM_LIGHT.value] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + try: + if ATTR_BRIGHTNESS in kwargs: + data = ranged_value_to_percentage( + BRIGHTNESS_RANGE, kwargs[ATTR_BRIGHTNESS] + ) + + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.set_floodlight_brightness, + self._serial, + data, + ) + else: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, + self._serial, + DeviceSwitchType.ALARM_LIGHT.value, + 1, + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn on light {self._attr_name}" + ) from err + + if update_ok: + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, + self._serial, + DeviceSwitchType.ALARM_LIGHT.value, + 0, + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn off light {self._attr_name}" + ) from err + + if update_ok: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 219f4c87d13..6697fbce71d 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.0.12"] + "requirements": ["pyezviz==0.2.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ca4b9192a2..ce188858754 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1664,7 +1664,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.12 +pyezviz==0.2.0.15 # homeassistant.components.fibaro pyfibaro==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae8f7a56e12..509fe08b37a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1219,7 +1219,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.12 +pyezviz==0.2.0.15 # homeassistant.components.fibaro pyfibaro==0.7.1 From c1c319d4d1500bcc8054bddba17e16873832b5c3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 13:15:48 +0200 Subject: [PATCH 027/857] Rename `cv.no_yaml_config_schema` to `cv.config_entry_only_config_schema` (#93908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename cv.no_yaml_config_schema to cv.config_entry_only_config_schema * ✏️ --- .../components/broadlink/__init__.py | 2 +- homeassistant/components/discord/__init__.py | 2 +- homeassistant/components/flux_led/__init__.py | 2 +- .../google_assistant_sdk/__init__.py | 2 +- .../components/google_mail/__init__.py | 2 +- .../components/homeassistant/strings.json | 2 +- .../components/homekit_controller/__init__.py | 2 +- homeassistant/components/mjpeg/__init__.py | 2 +- homeassistant/components/plex/__init__.py | 2 +- homeassistant/components/ps4/__init__.py | 2 +- .../components/pushbullet/__init__.py | 2 +- homeassistant/components/pushover/__init__.py | 2 +- homeassistant/helpers/config_validation.py | 6 ++--- homeassistant/setup.py | 4 +-- tests/helpers/test_config_validation.py | 26 ++++++++++++------- 15 files changed, 33 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index f9c00b8d4d5..e6a769fd2c4 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -12,7 +12,7 @@ from .const import DOMAIN from .device import BroadlinkDevice from .heartbeat import BroadlinkHeartbeat -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @dataclass diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py index be6907c4690..329709e88d2 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -13,7 +13,7 @@ from .const import DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 8f141564884..100d63d8bf7 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -75,7 +75,7 @@ NAME_TO_WHITE_CHANNEL_TYPE: Final = { option.name.lower(): option for option in WhiteChannelType } -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @callback diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 1542de35e01..e2791f6000f 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -44,7 +44,7 @@ SERVICE_SEND_TEXT_COMMAND_SCHEMA = vol.All( }, ) -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index c6053275a6e..7e5281630bc 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -21,7 +21,7 @@ from .services import async_setup_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 6ea8a214dda..74bfa16e471 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,7 +12,7 @@ "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." }, - "integration_key_no_support": { + "config_entry_only": { "title": "The {domain} integration does not support YAML configuration", "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but any configuration settings defined in YAML are not actually applied.\n\nTo resolve this:\n\n1. If you've not already done so, [set up the integration]({add_integration}).\n\n2. Remove `{domain}:` from your YAML configuration file.\n\n3. Restart Home Assistant." } diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 93cc1d5a6ff..ed9b8ca4622 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -26,7 +26,7 @@ from .utils import async_get_controller _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py index 9492b595adb..a5bfc49edf6 100644 --- a/homeassistant/components/mjpeg/__init__.py +++ b/homeassistant/components/mjpeg/__init__.py @@ -16,7 +16,7 @@ __all__ = [ "filter_urllib3_logging", ] -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 3c49c0112c0..4ce5a359dcd 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -58,7 +58,7 @@ from .view import PlexImageView _LOGGER = logging.getLogger(__package__) -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) def is_plex_media_id(media_content_id): diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 30c7d475196..1c87a275126 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -53,7 +53,7 @@ PS4_COMMAND_SCHEMA = vol.Schema( PLATFORMS = [Platform.MEDIA_PLAYER] -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) class PS4Data: diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index 276842df56c..14d90d4ca0b 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -24,7 +24,7 @@ PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index 77a6ffbb1ba..c3b15b7c130 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -14,7 +14,7 @@ from .const import CONF_USER_KEY, DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] -CONFIG_SCHEMA = cv.no_yaml_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index da39913b491..7b8ece69392 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1075,7 +1075,7 @@ def empty_config_schema(domain: str) -> Callable[[dict], dict]: return validator -def no_yaml_config_schema(domain: str) -> Callable[[dict], dict]: +def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: """Return a config schema which logs if attempted to setup from YAML.""" module = inspect.getmodule(inspect.stack(context=0)[2].frame) @@ -1098,11 +1098,11 @@ def no_yaml_config_schema(domain: str) -> Callable[[dict], dict]: async_create_issue( hass, HOMEASSISTANT_DOMAIN, - f"integration_key_no_support_{domain}", + f"config_entry_only_{domain}", is_fixable=False, issue_domain=domain, severity=IssueSeverity.ERROR, - translation_key="integration_key_no_support", + translation_key="config_entry_only", translation_placeholders={ "domain": domain, "add_integration": add_integration, diff --git a/homeassistant/setup.py b/homeassistant/setup.py index d4b9be05ef4..b6db8c0ebb3 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -240,11 +240,11 @@ async def _async_setup_component( async_create_issue( hass, HOMEASSISTANT_DOMAIN, - f"integration_key_no_support_{domain}", + f"config_entry_only{domain}", is_fixable=False, severity=IssueSeverity.ERROR, issue_domain=domain, - translation_key="integration_key_no_support", + translation_key="config_entry_only", translation_placeholders={"domain": domain}, ) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index fb8cbc2f46b..b9b5f989cba 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1501,36 +1501,40 @@ def test_empty_schema_cant_find_module() -> None: cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) -def test_no_yaml_schema(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: - """Test no_yaml_config_schema.""" - expected_issue = "integration_key_no_support_test_domain" +def test_config_entry_only_schema( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test config_entry_only_config_schema.""" + expected_issue = "config_entry_only_test_domain" expected_message = ( "The test_domain integration does not support YAML setup, please remove " "it from your configuration" ) issue_registry = ir.async_get(hass) - cv.no_yaml_config_schema("test_domain")({}) + cv.config_entry_only_config_schema("test_domain")({}) assert expected_message not in caplog.text assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) - cv.no_yaml_config_schema("test_domain")({"test_domain": {}}) + cv.config_entry_only_config_schema("test_domain")({"test_domain": {}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) issue_registry.async_delete(HOMEASSISTANT_DOMAIN, expected_issue) - cv.no_yaml_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + cv.config_entry_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) -def test_no_yaml_schema_cant_find_module() -> None: +def test_config_entry_only_schema_cant_find_module() -> None: """Test if the current module cannot be inspected.""" with patch("inspect.getmodule", return_value=None): - cv.no_yaml_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + cv.config_entry_only_config_schema("test_domain")( + {"test_domain": {"foo": "bar"}} + ) -def test_no_yaml_schema_no_hass( +def test_config_entry_only_schema_no_hass( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test if the the hass context var is not set in our context.""" @@ -1538,7 +1542,9 @@ def test_no_yaml_schema_no_hass( "homeassistant.helpers.config_validation.async_get_hass", side_effect=LookupError, ): - cv.no_yaml_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + cv.config_entry_only_config_schema("test_domain")( + {"test_domain": {"foo": "bar"}} + ) expected_message = ( "The test_domain integration does not support YAML setup, please remove " "it from your configuration" From 09e8d7df0f5798bede1a498cef14a023a3e0691c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 13:30:52 +0200 Subject: [PATCH 028/857] Remove async_setup from sky_hub (#93911) --- homeassistant/components/sky_hub/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/sky_hub/__init__.py b/homeassistant/components/sky_hub/__init__.py index 40637d866ff..a5b8969018f 100644 --- a/homeassistant/components/sky_hub/__init__.py +++ b/homeassistant/components/sky_hub/__init__.py @@ -1,8 +1 @@ """The sky_hub component.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the sky_hub component.""" - return True From 677dd5b1fd789ee2fec10b8ad6162021028c08df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 13:37:02 +0200 Subject: [PATCH 029/857] Remove async_setup from traccar (#93912) --- homeassistant/components/traccar/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 1679965b070..c428ce7a5b1 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ACCURACY, @@ -55,12 +54,6 @@ WEBHOOK_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up the Traccar component.""" - hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} - return True - - async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook with Traccar request.""" try: @@ -94,6 +87,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure based on config entry.""" + hass.data.setdefault(DOMAIN, {"devices": set(), "unsub_device_tracker": {}}) webhook.async_register( hass, DOMAIN, "Traccar", entry.data[CONF_WEBHOOK_ID], handle_webhook ) From 6c7d922defd54f5355fe530390545a30bdfaadfa Mon Sep 17 00:00:00 2001 From: disforw Date: Thu, 1 Jun 2023 08:53:28 -0400 Subject: [PATCH 030/857] Adding myself as codeowner to QNAP (#93915) * Adding codeowner and integration_type * Update CODEOWNERS * Update sorting * Update integrations.json --- CODEOWNERS | 1 + homeassistant/components/qnap/manifest.json | 3 ++- homeassistant/generated/integrations.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index cd7ae315b09..ed111835aa0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -963,6 +963,7 @@ build.json @home-assistant/supervisor /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte /tests/components/qld_bushfire/ @exxamalte +/homeassistant/components/qnap/ @disforw /homeassistant/components/qnap_qsw/ @Noltari /tests/components/qnap_qsw/ @Noltari /homeassistant/components/quantum_gateway/ @cisasteelersfan diff --git a/homeassistant/components/qnap/manifest.json b/homeassistant/components/qnap/manifest.json index 95ab9264dfc..fb55d40c66e 100644 --- a/homeassistant/components/qnap/manifest.json +++ b/homeassistant/components/qnap/manifest.json @@ -1,8 +1,9 @@ { "domain": "qnap", "name": "QNAP", - "codeowners": [], + "codeowners": ["@disforw"], "documentation": "https://www.home-assistant.io/integrations/qnap", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["qnapstats"], "requirements": ["qnapstats==0.4.0"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 37f7c2e6071..4306aa981fa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4386,7 +4386,7 @@ "name": "QNAP", "integrations": { "qnap": { - "integration_type": "hub", + "integration_type": "device", "config_flow": false, "iot_class": "local_polling", "name": "QNAP" From 1a3d6bbb9a52a51b54220c0999c162549eb78b35 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 15:21:15 +0200 Subject: [PATCH 031/857] Fix typo in cloud (#93917) --- homeassistant/components/cloud/prefs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 46ddafd48e7..8b6f773e5d9 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -47,7 +47,7 @@ GOOGLE_SETTINGS_VERSION = 3 class CloudPreferencesStore(Store): - """Store entity registry data.""" + """Store cloud preferences.""" async def _async_migrate_func( self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] From 1f0e235b993c64dff9b3eac68e88ff891bd75590 Mon Sep 17 00:00:00 2001 From: disforw Date: Thu, 1 Jun 2023 10:13:07 -0400 Subject: [PATCH 032/857] Move QNAP constants (#93918) * Create const.py * Update sensor.py * Add docstring * Update sensor.py * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/qnap/const.py | 6 ++++++ homeassistant/components/qnap/sensor.py | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/qnap/const.py diff --git a/homeassistant/components/qnap/const.py b/homeassistant/components/qnap/const.py new file mode 100644 index 00000000000..b5a0aef3dbc --- /dev/null +++ b/homeassistant/components/qnap/const.py @@ -0,0 +1,6 @@ +"""The Qnap constants.""" + +DEFAULT_PORT = 8080 +DEFAULT_TIMEOUT = 5 + +DOMAIN = "qnap" diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 66fc5631718..ca81b2763fa 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -35,6 +35,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle +from .const import DEFAULT_PORT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) ATTR_DRIVE = "Drive" @@ -56,9 +58,6 @@ ATTR_VOLUME_SIZE = "Volume Size" CONF_DRIVES = "drives" CONF_NICS = "nics" CONF_VOLUMES = "volumes" -DEFAULT_NAME = "QNAP" -DEFAULT_PORT = 8080 -DEFAULT_TIMEOUT = 5 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) From 9aed5a47ae223cc3afb4a35b85e3064c11ffe2bd Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 1 Jun 2023 16:18:49 +0200 Subject: [PATCH 033/857] Raise exception instead of hide in logs on zha write (#93571) Raise exception instead of hide in logs Write request that failed parsing of data would fail, yet display as successful in the gui. --- homeassistant/components/zha/core/device.py | 28 +++++++++++-------- homeassistant/components/zha/websocket_api.py | 3 ++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 139acb23923..311e876bbc0 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -740,9 +740,15 @@ class ZHADevice(LogMixin): manufacturer=None, ): """Write a value to a zigbee attribute for a cluster in this entity.""" - cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type) - if cluster is None: - return None + try: + cluster: Cluster = self.async_get_cluster( + endpoint_id, cluster_id, cluster_type + ) + except KeyError as exc: + raise ValueError( + f"Cluster {cluster_id} not found on endpoint {endpoint_id} while" + f" writing attribute {attribute} with value {value}" + ) from exc try: response = await cluster.write_attributes( @@ -758,15 +764,13 @@ class ZHADevice(LogMixin): ) return response except zigpy.exceptions.ZigbeeException as exc: - self.debug( - "failed to set attribute: %s %s %s %s %s", - f"{ATTR_VALUE}: {value}", - f"{ATTR_ATTRIBUTE}: {attribute}", - f"{ATTR_CLUSTER_ID}: {cluster_id}", - f"{ATTR_ENDPOINT_ID}: {endpoint_id}", - exc, - ) - return None + raise HomeAssistantError( + f"Failed to set attribute: " + f"{ATTR_VALUE}: {value} " + f"{ATTR_ATTRIBUTE}: {attribute} " + f"{ATTR_CLUSTER_ID}: {cluster_id} " + f"{ATTR_ENDPOINT_ID}: {endpoint_id}" + ) from exc async def issue_cluster_command( self, diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 2d4126861b4..019a5c50238 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -1302,6 +1302,9 @@ def async_load_api(hass: HomeAssistant) -> None: cluster_type=cluster_type, manufacturer=manufacturer, ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + _LOGGER.debug( ( "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" From e33ae72f95eb298703c3fcf1b6265ca7a778ca05 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 1 Jun 2023 17:01:51 +0200 Subject: [PATCH 034/857] Bump python-opensky (#93916) --- homeassistant/components/opensky/manifest.json | 2 +- homeassistant/components/opensky/sensor.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 854f2ec840b..460453968b6 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@joostlek"], "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.0.7"] + "requirements": ["python-opensky==0.0.8"] } diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index f3704f8d547..cdedd0c9620 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -78,7 +78,7 @@ def setup_platform( latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) radius = config.get(CONF_RADIUS, 0) - bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius) + bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius * 1000) session = async_get_clientsession(hass) opensky = OpenSky(session=session) add_entities( diff --git a/requirements_all.txt b/requirements_all.txt index ce188858754..893a2a00783 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2124,7 +2124,7 @@ python-nest==4.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.0.7 +python-opensky==0.0.8 # homeassistant.components.otbr # homeassistant.components.thread From f9037d5f6d87a1aa04cad08f0d63e873f9604432 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 17:30:15 +0200 Subject: [PATCH 035/857] Add platform only config schema to nsw_fuel_station and ping (#93913) --- .../components/homeassistant/strings.json | 4 ++ .../components/nsw_fuel_station/__init__.py | 3 ++ homeassistant/components/ping/__init__.py | 3 ++ homeassistant/helpers/config_validation.py | 39 +++++++++++++++---- tests/helpers/test_config_validation.py | 25 ++++++++++++ 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 74bfa16e471..0a41f9c7a99 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -15,6 +15,10 @@ "config_entry_only": { "title": "The {domain} integration does not support YAML configuration", "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but any configuration settings defined in YAML are not actually applied.\n\nTo resolve this:\n\n1. If you've not already done so, [set up the integration]({add_integration}).\n\n2. Remove `{domain}:` from your YAML configuration file.\n\n3. Restart Home Assistant." + }, + "platform_only": { + "title": "The {domain} integration does not support YAML configuration under its own key", + "description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant." } }, "system_health": { diff --git a/homeassistant/components/nsw_fuel_station/__init__.py b/homeassistant/components/nsw_fuel_station/__init__.py index 6d45104e5d3..818656779a3 100644 --- a/homeassistant/components/nsw_fuel_station/__init__.py +++ b/homeassistant/components/nsw_fuel_station/__init__.py @@ -8,6 +8,7 @@ import logging from nsw_fuel import FuelCheckClient, FuelCheckError, Station from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,6 +19,8 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "nsw_fuel_station" SCAN_INTERVAL = datetime.timedelta(hours=1) +CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NSW Fuel Station platform.""" diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index b78702de54d..3ff36f2e283 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -6,6 +6,7 @@ import logging from icmplib import SocketPermissionError, ping as icmp_ping from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType @@ -13,6 +14,8 @@ from .const import DOMAIN, PING_PRIVS, PLATFORMS _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 7b8ece69392..01fa4c19561 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1075,7 +1075,12 @@ def empty_config_schema(domain: str) -> Callable[[dict], dict]: return validator -def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: +def _no_yaml_config_schema( + domain: str, + issue_base: str, + translation_key: str, + translation_placeholders: dict[str, str], +) -> Callable[[dict], dict]: """Return a config schema which logs if attempted to setup from YAML.""" module = inspect.getmodule(inspect.stack(context=0)[2].frame) @@ -1092,21 +1097,17 @@ def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: # pylint: disable-next=import-outside-toplevel from .issue_registry import IssueSeverity, async_create_issue - add_integration = f"/_my_redirect/config_flow_start?domain={domain}" with contextlib.suppress(LookupError): hass = async_get_hass() async_create_issue( hass, HOMEASSISTANT_DOMAIN, - f"config_entry_only_{domain}", + f"{issue_base}_{domain}", is_fixable=False, issue_domain=domain, severity=IssueSeverity.ERROR, - translation_key="config_entry_only", - translation_placeholders={ - "domain": domain, - "add_integration": add_integration, - }, + translation_key=translation_key, + translation_placeholders={"domain": domain} | translation_placeholders, ) def validator(config: dict) -> dict: @@ -1124,6 +1125,28 @@ def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: return validator +def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: + """Return a config schema which logs if attempted to setup from YAML.""" + + return _no_yaml_config_schema( + domain, + "config_entry_only", + "config_entry_only", + {"add_integration": f"/_my_redirect/config_flow_start?domain={domain}"}, + ) + + +def platform_only_config_schema(domain: str) -> Callable[[dict], dict]: + """Return a config schema which logs if attempted to setup from YAML.""" + + return _no_yaml_config_schema( + domain, + "platform_only", + "platform_only", + {}, + ) + + PLATFORM_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): string, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index b9b5f989cba..e29447a8688 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1552,3 +1552,28 @@ def test_config_entry_only_schema_no_hass( assert expected_message in caplog.text issue_registry = ir.async_get(hass) assert not issue_registry.issues + + +def test_platform_only_schema( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test config_entry_only_config_schema.""" + expected_issue = "platform_only_test_domain" + expected_message = ( + "The test_domain integration does not support YAML setup, please remove " + "it from your configuration" + ) + issue_registry = ir.async_get(hass) + + cv.platform_only_config_schema("test_domain")({}) + assert expected_message not in caplog.text + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + cv.platform_only_config_schema("test_domain")({"test_domain": {}}) + assert expected_message in caplog.text + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + issue_registry.async_delete(HOMEASSISTANT_DOMAIN, expected_issue) + + cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + assert expected_message in caplog.text + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) From a260c11d4ef298ca04bc62c97acce994de2d9969 Mon Sep 17 00:00:00 2001 From: Sebastian Heiden Date: Thu, 1 Jun 2023 18:04:00 +0200 Subject: [PATCH 036/857] Fix LaMetric Config Flow for SKY (#93483) Co-authored-by: Franck Nijhof --- .../components/lametric/config_flow.py | 6 +- tests/components/lametric/conftest.py | 12 +++- .../lametric/fixtures/device_sa5.json | 71 +++++++++++++++++++ tests/components/lametric/test_config_flow.py | 52 ++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/components/lametric/fixtures/device_sa5.json diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 8e9da5851cf..1dad190d706 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -248,6 +248,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} ) + notify_sound: Sound | None = None + if device.model != "sa5": + notify_sound = Sound(sound=NotificationSound.WIN) + await lametric.notify( notification=Notification( priority=NotificationPriority.CRITICAL, @@ -255,7 +259,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): model=Model( cycles=2, frames=[Simple(text="Connected to Home Assistant!", icon=7956)], - sound=Sound(sound=NotificationSound.WIN), + sound=notify_sound, ), ) ) diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index 177204b6f24..b3a9f2d8665 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -67,8 +67,14 @@ def mock_lametric_cloud() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_lametric() -> Generator[MagicMock, None, None]: - """Return a mocked LaMetric client.""" +def device_fixture() -> str: + """Return the device fixture for a specific device.""" + return "device" + + +@pytest.fixture +def mock_lametric(request, device_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked LaMetric TIME client.""" with patch( "homeassistant.components.lametric.coordinator.LaMetricDevice", autospec=True ) as lametric_mock, patch( @@ -79,7 +85,7 @@ def mock_lametric() -> Generator[MagicMock, None, None]: lametric.api_key = "mock-api-key" lametric.host = "127.0.0.1" lametric.device.return_value = Device.parse_raw( - load_fixture("device.json", DOMAIN) + load_fixture(f"{device_fixture}.json", DOMAIN) ) yield lametric diff --git a/tests/components/lametric/fixtures/device_sa5.json b/tests/components/lametric/fixtures/device_sa5.json new file mode 100644 index 00000000000..47120f672ef --- /dev/null +++ b/tests/components/lametric/fixtures/device_sa5.json @@ -0,0 +1,71 @@ +{ + "audio": { + "volume": 100, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": true, + "address": "AA:BB:CC:DD:EE:FF", + "available": true, + "discoverable": true, + "low_energy": { + "active": true, + "advertising": true, + "connectable": true + }, + "name": "SKY0123", + "pairable": false + }, + "display": { + "brightness": 66, + "brightness_limit": { + "max": 100, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "screen_off": { + "enabled": false + }, + "time_based": { + "enabled": false + } + }, + "widget": "" + }, + "type": "mixed", + "width": 64 + }, + "id": "12345", + "mode": "manual", + "model": "sa5", + "name": "spyfly's LaMetric SKY", + "os_version": "3.0.13", + "serial_number": "SA52100000123TBNC", + "wifi": { + "active": true, + "mac": "AA:BB:CC:DD:EE:FF", + "available": true, + "encryption": "WPA", + "ssid": "IoT", + "ip": "127.0.0.1", + "mode": "dhcp", + "netmask": "255.255.255.0", + "strength": 58 + } +} diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 8fd0ef061ac..0fa3a2d9838 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -6,6 +6,9 @@ from demetriek import ( LaMetricConnectionError, LaMetricConnectionTimeoutError, LaMetricError, + Notification, + NotificationSound, + Sound, ) import pytest @@ -238,6 +241,10 @@ async def test_full_manual( assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.model.sound == Sound(sound=NotificationSound.WIN) + assert len(mock_setup_entry.mock_calls) == 1 @@ -894,3 +901,48 @@ async def test_reauth_manual( assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize("device_fixture", ["device_sa5"]) +async def test_reauth_manual_sky( + hass: HomeAssistant, + mock_lametric: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with manual entry for LaMetric Sky.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "manual_entry"} + ) + + result2 = await hass.config_entries.flow.async_configure( + flow_id, user_input={CONF_API_KEY: "mock-api-key"} + ) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_API_KEY: "mock-api-key", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.model.sound is None From 8f485be87e9d1b4fbc25b5c2a5781a318c10dbbc Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Thu, 1 Jun 2023 19:09:09 +0200 Subject: [PATCH 037/857] Properly order moon phases in attribute (#93933) --- homeassistant/components/moon/sensor.py | 10 +++++----- tests/components/moon/test_sensor.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index f8e1cd24abe..10251fc679d 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -50,14 +50,14 @@ class MoonSensorEntity(SensorEntity): _attr_name = "Phase" _attr_device_class = SensorDeviceClass.ENUM _attr_options = [ - STATE_FIRST_QUARTER, - STATE_FULL_MOON, - STATE_LAST_QUARTER, STATE_NEW_MOON, - STATE_WANING_CRESCENT, - STATE_WANING_GIBBOUS, STATE_WAXING_CRESCENT, + STATE_FIRST_QUARTER, STATE_WAXING_GIBBOUS, + STATE_FULL_MOON, + STATE_WANING_GIBBOUS, + STATE_LAST_QUARTER, + STATE_WANING_CRESCENT, ] _attr_translation_key = "phase" diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py index 164e5ddace5..922febed3bf 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -60,14 +60,14 @@ async def test_moon_day( assert state.attributes[ATTR_FRIENDLY_NAME] == "Moon Phase" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM assert state.attributes[ATTR_OPTIONS] == [ - STATE_FIRST_QUARTER, - STATE_FULL_MOON, - STATE_LAST_QUARTER, STATE_NEW_MOON, - STATE_WANING_CRESCENT, - STATE_WANING_GIBBOUS, STATE_WAXING_CRESCENT, + STATE_FIRST_QUARTER, STATE_WAXING_GIBBOUS, + STATE_FULL_MOON, + STATE_WANING_GIBBOUS, + STATE_LAST_QUARTER, + STATE_WANING_CRESCENT, ] entity_registry = er.async_get(hass) From 240372b45d7613064fba6f065aa0e0d0f7638b90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:14:03 -0500 Subject: [PATCH 038/857] Fix onvif cameras that use basic auth with no password (#93928) --- homeassistant/components/onvif/__init__.py | 37 +++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index a834a8f2df6..ea6cd542fea 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,10 +1,11 @@ """The ONVIF integration.""" import asyncio +from contextlib import suppress from http import HTTPStatus import logging from httpx import RequestError -from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError +from onvif.exceptions import ONVIFError from onvif.util import is_auth_error, stringify_onvif_error from zeep.exceptions import Fault, TransportError @@ -120,31 +121,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, device.platforms) -async def _get_snapshot_auth(device): +async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: """Determine auth type for snapshots.""" - if not device.capabilities.snapshot or not (device.username and device.password): - return HTTP_DIGEST_AUTHENTICATION + if not device.capabilities.snapshot: + return None - try: - snapshot = await device.device.get_snapshot(device.profiles[0].token) + for basic_auth in (False, True): + method = HTTP_BASIC_AUTHENTICATION if basic_auth else HTTP_DIGEST_AUTHENTICATION + with suppress(ONVIFError): + if await device.device.get_snapshot(device.profiles[0].token, basic_auth): + return method - if snapshot: - return HTTP_DIGEST_AUTHENTICATION - return HTTP_BASIC_AUTHENTICATION - except (ONVIFAuthError, ONVIFTimeoutError): - return HTTP_BASIC_AUTHENTICATION - except ONVIFError: - return HTTP_DIGEST_AUTHENTICATION + return None -async def async_populate_snapshot_auth(hass, device, entry): +async def async_populate_snapshot_auth( + hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry +) -> None: """Check if digest auth for snapshots is possible.""" - auth = await _get_snapshot_auth(device) - new_data = {**entry.data, CONF_SNAPSHOT_AUTH: auth} - hass.config_entries.async_update_entry(entry, data=new_data) + if auth := await _get_snapshot_auth(device): + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_SNAPSHOT_AUTH: auth} + ) -async def async_populate_options(hass, entry): +async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, From 0ca53eccb8c805d3c8970b4306a683a8254ebef5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:15:16 -0500 Subject: [PATCH 039/857] Bump python-onvif-zeep to 3.1.9 (#93930) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index fd00d28b832..e92e80a9a68 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.8", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.1.9", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 893a2a00783..0d77c9ef1a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1331,7 +1331,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==3.1.8 +onvif-zeep-async==3.1.9 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 509fe08b37a..55949541657 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1012,7 +1012,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.8 +onvif-zeep-async==3.1.9 # homeassistant.components.opengarage open-garage==0.2.0 From d4e352d6a790367774d3e3fbf13b3637095834e8 Mon Sep 17 00:00:00 2001 From: Tobias G Date: Thu, 1 Jun 2023 19:16:42 +0200 Subject: [PATCH 040/857] Add humidity sensor to deconz component (#93024) Co-authored-by: Robert Svensson --- homeassistant/components/deconz/manifest.json | 2 +- homeassistant/components/deconz/sensor.py | 13 +++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/test_sensor.py | 37 +++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 8139d77df85..6245558a1c5 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==112"], + "requirements": ["pydeconz==113"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e5d5de41008..4e00ac0a415 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -16,6 +16,7 @@ from pydeconz.models.sensor.daylight import DAYLIGHT_STATUS, Daylight from pydeconz.models.sensor.generic_status import GenericStatus from pydeconz.models.sensor.humidity import Humidity from pydeconz.models.sensor.light_level import LightLevel +from pydeconz.models.sensor.moisture import Moisture from pydeconz.models.sensor.power import Power from pydeconz.models.sensor.pressure import Pressure from pydeconz.models.sensor.switch import Switch @@ -81,6 +82,7 @@ T = TypeVar( GenericStatus, Humidity, LightLevel, + Moisture, Power, Pressure, Temperature, @@ -206,6 +208,17 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, ), + DeconzSensorDescription[Moisture]( + key="moisture", + supported_fn=lambda device: device.moisture is not None, + update_key="moisture", + value_fn=lambda device: device.scaled_moisture, + instance_check=Moisture, + device_class=SensorDeviceClass.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, + ), DeconzSensorDescription[Power]( key="power", supported_fn=lambda device: device.power is not None, diff --git a/requirements_all.txt b/requirements_all.txt index 0d77c9ef1a0..e2bf9451e44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1625,7 +1625,7 @@ pydaikin==2.9.1 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==112 +pydeconz==113 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55949541657..be1e45d3634 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1198,7 +1198,7 @@ pycsspeechtts==1.0.8 pydaikin==2.9.1 # homeassistant.components.deconz -pydeconz==112 +pydeconz==113 # homeassistant.components.dexcom pydexcom==0.2.3 diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 0d2f792b6dc..4d93df17ba3 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -441,6 +441,43 @@ TEST_DATA = [ "next_state": "10.0", }, ), + ( # Moisture Sensor + { + "config": {"battery": 100, "offset": 0, "on": True, "reachable": True}, + "etag": "1ba99c68975111c04367b67cf95ead44", + "lastannounced": None, + "lastseen": "2023-05-19T09:55Z", + "manufacturername": "_TZE200_myd45weu", + "modelid": "TS0601", + "name": "Soil Sensor", + "state": { + "lastupdated": "2023-05-19T09:42:00.472", + "lowbattery": False, + "moisture": 7213, + }, + "swversion": "1.0.8", + "type": "ZHAMoisture", + "uniqueid": "a4:c1:38:fe:86:8f:07:a3-01-0408", + }, + { + "entity_count": 3, + "device_count": 3, + "entity_id": "sensor.soil_sensor", + "unique_id": "a4:c1:38:fe:86:8f:07:a3-01-0408-moisture", + "state": "72.13", + "entity_category": None, + "device_class": SensorDeviceClass.MOISTURE, + "state_class": SensorStateClass.MEASUREMENT, + "attributes": { + "state_class": "measurement", + "unit_of_measurement": "%", + "device_class": "moisture", + "friendly_name": "Soil Sensor", + }, + "websocket_event": {"state": {"moisture": 6923}}, + "next_state": "69.23", + }, + ), ( # Light level sensor { "config": { From 5365d57bef5e1f3b72224d4d279c1907813f0b52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:20:32 -0500 Subject: [PATCH 041/857] Bump pyunifiprotect to 4.9.1 (#93931) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index fcb30cdba5f..a2bb76c92b7 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.9.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.9.1", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index e2bf9451e44..c423d939608 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.0 +pyunifiprotect==4.9.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be1e45d3634..c718dd2dbb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1592,7 +1592,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.0 +pyunifiprotect==4.9.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 145a8bc41d83fb1a43d05b975521c04bb66205ce Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 1 Jun 2023 19:23:42 +0200 Subject: [PATCH 042/857] Update frontend to 20230601.1 (#93927) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index bde1977b1c1..838294f7ba5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230601.0"] + "requirements": ["home-assistant-frontend==20230601.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 57b36d1807f..b633ec97bd0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230601.0 +home-assistant-frontend==20230601.1 home-assistant-intents==2023.5.30 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c423d939608..e6d0e70c54c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -987,7 +987,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.0 +home-assistant-frontend==20230601.1 # homeassistant.components.conversation home-assistant-intents==2023.5.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c718dd2dbb7..dc4ebbae8c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -767,7 +767,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.0 +home-assistant-frontend==20230601.1 # homeassistant.components.conversation home-assistant-intents==2023.5.30 From 1a798f6eee6ebf7f11674cc2f3b42ac0340f4b90 Mon Sep 17 00:00:00 2001 From: andiukas <44068098+andiukas@users.noreply.github.com> Date: Thu, 1 Jun 2023 20:29:15 +0300 Subject: [PATCH 043/857] Adding new supported language code to Google translate (#93926) --- homeassistant/components/google_translate/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py index e6361c1025e..78e96acc91d 100644 --- a/homeassistant/components/google_translate/const.py +++ b/homeassistant/components/google_translate/const.py @@ -35,6 +35,7 @@ SUPPORT_LANGUAGES = [ "ko", "la", "lv", + "lt", "mk", "ml", "mr", From 5a8daf06e8bcf510fc82bc75b5b5e6787a53bf62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:30:08 -0500 Subject: [PATCH 044/857] Fix typing_extensions to match metadata (#93920) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b633ec97bd0..c3117734018 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ PyYAML==6.0 requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.15 -typing-extensions>=4.5.0,<5.0 +typing_extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/pyproject.toml b/pyproject.toml index 3868e85988b..7e8318c6e69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "python-slugify==4.0.1", "PyYAML==6.0", "requests==2.31.0", - "typing-extensions>=4.5.0,<5.0", + "typing_extensions>=4.5.0,<5.0", "ulid-transform==0.7.2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", diff --git a/requirements.txt b/requirements.txt index 6a2630e2ab4..ee8b2d7821a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ pip>=21.0,<23.2 python-slugify==4.0.1 PyYAML==6.0 requests==2.31.0 -typing-extensions>=4.5.0,<5.0 +typing_extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 From 457bc4571d3886dcd72e8634af5bc72515620164 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:31:17 -0500 Subject: [PATCH 045/857] Make RestoreStateData.async_get_instance backwards compatible (#93924) --- homeassistant/helpers/restore_state.py | 44 ++++++--- tests/helpers/test_restore_state.py | 122 ++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 17 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index e34e3c86324..ab3b93cf3c4 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -16,6 +16,7 @@ import homeassistant.util.dt as dt_util from . import start from .entity import Entity from .event import async_track_time_interval +from .frame import report from .json import JSONEncoder from .storage import Store @@ -96,7 +97,9 @@ class StoredState: async def async_load(hass: HomeAssistant) -> None: """Load the restore state task.""" - hass.data[DATA_RESTORE_STATE] = await RestoreStateData.async_get_instance(hass) + restore_state = RestoreStateData(hass) + await restore_state.async_setup() + hass.data[DATA_RESTORE_STATE] = restore_state @callback @@ -108,25 +111,26 @@ def async_get(hass: HomeAssistant) -> RestoreStateData: class RestoreStateData: """Helper class for managing the helper saved data.""" - @staticmethod - async def async_get_instance(hass: HomeAssistant) -> RestoreStateData: - """Get the instance of this data helper.""" - data = RestoreStateData(hass) - await data.async_load() - - async def hass_start(hass: HomeAssistant) -> None: - """Start the restore state task.""" - data.async_setup_dump() - - start.async_at_start(hass, hass_start) - - return data - @classmethod async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: """Dump states now.""" await async_get(hass).async_dump_states() + @classmethod + async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData: + """Return the instance of this class.""" + # Nothing should actually be calling this anymore, but we'll keep it + # around for a while to avoid breaking custom components. + # + # In fact they should not be accessing this at all. + report( + "restore_state.RestoreStateData.async_get_instance is deprecated, " + "and not intended to be called by custom components; Please" + "refactor your code to use RestoreEntity instead;" + " restore_state.async_get(hass) can be used in the meantime", + ) + return async_get(hass) + def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" self.hass: HomeAssistant = hass @@ -136,6 +140,16 @@ class RestoreStateData: self.last_states: dict[str, StoredState] = {} self.entities: dict[str, RestoreEntity] = {} + async def async_setup(self) -> None: + """Set up up the instance of this data helper.""" + await self.async_load() + + async def hass_start(hass: HomeAssistant) -> None: + """Start the restore state task.""" + self.async_setup_dump() + + start.async_at_start(self.hass, hass_start) + async def async_load(self) -> None: """Load the instance of this data helper.""" try: diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index cf6a078d137..b5ce7afade0 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -1,12 +1,19 @@ """The tests for the Restore component.""" +from collections.abc import Coroutine from datetime import datetime, timedelta +import logging from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch + +import pytest from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.reload import async_get_platform_without_config_entry from homeassistant.helpers.restore_state import ( DATA_RESTORE_STATE, STORAGE_KEY, @@ -16,9 +23,20 @@ from homeassistant.helpers.restore_state import ( async_get, async_load, ) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import ( + MockModule, + MockPlatform, + async_fire_time_changed, + mock_entity_platform, + mock_integration, +) + +_LOGGER = logging.getLogger(__name__) +DOMAIN = "test_domain" +PLATFORM = "test_platform" async def test_caching_data(hass: HomeAssistant) -> None: @@ -68,6 +86,20 @@ async def test_caching_data(hass: HomeAssistant) -> None: assert mock_write_data.called +async def test_async_get_instance_backwards_compatibility(hass: HomeAssistant) -> None: + """Test async_get_instance backwards compatibility.""" + await async_load(hass) + data = async_get(hass) + # When called from core it should raise + with pytest.raises(RuntimeError): + await RestoreStateData.async_get_instance(hass) + + # When called from a component it should not raise + # but it should report + with patch("homeassistant.helpers.restore_state.report"): + assert data is await RestoreStateData.async_get_instance(hass) + + async def test_periodic_write(hass: HomeAssistant) -> None: """Test that we write periodiclly but not after stop.""" data = async_get(hass) @@ -401,3 +433,89 @@ async def test_restoring_invalid_entity_id( state = await entity.async_get_last_state() assert state is None + + +async def test_restore_entity_end_to_end( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test restoring an entity end-to-end.""" + component_setup = Mock(return_value=True) + + setup_called = [] + + entity_id = "test_domain.unnamed_device" + data = async_get(hass) + now = dt_util.utcnow() + data.last_states = { + entity_id: StoredState(State(entity_id, "stored"), None, now), + } + + class MockRestoreEntity(RestoreEntity): + """Mock restore entity.""" + + def __init__(self): + """Initialize the mock entity.""" + self._state: str | None = None + + @property + def state(self): + """Return the state.""" + return self._state + + async def async_added_to_hass(self) -> Coroutine[Any, Any, None]: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self._state = (await self.async_get_last_state()).state + + async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up the test platform.""" + async_add_entities([MockRestoreEntity()]) + setup_called.append(True) + + mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) + mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) + + mock_platform = MockPlatform(async_setup_platform=async_setup_platform) + mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + + await component.async_setup({DOMAIN: {"platform": PLATFORM, "sensors": None}}) + await hass.async_block_till_done() + assert component_setup.called + + assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert len(setup_called) == 1 + + platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) + assert platform.platform_name == PLATFORM + assert platform.domain == DOMAIN + assert hass.states.get(entity_id).state == "stored" + + await data.async_dump_states() + await hass.async_block_till_done() + + storage_data = hass_storage[STORAGE_KEY]["data"] + assert len(storage_data) == 1 + assert storage_data[0]["state"]["entity_id"] == entity_id + assert storage_data[0]["state"]["state"] == "stored" + + await platform.async_reset() + + assert hass.states.get(entity_id) is None + + # Make sure the entity still gets saved to restore state + # even though the platform has been reset since it should + # not be expired yet. + await data.async_dump_states() + await hass.async_block_till_done() + + storage_data = hass_storage[STORAGE_KEY]["data"] + assert len(storage_data) == 1 + assert storage_data[0]["state"]["entity_id"] == entity_id + assert storage_data[0]["state"]["state"] == "stored" From cc47736d2010fa25cca1d5f0e5b5f98d0a1c5660 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 1 Jun 2023 13:53:41 -0400 Subject: [PATCH 046/857] Include port info in the ZHA websocket settings response (#93934) --- homeassistant/components/zha/websocket_api.py | 2 ++ tests/components/zha/test_websocket_api.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 019a5c50238..28e115c0ec4 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast import voluptuous as vol import zigpy.backups +from zigpy.config import CONF_DEVICE from zigpy.config.validators import cv_boolean from zigpy.types.named import EUI64 from zigpy.zcl.clusters.security import IasAce @@ -1136,6 +1137,7 @@ async def websocket_get_network_settings( msg[ID], { "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "device": zha_gateway.application_controller.config[CONF_DEVICE], "settings": backup.as_dict(), }, ) diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 720cfaaac9b..5250b62a9b0 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -744,6 +744,7 @@ async def test_get_network_settings( assert msg["success"] assert "radio_type" in msg["result"] assert "network_info" in msg["result"]["settings"] + assert "path" in msg["result"]["device"] async def test_list_network_backups( From a1a055f6183b03c1378de07b6e75632b7c26849f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 20:05:11 +0200 Subject: [PATCH 047/857] Add config entry only config schema to integrations s-z (#93910) --- homeassistant/components/simplepush/__init__.py | 4 +++- homeassistant/components/slack/__init__.py | 4 +++- homeassistant/components/smartthings/__init__.py | 3 +++ homeassistant/components/soundtouch/__init__.py | 2 ++ homeassistant/components/steamist/__init__.py | 4 +++- homeassistant/components/tplink/__init__.py | 7 ++++++- homeassistant/components/unifi/__init__.py | 4 +++- homeassistant/components/unifiprotect/__init__.py | 8 +++++++- homeassistant/components/wiz/__init__.py | 3 +++ homeassistant/components/xiaomi_aqara/__init__.py | 2 ++ homeassistant/components/zwave_js/__init__.py | 8 +++++++- 11 files changed, 42 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/simplepush/__init__.py b/homeassistant/components/simplepush/__init__.py index c5782258cb7..e39c3cc6ce1 100644 --- a/homeassistant/components/simplepush/__init__.py +++ b/homeassistant/components/simplepush/__init__.py @@ -3,13 +3,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the simplepush component.""" diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 36b457b75b7..ee4935c7ead 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, discovery +from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from homeassistant.helpers.typing import ConfigType @@ -30,6 +30,8 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Slack component.""" diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 4127cd5deb5..024f04b0dc9 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -51,6 +52,8 @@ from .smartapp import ( _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the SmartThings platform.""" diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index f3fa221db7f..b021604af4a 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -43,6 +43,8 @@ SERVICE_REMOVE_ZONE_SCHEMA = vol.Schema( PLATFORMS = [Platform.MEDIA_PLAYER] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + class SoundTouchData: """SoundTouch data stored in the Home Assistant data object.""" diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index 0a363f77e82..ee46d644847 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -9,6 +9,7 @@ from aiosteamist import Steamist from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -25,10 +26,11 @@ from .discovery import ( PLATFORMS: list[str] = [Platform.SENSOR, Platform.SWITCH] DISCOVERY_INTERVAL = timedelta(minutes=15) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the flux_led component.""" + """Set up the steamist component.""" domain_data = hass.data.setdefault(DOMAIN, {}) domain_data[DISCOVERY] = await async_discover_devices(hass, STARTUP_SCAN_TIMEOUT) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 48090d75706..2edea30835f 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -19,7 +19,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery_flow, +) from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -27,6 +31,7 @@ from .const import DOMAIN, PLATFORMS from .coordinator import TPLinkDataUpdateCoordinator DISCOVERY_INTERVAL = timedelta(minutes=15) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @callback diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index a7e8aede361..4d11a690f35 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -19,6 +19,8 @@ SAVE_DELAY = 10 STORAGE_KEY = "unifi_data" STORAGE_VERSION = 1 +CONFIG_SCHEMA = cv.config_entry_only_config_schema(UNIFI_DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Integration doesn't support configuration through configuration.yaml.""" diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 96d31872d0b..174f60fd135 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -12,7 +12,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType @@ -40,6 +44,8 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 4a6b1dfb44a..26d4e33a7a6 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -41,6 +42,8 @@ PLATFORMS = [ REQUEST_REFRESH_DELAY = 0.35 +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: """Set up the wiz integration.""" diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 16c3fa54731..ef36bd67778 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -79,6 +79,8 @@ SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema( {vol.Required(ATTR_DEVICE_ID): vol.All(cv.string, vol.Length(min=14, max=14))} ) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Xiaomi component.""" diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a89d20d8384..57b0e2edc6f 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -33,7 +33,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.issue_registry import ( @@ -115,6 +119,8 @@ DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_DRIVER_EVENTS = "driver_events" DATA_START_CLIENT_TASK = "start_client_task" +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" From 23ca26ae56d018bc30a165cf312f96e8412d8b5b Mon Sep 17 00:00:00 2001 From: Brent Perdue Date: Thu, 1 Jun 2023 14:06:53 -0400 Subject: [PATCH 048/857] Add save clips to Blink services (#84149) --- homeassistant/components/blink/__init__.py | 48 +++++++++++++++++-- .../components/blink/alarm_control_panel.py | 11 ++++- .../components/blink/binary_sensor.py | 11 ++++- homeassistant/components/blink/const.py | 1 + homeassistant/components/blink/sensor.py | 7 ++- homeassistant/components/blink/services.yaml | 21 +++++++- 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 668a7f99c02..b94a77fbf18 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -8,7 +8,13 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_FILENAME, CONF_NAME, CONF_PIN, CONF_SCAN_INTERVAL +from homeassistant.const import ( + CONF_FILE_PATH, + CONF_FILENAME, + CONF_NAME, + CONF_PIN, + CONF_SCAN_INTERVAL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -18,6 +24,7 @@ from .const import ( DOMAIN, PLATFORMS, SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) @@ -28,6 +35,9 @@ SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string} ) SERVICE_SEND_PIN_SCHEMA = vol.Schema({vol.Optional(CONF_PIN): cv.string}) +SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( + {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string} +) def _blink_startup_wrapper(hass: HomeAssistant, entry: ConfigEntry) -> Blink: @@ -100,6 +110,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Call save video service handler.""" await async_handle_save_video_service(hass, entry, call) + async def async_save_recent_clips(call): + """Call save recent clips service handler.""" + await async_handle_save_recent_clips_service(hass, entry, call) + def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] @@ -112,6 +126,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.services.async_register( DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA ) + hass.services.async_register( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + async_save_recent_clips, + schema=SERVICE_SAVE_RECENT_CLIPS_SCHEMA, + ) hass.services.async_register( DOMAIN, SERVICE_SEND_PIN, send_pin, schema=SERVICE_SEND_PIN_SCHEMA ) @@ -164,13 +184,33 @@ async def async_handle_save_video_service(hass, entry, call): _LOGGER.error("Can't write %s, no access to path!", video_path) return - def _write_video(camera_name, video_path): + def _write_video(name, file_path): """Call video write.""" all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if camera_name in all_cameras: - all_cameras[camera_name].video_to_file(video_path) + if name in all_cameras: + all_cameras[name].video_to_file(file_path) try: await hass.async_add_executor_job(_write_video, camera_name, video_path) except OSError as err: _LOGGER.error("Can't write image to file: %s", err) + + +async def async_handle_save_recent_clips_service(hass, entry, call): + """Save multiple recent clips to output directory.""" + camera_name = call.data[CONF_NAME] + clips_dir = call.data[CONF_FILE_PATH] + if not hass.config.is_allowed_path(clips_dir): + _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) + return + + def _save_recent_clips(name, output_dir): + """Call save recent clips.""" + all_cameras = hass.data[DOMAIN][entry.entry_id].cameras + if name in all_cameras: + all_cameras[name].save_recent_clips(output_dir=output_dir) + + try: + await hass.async_add_executor_job(_save_recent_clips, camera_name, clips_dir) + except OSError as err: + _LOGGER.error("Can't write recent clips to directory: %s", err) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 22a142ff44c..64463df723a 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -55,8 +55,15 @@ class BlinkSyncModule(AlarmControlPanelEntity): def update(self) -> None: """Update the state of the device.""" - _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) - self.data.refresh() + if self.data.check_if_ok_to_update(): + _LOGGER.debug( + "Initiating a blink.refresh() from BlinkSyncModule('%s') (%s)", + self._name, + self.data, + ) + self.data.refresh() + _LOGGER.info("Updating State of Blink Alarm Control Panel '%s'", self._name) + self._attr_state = ( STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED ) diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 9454daa85ec..5a50d3f8c93 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Blink system camera control.""" from __future__ import annotations +import logging + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -20,6 +22,8 @@ from .const import ( TYPE_MOTION_DETECTED, ) +_LOGGER = logging.getLogger(__name__) + BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=TYPE_BATTERY, @@ -74,8 +78,13 @@ class BlinkBinarySensor(BinarySensorEntity): def update(self) -> None: """Update sensor state.""" - self.data.refresh() state = self._camera.attributes[self.entity_description.key] + _LOGGER.debug( + "'%s' %s = %s", + self._camera.attributes["name"], + self.entity_description.key, + state, + ) if self.entity_description.key == TYPE_BATTERY: state = state != "ok" self._attr_is_on = state diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 8986782031f..d58920562f4 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -23,6 +23,7 @@ TYPE_WIFI_STRENGTH = "wifi_strength" SERVICE_REFRESH = "blink_update" SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" +SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" PLATFORMS = [ diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index c051fef98f4..eae45394534 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -78,9 +78,14 @@ class BlinkSensor(SensorEntity): def update(self) -> None: """Retrieve sensor data from the camera.""" - self.data.refresh() try: self._attr_native_value = self._camera.attributes[self._sensor_key] + _LOGGER.debug( + "'%s' %s = %s", + self._camera.attributes["name"], + self._sensor_key, + self._attr_native_value, + ) except KeyError: self._attr_native_value = None _LOGGER.error( diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 89af4799c85..3d51ba2f7bb 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -25,12 +25,31 @@ save_video: text: filename: name: File name - description: Filename to writable path (directory may need to be included in whitelist_dirs in config) + description: Filename to writable path (directory may need to be included in allowlist_external_dirs in config) required: true example: "/tmp/video.mp4" selector: text: +save_recent_clips: + name: Save recent clips + description: 'Save all recent video clips to local directory with file pattern "%Y%m%d_%H%M%S_{name}.mp4"' + fields: + name: + name: Name + description: Name of camera to grab recent clips from. + required: true + example: "Living Room" + selector: + text: + file_path: + name: Output directory + description: Directory name of writable path (directory may need to be included in allowlist_external_dirs in config) + required: true + example: "/tmp" + selector: + text: + send_pin: name: Send pin description: Send a new PIN to blink for 2FA. From bb5430ff5906184673e3fb1839dadc2fd2ca6923 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 20:08:32 +0200 Subject: [PATCH 049/857] Remove async_setup from locative (#93895) * Remove async_setup from locative * Micro-optimize async_setup_entry --- homeassistant/components/locative/__init__.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 5a796b976ff..cca322f3baa 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -21,7 +21,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -61,12 +60,6 @@ WEBHOOK_SCHEMA = vol.All( ) -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up the Locative component.""" - hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} - return True - - async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook from Locative.""" try: @@ -117,6 +110,8 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure based on config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} webhook.async_register( hass, DOMAIN, "Locative", entry.data[CONF_WEBHOOK_ID], handle_webhook ) From ba66a39668da1c1b4c09ebfe7f0efeae68c0a1b7 Mon Sep 17 00:00:00 2001 From: automaton82 Date: Thu, 1 Jun 2023 16:23:26 -0400 Subject: [PATCH 050/857] Update netdata to 1.1.0, set longer timeout (#93937) --- homeassistant/components/netdata/manifest.json | 2 +- homeassistant/components/netdata/sensor.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 2d7604765c4..99410ce033d 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netdata", "iot_class": "local_polling", "loggers": ["netdata"], - "requirements": ["netdata==1.0.1"] + "requirements": ["netdata==1.1.0"] } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 6606604ac90..1ab7a48e1b3 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -66,7 +66,7 @@ async def async_setup_platform( port = config[CONF_PORT] resources = config[CONF_RESOURCES] - netdata = NetdataData(Netdata(host, port=port)) + netdata = NetdataData(Netdata(host, port=port, timeout=20.0)) await netdata.async_update() if netdata.api.metrics is None: diff --git a/requirements_all.txt b/requirements_all.txt index e6d0e70c54c..02af1c6d5e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1242,7 +1242,7 @@ ndms2-client==0.1.2 nessclient==0.10.0 # homeassistant.components.netdata -netdata==1.0.1 +netdata==1.1.0 # homeassistant.components.discovery netdisco==3.0.0 From b18356bb3f78641e123ade31e1a7cf1e1cddaffa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 18:54:25 -0500 Subject: [PATCH 051/857] Fix august aiohttp session being closed out from under it (#93942) * Fix august aiohttp session being closed out from under it fixes #93941 * Fix august aiohttp session being closed out from under it fixes #93941 * Fix august aiohttp session being closed out from under it fixes #93941 --- homeassistant/components/august/__init__.py | 9 ++++-- .../components/august/config_flow.py | 29 +++++++++++++++++-- homeassistant/components/august/gateway.py | 10 ++----- tests/components/august/test_gateway.py | 2 +- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 8be7d8dd2d1..8738b58dab9 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow from .activity import ActivityStream from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS @@ -44,8 +44,11 @@ YALEXS_BLE_DOMAIN = "yalexs_ble" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" - - august_gateway = AugustGateway(hass) + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + session = aiohttp_client.async_create_clientsession(hass) + august_gateway = AugustGateway(hass, session) try: await august_gateway.async_setup(entry.data) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 58f1c2fc976..670d1608421 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -4,13 +4,16 @@ from dataclasses import dataclass import logging from typing import Any +import aiohttp import voluptuous as vol from yalexs.authenticator import ValidationResult from yalexs.const import BRANDS, DEFAULT_BRAND from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -80,6 +83,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Store an AugustGateway().""" self._august_gateway: AugustGateway | None = None + self._aiohttp_session: aiohttp.ClientSession | None = None self._user_auth_details: dict[str, Any] = {} self._needs_reset = True self._mode = None @@ -87,7 +91,6 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" - self._august_gateway = AugustGateway(self.hass) return await self.async_step_user_validate() async def async_step_user_validate(self, user_input=None): @@ -151,12 +154,30 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) + @callback + def _async_get_gateway(self) -> AugustGateway: + """Set up the gateway.""" + if self._august_gateway is not None: + return self._august_gateway + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass) + self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) + return self._august_gateway + + @callback + def _async_shutdown_gateway(self) -> None: + """Shutdown the gateway.""" + if self._aiohttp_session is not None: + self._aiohttp_session.detach() + self._august_gateway = None + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._user_auth_details = dict(entry_data) self._mode = "reauth" self._needs_reset = True - self._august_gateway = AugustGateway(self.hass) return await self.async_step_reauth_validate() async def async_step_reauth_validate(self, user_input=None): @@ -206,7 +227,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_auth_or_validate(self) -> ValidateResult: """Authenticate or validate.""" user_auth_details = self._user_auth_details - gateway = self._august_gateway + gateway = self._async_get_gateway() assert gateway is not None await self._async_reset_access_token_cache_if_needed( gateway, @@ -239,6 +260,8 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_update_or_create_entry(self, info: dict[str, Any]) -> FlowResult: """Update existing entry or create a new one.""" + self._async_shutdown_gateway() + existing_entry = await self.async_set_unique_id( self._user_auth_details[CONF_USERNAME] ) diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 9dcf96f057a..badff721d10 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -7,7 +7,7 @@ import logging import os from typing import Any -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientError, ClientResponseError, ClientSession from yalexs.api_async import ApiAsync from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync from yalexs.authenticator_common import Authentication @@ -16,7 +16,6 @@ from yalexs.exceptions import AugustApiAIOHTTPError from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -35,12 +34,9 @@ _LOGGER = logging.getLogger(__name__) class AugustGateway: """Handle the connection to August.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: """Init the connection.""" - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - self._aiohttp_session = aiohttp_client.async_create_clientsession(hass) + self._aiohttp_session = aiohttp_session self._token_refresh_lock = asyncio.Lock() self._access_token_cache_file: str | None = None self._hass: HomeAssistant = hass diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py index d0e18c0bed4..2a364304c4b 100644 --- a/tests/components/august/test_gateway.py +++ b/tests/components/august/test_gateway.py @@ -35,7 +35,7 @@ async def _patched_refresh_access_token( "original_token", 1234, AuthenticationState.AUTHENTICATED ) ) - august_gateway = AugustGateway(hass) + august_gateway = AugustGateway(hass, MagicMock()) mocked_config = _mock_get_config() await august_gateway.async_setup(mocked_config[DOMAIN]) await august_gateway.async_authenticate() From 8ef799601b524409dad11db1c004efb00caf8825 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 18:54:44 -0500 Subject: [PATCH 052/857] Bump aiohomekit to 2.6.4 (#93943) changelog: https://github.com/Jc2k/aiohomekit/compare/2.6.3...2.6.4 mostly additional logging to help track down #93891 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9db26d4c8e0..89261df8751 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.3"], + "requirements": ["aiohomekit==2.6.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 02af1c6d5e9..b505b6bab9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.3 +aiohomekit==2.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc4ebbae8c7..77ffc029bbd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.3 +aiohomekit==2.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http From 95e4ef225585891edae6280e025bb7b6b268c74a Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Fri, 2 Jun 2023 03:10:57 +0300 Subject: [PATCH 053/857] Fix states not being translated in voice assistants (#93572) Fix states not being translated --- homeassistant/components/conversation/default_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 5cb4487de65..44b13522412 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -278,13 +278,13 @@ class DefaultAgent(AbstractConversationAgent): all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( - self.hass, language, "state", domains + self.hass, language, "entity_component", domains ) # Use translated state names for state in all_states: device_class = state.attributes.get("device_class", "_") - key = f"component.{state.domain}.state.{device_class}.{state.state}" + key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}" state.state = translations.get(key, state.state) # Get first matched or unmatched state. From f941203949c0091635c2debc53fc45e91362fd6a Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 1 Jun 2023 22:24:53 -0500 Subject: [PATCH 054/857] Update pyipp to 0.13.0 (#93886) --- homeassistant/components/ipp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 59f8c32c210..e93f9832722 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.12.1"], + "requirements": ["pyipp==0.13.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b505b6bab9c..86054ffca98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1733,7 +1733,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.12.1 +pyipp==0.13.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77ffc029bbd..415372dd835 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1273,7 +1273,7 @@ pyinsteon==1.4.2 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.12.1 +pyipp==0.13.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 From 0916a6c0cbf2e6b8a63fd3f7d4450e64e6c33960 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jun 2023 09:21:19 +0200 Subject: [PATCH 055/857] Remove insteon import flow (#93952) --- homeassistant/components/insteon/__init__.py | 26 +-- .../components/insteon/config_flow.py | 8 - homeassistant/components/insteon/schemas.py | 136 -------------- tests/components/insteon/const.py | 25 --- tests/components/insteon/test_config_flow.py | 99 ----------- tests/components/insteon/test_init.py | 167 +----------------- 6 files changed, 6 insertions(+), 455 deletions(-) diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 1667f5fb779..a074ad4600b 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -9,13 +9,12 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from . import api from .const import ( CONF_CAT, - CONF_DEV_PATH, CONF_DIM_STEPS, CONF_HOUSECODE, CONF_OVERRIDE, @@ -25,7 +24,6 @@ from .const import ( DOMAIN, INSTEON_PLATFORMS, ) -from .schemas import convert_yaml_to_config_flow from .utils import ( add_insteon_events, async_register_services, @@ -36,6 +34,8 @@ from .utils import ( _LOGGER = logging.getLogger(__name__) OPTIONS = "options" +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) + async def async_get_device_config(hass, config_entry): """Initiate the connection and services.""" @@ -77,26 +77,6 @@ async def close_insteon_connection(*args): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Insteon platform.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - conf = dict(config[DOMAIN]) - hass.data[DOMAIN][CONF_DEV_PATH] = conf.pop(CONF_DEV_PATH, None) - - if not conf: - return True - - data, options = convert_yaml_to_config_flow(conf) - - if options: - hass.data[DOMAIN][OPTIONS] = options - # Create a config entry with the connection data - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=data - ) - ) return True diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 15ce7c849e6..f153bc1aa34 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -163,14 +163,6 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=data_schema, errors=errors ) - async def async_step_import(self, import_info): - """Import a yaml entry as a config entry.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - if not await _async_connect(**import_info): - return self.async_abort(reason="cannot_connect") - return self.async_create_entry(title="", data=import_info) - async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: """Handle USB discovery.""" if self._async_current_entries(): diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 84b586e7649..e6b22a8cbb9 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -22,26 +22,13 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONF_CAT, - CONF_DEV_PATH, CONF_DIM_STEPS, - CONF_FIRMWARE, CONF_HOUSECODE, - CONF_HUB_PASSWORD, - CONF_HUB_USERNAME, - CONF_HUB_VERSION, - CONF_IP_PORT, CONF_OVERRIDE, - CONF_PLM_HUB_MSG, - CONF_PRODUCT_KEY, CONF_SUBCAT, CONF_UNITCODE, CONF_X10, - CONF_X10_ALL_LIGHTS_OFF, - CONF_X10_ALL_LIGHTS_ON, - CONF_X10_ALL_UNITS_OFF, - DOMAIN, HOUSECODES, - INSTEON_ADDR_REGEX, PORT_HUB_V1, PORT_HUB_V2, SRV_ALL_LINK_GROUP, @@ -53,88 +40,6 @@ from .const import ( X10_PLATFORMS, ) - -def set_default_port(schema: dict) -> dict: - """Set the default port based on the Hub version.""" - # If the ip_port is found do nothing - # If it is not found the set the default - if not schema.get(CONF_IP_PORT): - hub_version = schema.get(CONF_HUB_VERSION) - # Found hub_version but not ip_port - schema[CONF_IP_PORT] = PORT_HUB_V1 if hub_version == 1 else PORT_HUB_V2 - return schema - - -def insteon_address(value: str) -> str: - """Validate an Insteon address.""" - if not INSTEON_ADDR_REGEX.match(value): - raise vol.Invalid("Invalid Insteon Address") - return str(value).replace(".", "").lower() - - -CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_ADDRESS): cv.string, - vol.Optional(CONF_CAT): cv.byte, - vol.Optional(CONF_SUBCAT): cv.byte, - vol.Optional(CONF_FIRMWARE): cv.byte, - vol.Optional(CONF_PRODUCT_KEY): cv.byte, - vol.Optional(CONF_PLATFORM): cv.string, - } - ), -) - - -CONF_X10_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOUSECODE): cv.string, - vol.Required(CONF_UNITCODE): vol.Range(min=1, max=16), - vol.Required(CONF_PLATFORM): cv.string, - vol.Optional(CONF_DIM_STEPS): vol.Range(min=2, max=255), - } - ) -) - - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(CONF_X10_ALL_UNITS_OFF), - cv.deprecated(CONF_X10_ALL_LIGHTS_ON), - cv.deprecated(CONF_X10_ALL_LIGHTS_OFF), - vol.Schema( - { - vol.Exclusive( - CONF_PORT, "plm_or_hub", msg=CONF_PLM_HUB_MSG - ): cv.string, - vol.Exclusive( - CONF_HOST, "plm_or_hub", msg=CONF_PLM_HUB_MSG - ): cv.string, - vol.Optional(CONF_IP_PORT): cv.port, - vol.Optional(CONF_HUB_USERNAME): cv.string, - vol.Optional(CONF_HUB_PASSWORD): cv.string, - vol.Optional(CONF_HUB_VERSION, default=2): vol.In([1, 2]), - vol.Optional(CONF_OVERRIDE): vol.All( - cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA] - ), - vol.Optional(CONF_X10): vol.All( - cv.ensure_list_csv, [CONF_X10_SCHEMA] - ), - vol.Optional(CONF_DEV_PATH): cv.string, - }, - extra=vol.ALLOW_EXTRA, - required=True, - ), - cv.has_at_least_one_key(CONF_PORT, CONF_HOST), - set_default_port, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - ADD_ALL_LINK_SCHEMA = vol.Schema( { vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255), @@ -170,18 +75,6 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) -SCENE_ENTITY_SCHEMA = vol.Schema( - [ - { - vol.Required(CONF_ADDRESS): str, - vol.Required("data1"): int, - vol.Required("data2"): int, - vol.Required("data3"): int, - } - ] -) - - def normalize_byte_entry_to_int(entry: int | bytes | str): """Format a hex entry value.""" if isinstance(entry, int): @@ -338,32 +231,3 @@ def build_remove_x10_schema(data): unitcode = device[CONF_UNITCODE] selection.append(f"Housecode: {housecode}, Unitcode: {unitcode}") return vol.Schema({vol.Required(CONF_DEVICE): vol.In(selection)}) - - -def convert_yaml_to_config_flow(yaml_config): - """Convert the YAML based configuration to a config flow configuration.""" - config = {} - if yaml_config.get(CONF_HOST): - hub_version = yaml_config.get(CONF_HUB_VERSION, 2) - default_port = PORT_HUB_V2 if hub_version == 2 else PORT_HUB_V1 - config[CONF_HOST] = yaml_config.get(CONF_HOST) - config[CONF_PORT] = yaml_config.get(CONF_PORT, default_port) - config[CONF_HUB_VERSION] = hub_version - if hub_version == 2: - config[CONF_USERNAME] = yaml_config[CONF_USERNAME] - config[CONF_PASSWORD] = yaml_config[CONF_PASSWORD] - else: - config[CONF_DEVICE] = yaml_config[CONF_PORT] - - options = {} - for old_override in yaml_config.get(CONF_OVERRIDE, []): - override = {} - override[CONF_ADDRESS] = str(Address(old_override[CONF_ADDRESS])) - override[CONF_CAT] = normalize_byte_entry_to_int(old_override[CONF_CAT]) - override[CONF_SUBCAT] = normalize_byte_entry_to_int(old_override[CONF_SUBCAT]) - options = add_device_override(options, override) - - for x10_device in yaml_config.get(CONF_X10, []): - options = add_x10_device(options, x10_device) - - return config, options diff --git a/tests/components/insteon/const.py b/tests/components/insteon/const.py index eb25f2ed43e..e731c51d6c6 100644 --- a/tests/components/insteon/const.py +++ b/tests/components/insteon/const.py @@ -3,11 +3,8 @@ from homeassistant.components.insteon.const import ( CONF_CAT, CONF_DIM_STEPS, CONF_HOUSECODE, - CONF_HUB_VERSION, - CONF_OVERRIDE, CONF_SUBCAT, CONF_UNITCODE, - CONF_X10, X10_PLATFORMS, ) from homeassistant.const import ( @@ -73,28 +70,6 @@ MOCK_X10_CONFIG_2 = { CONF_DIM_STEPS: MOCK_X10_STEPS, } -MOCK_IMPORT_CONFIG_PLM = {CONF_PORT: MOCK_DEVICE} - -MOCK_IMPORT_MINIMUM_HUB_V2 = { - CONF_HOST: MOCK_HOSTNAME, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, -} -MOCK_IMPORT_MINIMUM_HUB_V1 = {CONF_HOST: MOCK_HOSTNAME, CONF_HUB_VERSION: 1} -MOCK_IMPORT_FULL_CONFIG_PLM = MOCK_IMPORT_CONFIG_PLM.copy() -MOCK_IMPORT_FULL_CONFIG_PLM[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG] -MOCK_IMPORT_FULL_CONFIG_PLM[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2] - -MOCK_IMPORT_FULL_CONFIG_HUB_V2 = MOCK_USER_INPUT_HUB_V2.copy() -MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_HUB_VERSION] = 2 -MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG] -MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2] - -MOCK_IMPORT_FULL_CONFIG_HUB_V1 = MOCK_USER_INPUT_HUB_V1.copy() -MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_HUB_VERSION] = 1 -MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG] -MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2] - PATCH_CONNECTION = "homeassistant.components.insteon.config_flow.async_connect" PATCH_CONNECTION_CLOSE = "homeassistant.components.insteon.config_flow.async_close" PATCH_DEVICES = "homeassistant.components.insteon.config_flow.devices" diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 70bb8fb37e2..e15b7b2a287 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -42,15 +42,9 @@ from homeassistant.core import HomeAssistant from .const import ( MOCK_DEVICE, - MOCK_HOSTNAME, - MOCK_IMPORT_CONFIG_PLM, - MOCK_IMPORT_MINIMUM_HUB_V1, - MOCK_IMPORT_MINIMUM_HUB_V2, - MOCK_PASSWORD, MOCK_USER_INPUT_HUB_V1, MOCK_USER_INPUT_HUB_V2, MOCK_USER_INPUT_PLM, - MOCK_USERNAME, PATCH_ASYNC_SETUP, PATCH_ASYNC_SETUP_ENTRY, PATCH_CONNECTION, @@ -243,30 +237,6 @@ async def test_failed_connection_hub(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def _import_config(hass, config): - """Run the import step.""" - with patch( - PATCH_CONNECTION, - new=mock_successful_connection, - ), patch( - PATCH_ASYNC_SETUP, return_value=True - ), patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): - return await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - - -async def test_import_plm(hass: HomeAssistant) -> None: - """Test importing a minimum PLM config from yaml.""" - - result = await _import_config(hass, MOCK_IMPORT_CONFIG_PLM) - - assert result["type"] == "create_entry" - assert hass.config_entries.async_entries(DOMAIN) - for entry in hass.config_entries.async_entries(DOMAIN): - assert entry.data == MOCK_IMPORT_CONFIG_PLM - - async def _options_init_form(hass, entry_id, step): """Run the init options form.""" with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): @@ -282,75 +252,6 @@ async def _options_init_form(hass, entry_id, step): return result2 -async def test_import_min_hub_v2(hass: HomeAssistant) -> None: - """Test importing a minimum Hub v2 config from yaml.""" - - result = await _import_config( - hass, {**MOCK_IMPORT_MINIMUM_HUB_V2, CONF_PORT: 25105, CONF_HUB_VERSION: 2} - ) - - assert result["type"] == "create_entry" - assert hass.config_entries.async_entries(DOMAIN) - for entry in hass.config_entries.async_entries(DOMAIN): - assert entry.data[CONF_HOST] == MOCK_HOSTNAME - assert entry.data[CONF_PORT] == 25105 - assert entry.data[CONF_USERNAME] == MOCK_USERNAME - assert entry.data[CONF_PASSWORD] == MOCK_PASSWORD - assert entry.data[CONF_HUB_VERSION] == 2 - - -async def test_import_min_hub_v1(hass: HomeAssistant) -> None: - """Test importing a minimum Hub v1 config from yaml.""" - - result = await _import_config( - hass, {**MOCK_IMPORT_MINIMUM_HUB_V1, CONF_PORT: 9761, CONF_HUB_VERSION: 1} - ) - - assert result["type"] == "create_entry" - assert hass.config_entries.async_entries(DOMAIN) - for entry in hass.config_entries.async_entries(DOMAIN): - assert entry.data[CONF_HOST] == MOCK_HOSTNAME - assert entry.data[CONF_PORT] == 9761 - assert entry.data[CONF_HUB_VERSION] == 1 - - -async def test_import_existing(hass: HomeAssistant) -> None: - """Test we fail on an existing config imported.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - config_entry.add_to_hass(hass) - assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED - - result = await _import_config( - hass, {**MOCK_IMPORT_MINIMUM_HUB_V2, CONF_PORT: 25105, CONF_HUB_VERSION: 2} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - -async def test_import_failed_connection(hass: HomeAssistant) -> None: - """Test a failed connection on import.""" - - with patch( - PATCH_CONNECTION, - new=mock_failed_connection, - ), patch( - PATCH_ASYNC_SETUP, return_value=True - ), patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={**MOCK_IMPORT_MINIMUM_HUB_V2, CONF_PORT: 25105, CONF_HUB_VERSION: 2}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - async def _options_form( hass, flow_id, user_input, connection=mock_successful_connection ): diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py index ecab741d5fe..1c4e2abf123 100644 --- a/tests/components/insteon/test_init.py +++ b/tests/components/insteon/test_init.py @@ -2,45 +2,15 @@ import asyncio from unittest.mock import patch -from pyinsteon.address import Address import pytest from homeassistant.components import insteon -from homeassistant.components.insteon.const import ( - CONF_CAT, - CONF_DEV_PATH, - CONF_OVERRIDE, - CONF_SUBCAT, - CONF_X10, - DOMAIN, - PORT_HUB_V1, - PORT_HUB_V2, -) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE, - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.components.insteon.const import CONF_DEV_PATH, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import ( - MOCK_ADDRESS, - MOCK_CAT, - MOCK_IMPORT_CONFIG_PLM, - MOCK_IMPORT_FULL_CONFIG_HUB_V1, - MOCK_IMPORT_FULL_CONFIG_HUB_V2, - MOCK_IMPORT_FULL_CONFIG_PLM, - MOCK_IMPORT_MINIMUM_HUB_V1, - MOCK_IMPORT_MINIMUM_HUB_V2, - MOCK_SUBCAT, - MOCK_USER_INPUT_PLM, - PATCH_CONNECTION, -) +from .const import MOCK_USER_INPUT_PLM, PATCH_CONNECTION from .mock_devices import MockDevices from tests.common import MockConfigEntry @@ -79,137 +49,6 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert mock_close.called -async def test_import_plm(hass: HomeAssistant) -> None: - """Test setting up the entry from YAML to a PLM.""" - config = {} - config[DOMAIN] = MOCK_IMPORT_CONFIG_PLM - - with patch.object( - insteon, "async_connect", new=mock_successful_connection - ), patch.object(insteon, "close_insteon_connection"), patch.object( - insteon, "devices", new=MockDevices() - ), patch( - PATCH_CONNECTION, new=mock_successful_connection - ): - assert await async_setup_component( - hass, - insteon.DOMAIN, - config, - ) - await hass.async_block_till_done() - await asyncio.sleep(0.01) - assert hass.config_entries.async_entries(DOMAIN) - data = hass.config_entries.async_entries(DOMAIN)[0].data - assert data[CONF_DEVICE] == MOCK_IMPORT_CONFIG_PLM[CONF_PORT] - assert CONF_PORT not in data - - -async def test_import_hub1(hass: HomeAssistant) -> None: - """Test setting up the entry from YAML to a hub v1.""" - config = {} - config[DOMAIN] = MOCK_IMPORT_MINIMUM_HUB_V1 - - with patch.object( - insteon, "async_connect", new=mock_successful_connection - ), patch.object(insteon, "close_insteon_connection"), patch.object( - insteon, "devices", new=MockDevices() - ), patch( - PATCH_CONNECTION, new=mock_successful_connection - ): - assert await async_setup_component( - hass, - insteon.DOMAIN, - config, - ) - await hass.async_block_till_done() - await asyncio.sleep(0.01) - assert hass.config_entries.async_entries(DOMAIN) - data = hass.config_entries.async_entries(DOMAIN)[0].data - assert data[CONF_HOST] == MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_HOST] - assert data[CONF_PORT] == PORT_HUB_V1 - assert CONF_USERNAME not in data - assert CONF_PASSWORD not in data - - -async def test_import_hub2(hass: HomeAssistant) -> None: - """Test setting up the entry from YAML to a hub v2.""" - config = {} - config[DOMAIN] = MOCK_IMPORT_MINIMUM_HUB_V2 - - with patch.object( - insteon, "async_connect", new=mock_successful_connection - ), patch.object(insteon, "close_insteon_connection"), patch.object( - insteon, "devices", new=MockDevices() - ), patch( - PATCH_CONNECTION, new=mock_successful_connection - ): - assert await async_setup_component( - hass, - insteon.DOMAIN, - config, - ) - await hass.async_block_till_done() - await asyncio.sleep(0.01) - assert hass.config_entries.async_entries(DOMAIN) - data = hass.config_entries.async_entries(DOMAIN)[0].data - assert data[CONF_HOST] == MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_HOST] - assert data[CONF_PORT] == PORT_HUB_V2 - assert data[CONF_USERNAME] == MOCK_IMPORT_MINIMUM_HUB_V2[CONF_USERNAME] - assert data[CONF_PASSWORD] == MOCK_IMPORT_MINIMUM_HUB_V2[CONF_PASSWORD] - - -async def test_import_options(hass: HomeAssistant) -> None: - """Test setting up the entry from YAML including options.""" - config = {} - config[DOMAIN] = MOCK_IMPORT_FULL_CONFIG_PLM - - with patch.object( - insteon, "async_connect", new=mock_successful_connection - ), patch.object(insteon, "close_insteon_connection"), patch.object( - insteon, "devices", new=MockDevices() - ), patch( - PATCH_CONNECTION, new=mock_successful_connection - ): - assert await async_setup_component( - hass, - insteon.DOMAIN, - config, - ) - await hass.async_block_till_done() - await asyncio.sleep(0.01) # Need to yield to async processes - # pylint: disable=no-member - assert insteon.devices.add_x10_device.call_count == 2 - assert insteon.devices.set_id.call_count == 1 - options = hass.config_entries.async_entries(DOMAIN)[0].options - assert len(options[CONF_OVERRIDE]) == 1 - assert options[CONF_OVERRIDE][0][CONF_ADDRESS] == str(Address(MOCK_ADDRESS)) - assert options[CONF_OVERRIDE][0][CONF_CAT] == MOCK_CAT - assert options[CONF_OVERRIDE][0][CONF_SUBCAT] == MOCK_SUBCAT - - assert len(options[CONF_X10]) == 2 - assert options[CONF_X10][0] == MOCK_IMPORT_FULL_CONFIG_PLM[CONF_X10][0] - assert options[CONF_X10][1] == MOCK_IMPORT_FULL_CONFIG_PLM[CONF_X10][1] - - -async def test_import_failed_connection(hass: HomeAssistant) -> None: - """Test a failed connection in import does not create a config entry.""" - config = {} - config[DOMAIN] = MOCK_IMPORT_CONFIG_PLM - - with patch.object( - insteon, "async_connect", new=mock_failed_connection - ), patch.object(insteon, "async_close"), patch.object( - insteon, "devices", new=MockDevices(connected=False) - ): - assert await async_setup_component( - hass, - insteon.DOMAIN, - config, - ) - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - - async def test_setup_entry_failed_connection( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 3ed8e2d0e1eec0acb76d05f71ee87f1513e94482 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jun 2023 09:28:13 +0200 Subject: [PATCH 056/857] Add empty config schema to mailbox (#93953) --- homeassistant/components/mailbox/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 29f023d0de2..75cea546b71 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -16,7 +16,11 @@ from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import ( + config_per_platform, + config_validation as cv, + discovery, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -32,6 +36,8 @@ CONTENT_TYPE_NONE: Final = "none" SCAN_INTERVAL = timedelta(seconds=30) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for mailboxes.""" From 7db943d13861f3f6f277fd2bf2fc72fae5992725 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jun 2023 12:06:59 +0200 Subject: [PATCH 057/857] Add CONFIG_SCHEMA to imap_email_content (#93951) * Add CONFIG_SCHEMA to imap_email_content * Update homeassistant/components/imap_email_content/__init__.py Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/imap_email_content/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py index 1a148f4591b..f2041b947df 100644 --- a/homeassistant/components/imap_email_content/__init__.py +++ b/homeassistant/components/imap_email_content/__init__.py @@ -2,10 +2,15 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN + PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.deprecated(DOMAIN, raise_if_present=False) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up imap_email_content.""" From ce9a0059d1bd6c44f1fa71ee1f17130865e5be07 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jun 2023 12:07:44 +0200 Subject: [PATCH 058/857] Add empty config schema to stt (#93954) --- homeassistant/components/stt/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 516cd4ddea1..679f9b29e41 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -22,6 +22,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -65,6 +66,8 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @callback def async_default_engine(hass: HomeAssistant) -> str | None: From 3934f91242c0290553bb2f105108bc7ee9ea1a11 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 2 Jun 2023 06:09:53 -0400 Subject: [PATCH 059/857] Make Z-Wave device IBT4ZWAVE discoverable as a cover (#93946) * Make Z-Wave device IBT4ZWAVE discoverable as a cover * Test device class --- homeassistant/components/zwave_js/cover.py | 4 +- .../components/zwave_js/discovery.py | 10 + tests/components/zwave_js/conftest.py | 16 +- .../fixtures/cover_nice_ibt4zwave_state.json | 1410 +++++++++++++++++ tests/components/zwave_js/test_cover.py | 64 + 5 files changed, 1502 insertions(+), 2 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4b3113af202..9a8cb203c05 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -305,8 +305,10 @@ class ZWaveMultilevelSwitchCover(CoverPositionMixin): self._attr_device_class = CoverDeviceClass.WINDOW if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): self._attr_device_class = CoverDeviceClass.SHUTTER - if self.info.platform_hint and self.info.platform_hint.startswith("blind"): + elif self.info.platform_hint and self.info.platform_hint.startswith("blind"): self._attr_device_class = CoverDeviceClass.BLIND + elif self.info.platform_hint and self.info.platform_hint.startswith("gate"): + self._attr_device_class = CoverDeviceClass.GATE class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin): diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 685319de343..a96d323de57 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -378,6 +378,16 @@ DISCOVERY_SCHEMAS = [ ) ], ), + # Fibaro Nice BiDi-ZWave (IBT4ZWAVE) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="gate", + manufacturer_id={0x0441}, + product_id={0x1000}, + product_type={0x2400}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], + ), # Qubino flush shutter ZWaveDiscoverySchema( platform=Platform.COVER, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index bbf422c8b71..68484111802 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -636,6 +636,12 @@ def energy_production_state_fixture(): return json.loads(load_fixture("zwave_js/energy_production_state.json")) +@pytest.fixture(name="nice_ibt4zwave_state", scope="session") +def nice_ibt4zwave_state_fixture(): + """Load a Nice IBT4ZWAVE cover node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_nice_ibt4zwave_state.json")) + + # model fixtures @@ -1214,8 +1220,16 @@ def indicator_test_fixture(client, indicator_test_state): @pytest.fixture(name="energy_production") -def energy_prodution_fixture(client, energy_production_state): +def energy_production_fixture(client, energy_production_state): """Mock a mock node with Energy Production CC.""" node = Node(client, copy.deepcopy(energy_production_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="nice_ibt4zwave") +def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): + """Mock a Nice IBT4ZWAVE cover node.""" + node = Node(client, copy.deepcopy(nice_ibt4zwave_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json b/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json new file mode 100644 index 00000000000..eab42c321fe --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json @@ -0,0 +1,1410 @@ +{ + "nodeId": 72, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 1089, + "productId": 4096, + "productType": 9216, + "firmwareVersion": "7.0", + "zwavePlusVersion": 2, + "name": "Portail", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/data/db/devices/0x0441/ibt4zwave.json", + "isEmbedded": true, + "manufacturer": "NICE Spa", + "manufacturerId": 1089, + "label": "IBT4ZWAVE", + "description": "BusT4-Z-Wave interface", + "devices": [ + { + "productType": 9216, + "productId": 4096 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Install the external antenna before powering the device and adding to the Z-Wave network for the device to automatically detect and enable it (use only antennas and cables compliant with technical specification).\n\n01. Set the Z-Wave gateway into adding mode (see the Z-Wave gateway\u2019s manual)\n02. On the IBT4ZWAVE press and release the S1 button 3 times x3 S1\n03. LEDs on the IBT4ZW AVE will start slow flashing alter nately\n04. If you are adding in Security S2 Authenticated, input the underlined part\nof the DSK (label on the box) DSK: XXXXX-XXXXX-XXXXX-XXXXX XXXXX-XXXXX-XXXXX-XXXXX\n05. When the adding process ends, the LEDs on the IBT4ZWAVE will show adding and antenna status (Table 1 in manual)", + "exclusion": "01. Set the Z-Wave gateway into remove mode (see the Z-Wave gateway\u2019s manual)\n02. On the IBT4ZWAVE press and release the S1 button 3 times x3 S1\n03. LEDs on the IBT4ZW AVE will start slow flashing alternately\n04. Wait for the removing process to end", + "reset": "01. Press and hold the S1 button\n03. Wait 3 seconds\n04. LEDs will show adding and antenna status (Table 1 in manual) for 3 seconds\n05. LEDs will turn off for 3 seconds\n06. LEDs will show selected antenna (Table 2 in manual) for 3 seconds\n07. When both LEDs light up simultaneously, release the button\n08. Press and release the S1 button\n09. Both LEDs will flash once at the end of the procedure", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3837/IBT4ZWAVE-T-v0.7.pdf" + } + }, + "label": "IBT4ZWAVE", + "interviewAttempts": 2, + "endpoints": [ + { + "nodeId": 72, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Open", + "propertyName": "Open", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Open)", + "ccSpecific": { + "switchType": 3 + }, + "valueChangeOptions": ["transitionDuration"] + }, + "value": false, + "nodeId": 72 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Close", + "propertyName": "Close", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Close)", + "ccSpecific": { + "switchType": 3 + }, + "valueChangeOptions": ["transitionDuration"] + }, + "value": true, + "nodeId": 72 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 1st Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Type", + "default": 0, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 1st Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Event", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 1st Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 1st Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 2nd Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Type", + "default": 5, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 2nd Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 2nd Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 2nd Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 3rd Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Type", + "default": 1, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 3rd Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 3rd Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 3rd Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 4th Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Type", + "default": 2, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 4th Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 4th Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 4th Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 5th Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Type", + "default": 4, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 5th Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 5th Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 5th Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Barrier control status", + "propertyName": "Access Control", + "propertyKeyName": "Barrier control status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Barrier control status", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "76": "Barrier associated with non Z-Wave remote control" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Barrier safety beam obstacle status", + "propertyName": "Access Control", + "propertyKeyName": "Barrier safety beam obstacle status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Barrier safety beam obstacle status", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "72": "Barrier safety beam obstacle" + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 1089 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 9216 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 4096 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["7.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version" + }, + "value": "7.13.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version" + }, + "value": "10.13.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number" + }, + "value": 175 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version" + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "7.13.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number" + }, + "value": 175 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version" + }, + "value": "7.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymetic On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": 0 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0441:0x2400:0x1000:7.0", + "statistics": { + "commandsTX": 254, + "commandsRX": 224, + "commandsDroppedRX": 47, + "commandsDroppedTX": 85, + "timeoutResponse": 4, + "rtt": 18.7 + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index ae38c82a75c..502f2413c99 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -1,4 +1,6 @@ """Test the Z-Wave JS cover platform.""" +import logging + from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -24,6 +26,7 @@ from homeassistant.components.cover import ( CoverDeviceClass, CoverEntityFeature, ) +from homeassistant.components.zwave_js.const import LOGGER from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -45,6 +48,7 @@ BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +LOGGER.setLevel(logging.DEBUG) async def test_window_cover( @@ -795,3 +799,63 @@ async def test_iblinds_v3_cover( assert args["value"] is False client.async_send_command.reset_mock() + + +async def test_nice_ibt4zwave_cover( + hass: HomeAssistant, client, nice_ibt4zwave, integration +) -> None: + """Test Nice IBT4ZWAVE cover.""" + entity_id = "cover.portail" + state = hass.states.get(entity_id) + assert state + # This device has no state because there is no position value + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( + CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + ) + assert ATTR_CURRENT_POSITION in state.attributes + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GATE + + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 72 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 72 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() From d9f059fcaa0f30d3e32671318e9f5166853b6fa2 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 2 Jun 2023 06:12:32 -0400 Subject: [PATCH 060/857] Improve logic for zwave_js.lock.is_locked attr (#93947) --- homeassistant/components/zwave_js/lock.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index ff4cb84b47c..5457916a1e1 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -99,14 +99,17 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): @property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" - if self.info.primary_value.value is None: + value = self.info.primary_value + if value.value is None or ( + value.command_class == CommandClass.DOOR_LOCK + and value.value == DoorLockMode.UNKNOWN + ): # guard missing value return None - return int( - LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[ - CommandClass(self.info.primary_value.command_class) - ] - ) == int(self.info.primary_value.value) + return ( + LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[CommandClass(value.command_class)] + == self.info.primary_value.value + ) async def _set_lock_state(self, target_state: str, **kwargs: Any) -> None: """Set the lock state.""" From b5f582eeccbdb029017280673733d001ee490e45 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 2 Jun 2023 13:44:36 +0100 Subject: [PATCH 061/857] Make Riemann sum sensors restore last valid state (#93674) * keep last valid state * keep last valid state * typo * increase coverage * better error handling * debug messages * increase coverage * remove random log * don't expose last_valid_state as an attribute --- .../components/integration/sensor.py | 119 ++++++++++++++++-- tests/components/integration/test_sensor.py | 96 +++++++++++++- 2 files changed, 204 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 7e60f2c509c..b28b426d3af 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -1,16 +1,19 @@ """Numeric integration of data coming from a source sensor over time.""" from __future__ import annotations -from decimal import Decimal, DecimalException +from dataclasses import dataclass +from decimal import Decimal, DecimalException, InvalidOperation import logging -from typing import Final +from typing import Any, Final +from typing_extensions import Self import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + RestoreSensor, SensorDeviceClass, - SensorEntity, + SensorExtraStoredData, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -28,7 +31,6 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -79,6 +81,53 @@ PLATFORM_SCHEMA = vol.All( ) +@dataclass +class IntegrationSensorExtraStoredData(SensorExtraStoredData): + """Object to hold extra stored data.""" + + source_entity: str | None + last_valid_state: Decimal | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the utility sensor data.""" + data = super().as_dict() + data["source_entity"] = self.source_entity + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) + return data + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored sensor state from a dict.""" + extra = SensorExtraStoredData.from_dict(restored) + if extra is None: + return None + + source_entity = restored.get(ATTR_SOURCE_ID) + + try: + last_valid_state = ( + Decimal(str(restored.get("last_valid_state"))) + if restored.get("last_valid_state") + else None + ) + except InvalidOperation: + # last_period is corrupted + _LOGGER.error("Could not use last_valid_state") + return None + + if last_valid_state is None: + return None + + return cls( + extra.native_value, + extra.native_unit_of_measurement, + source_entity, + last_valid_state, + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -129,7 +178,7 @@ async def async_setup_platform( # pylint: disable-next=hass-invalid-inheritance # needs fixing -class IntegrationSensor(RestoreEntity, SensorEntity): +class IntegrationSensor(RestoreSensor): """Representation of an integration sensor.""" _attr_state_class = SensorStateClass.TOTAL @@ -160,7 +209,8 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_time = UNIT_TIME[unit_time] self._unit_time_str = unit_time self._attr_icon = "mdi:chart-histogram" - self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} + self._source_entity: str = source_entity + self._last_valid_state: Decimal | None = None def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -175,10 +225,28 @@ class IntegrationSensor(RestoreEntity, SensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - if (state := await self.async_get_last_state()) is not None: - if state.state == STATE_UNAVAILABLE: - self._attr_available = False - elif state.state != STATE_UNKNOWN: + + if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: + self._state = ( + Decimal(str(last_sensor_data.native_value)) + if last_sensor_data.native_value + else last_sensor_data.last_valid_state + ) + self._attr_native_value = last_sensor_data.native_value + self._unit_of_measurement = last_sensor_data.native_unit_of_measurement + self._last_valid_state = last_sensor_data.last_valid_state + + _LOGGER.debug( + "Restored state %s and last_valid_state %s", + self._state, + self._last_valid_state, + ) + elif (state := await self.async_get_last_state()) is not None: + # legacy to be removed on 2023.10 (we are keeping this to avoid losing data during the transition) + if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + if state.state == STATE_UNAVAILABLE: + self._attr_available = False + else: try: self._state = Decimal(state.state) except (DecimalException, ValueError) as err: @@ -295,6 +363,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._state += integral else: self._state = integral + self._last_valid_state = self._state self.async_write_ha_state() self.async_on_remove( @@ -314,3 +383,33 @@ class IntegrationSensor(RestoreEntity, SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._unit_of_measurement + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_SOURCE_ID: self._source_entity, + } + + return state_attr + + @property + def extra_restore_state_data(self) -> IntegrationSensorExtraStoredData: + """Return sensor specific state data to be restored.""" + return IntegrationSensorExtraStoredData( + self.native_value, + self.native_unit_of_measurement, + self._source_entity, + self._last_valid_state, + ) + + async def async_get_last_sensor_data( + self, + ) -> IntegrationSensorExtraStoredData | None: + """Restore Utility Meter Sensor Extra Stored Data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + + return IntegrationSensorExtraStoredData.from_dict( + restored_last_extra_data.as_dict() + ) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 515ae990deb..355d13c84d6 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import mock_restore_cache +from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) @@ -163,6 +163,100 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == "100.00" assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY + assert state.attributes.get("last_good_state") is None + + +async def test_restore_unavailable_state(hass: HomeAssistant) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.integration", + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + }, + ), + { + "native_value": None, + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "100.00", + }, + ), + ], + ) + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == "100.00" + + +@pytest.mark.parametrize( + "extra_attributes", + [ + { + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "100.00", + }, + { + "native_value": None, + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "None", + }, + ], +) +async def test_restore_unavailable_state_failed( + hass: HomeAssistant, extra_attributes +) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.integration", + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + }, + ), + extra_attributes, + ), + ], + ) + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == STATE_UNAVAILABLE async def test_restore_state_failed(hass: HomeAssistant) -> None: From 2a9bcae365d32ffa1583c865be1430ed78220922 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 2 Jun 2023 22:17:16 +0200 Subject: [PATCH 062/857] Fritz: cleanup unused variables (#93971) Cleanup --- homeassistant/components/fritz/device_tracker.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e32ee152796..d4ba53aa6a2 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -21,9 +21,6 @@ from .const import DATA_FRITZ, DOMAIN _LOGGER = logging.getLogger(__name__) -YAML_DEFAULT_HOST = "169.254.1.1" -YAML_DEFAULT_USERNAME = "admin" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback From 6c5fd40c92217588e051a4a5e62675ce40f6a906 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 2 Jun 2023 16:18:58 -0400 Subject: [PATCH 063/857] Catch Google Sheets api error (#93979) --- .../components/google_sheets/__init__.py | 10 +++++- tests/components/google_sheets/test_init.py | 35 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 803b737283b..590c7bd0c90 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -7,13 +7,18 @@ import aiohttp from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from gspread import Client +from gspread.exceptions import APIError from gspread.utils import ValueInputOption import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -93,6 +98,9 @@ async def async_setup_service(hass: HomeAssistant) -> None: except RefreshError as ex: entry.async_start_reauth(hass) raise ex + except APIError as ex: + raise HomeAssistantError("Failed to write data") from ex + worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title)) row_data = {"created": str(datetime.now())} | call.data[DATA] columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py index 50c82ac5109..8f7ce7603e8 100644 --- a/tests/components/google_sheets/test_init.py +++ b/tests/components/google_sheets/test_init.py @@ -6,7 +6,9 @@ import time from typing import Any from unittest.mock import patch +from gspread.exceptions import APIError import pytest +from requests.models import Response from homeassistant.components.application_credentials import ( ClientCredential, @@ -15,7 +17,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.components.google_sheets import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -212,6 +214,37 @@ async def test_append_sheet( assert len(mock_client.mock_calls) == 8 +async def test_append_sheet_api_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test append to sheet service call API error.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + response = Response() + response.status_code = 503 + + with pytest.raises(HomeAssistantError), patch( + "homeassistant.components.google_sheets.Client.request", + side_effect=APIError(response), + ): + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry.entry_id, + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) + + async def test_append_sheet_invalid_config_entry( hass: HomeAssistant, setup_integration: ComponentSetup, From aa636a28058d3bc115a8313cac0a16f84d7a9bff Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Fri, 2 Jun 2023 22:54:15 +0200 Subject: [PATCH 064/857] Fix broken URL in Z-Wave JS (#93983) Fix broken URL --- homeassistant/components/zwave_js/discovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index a96d323de57..947e5157a8a 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -137,7 +137,7 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): The Z-Wave Value must match these conditions. Use the Z-Wave specifications to find out the values for these parameters: - https://github.com/zwave-js/node-zwave-js/tree/master/specs + https://github.com/zwave-js/specs/tree/master """ # [optional] the value's command class must match ANY of these values @@ -168,7 +168,7 @@ class ZWaveDiscoverySchema: The Z-Wave node and it's (primary) value for an entity must match these conditions. Use the Z-Wave specifications to find out the values for these parameters: - https://github.com/zwave-js/node-zwave-js/tree/master/specs + https://github.com/zwave-js/specs/tree/master """ # specify the hass platform for which this scheme applies (e.g. light, sensor) From 038b0e6d23db2e17c20bf040804319a698fa3657 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Jun 2023 05:35:11 +0200 Subject: [PATCH 065/857] Add scan interval to Command Line (#93752) * Add scan interval * Handle previous not complete * Fix faulty text * Add tests * lingering * Cool down * Fix tests --- .../components/command_line/__init__.py | 25 ++++++- .../components/command_line/binary_sensor.py | 47 +++++++++++- .../components/command_line/const.py | 4 ++ .../components/command_line/cover.py | 71 +++++++++++++++---- .../components/command_line/sensor.py | 61 ++++++++++++---- .../components/command_line/switch.py | 60 +++++++++++++--- .../command_line/test_binary_sensor.py | 63 ++++++++++++++++ tests/components/command_line/test_cover.py | 58 +++++++++++++++ tests/components/command_line/test_sensor.py | 56 +++++++++++++++ tests/components/command_line/test_switch.py | 59 +++++++++++++++ 10 files changed, 463 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 651094db7f1..c9c18fe54a8 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -11,16 +11,24 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, + SCAN_INTERVAL as BINARY_SENSOR_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SCAN_INTERVAL as COVER_DEFAULT_SCAN_INTERVAL, ) -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, + SCAN_INTERVAL as SENSOR_DEFAULT_SCAN_INTERVAL, STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, ) -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SCAN_INTERVAL as SWITCH_DEFAULT_SCAN_INTERVAL, +) from homeassistant.const import ( CONF_COMMAND, CONF_COMMAND_CLOSE, @@ -34,6 +42,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -74,6 +83,9 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL + ): vol.All(cv.time_period, cv.positive_timedelta), } ) COVER_SCHEMA = vol.Schema( @@ -86,6 +98,9 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) NOTIFY_SCHEMA = vol.Schema( @@ -106,6 +121,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) SWITCH_SCHEMA = vol.Schema( @@ -118,6 +136,9 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) COMBINED_SCHEMA = vol.Schema( diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 18b3cf71eb0..9c5a1ce1bbe 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -1,6 +1,7 @@ """Support for custom shell commands to retrieve values.""" from __future__ import annotations +import asyncio from datetime import timedelta import voluptuous as vol @@ -18,17 +19,19 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -84,6 +87,9 @@ async def async_setup_platform( value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) + scan_interval: timedelta = binary_sensor_config.get( + CONF_SCAN_INTERVAL, SCAN_INTERVAL + ) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) @@ -98,6 +104,7 @@ async def async_setup_platform( payload_off, value_template, unique_id, + scan_interval, ) ], True, @@ -107,6 +114,8 @@ async def async_setup_platform( class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -116,6 +125,7 @@ class CommandBinarySensor(BinarySensorEntity): payload_off: str, value_template: Template | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" self.data = data @@ -126,8 +136,39 @@ class CommandBinarySensor(BinarySensorEntity): self._payload_off = payload_off self._value_template = value_template self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Binary Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Binary Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -141,3 +182,5 @@ class CommandBinarySensor(BinarySensorEntity): self._attr_is_on = True elif value == self._payload_off: self._attr_is_on = False + + self.async_write_ha_state() diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py index 4394f388910..ff51cb7e331 100644 --- a/homeassistant/components/command_line/const.py +++ b/homeassistant/components/command_line/const.py @@ -1,7 +1,11 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" +import logging + from homeassistant.const import Platform +LOGGER = logging.getLogger(__package__) + CONF_COMMAND_TIMEOUT = "command_timeout" DEFAULT_TIMEOUT = 15 DOMAIN = "command_line" diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 4503ceb8e56..2d2dc8c5fc2 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,7 +1,8 @@ """Support for command line covers.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -19,21 +20,23 @@ from homeassistant.const import ( CONF_COVERS, CONF_FRIENDLY_NAME, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) COVER_SCHEMA = vol.Schema( { @@ -97,11 +100,12 @@ async def async_setup_platform( value_template, device_config[CONF_COMMAND_TIMEOUT], device_config.get(CONF_UNIQUE_ID), + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not covers: - _LOGGER.error("No covers added") + LOGGER.error("No covers added") return async_add_entities(covers) @@ -110,6 +114,8 @@ async def async_setup_platform( class CommandCover(CoverEntity): """Representation a command line cover.""" + _attr_should_poll = False + def __init__( self, name: str, @@ -120,6 +126,7 @@ class CommandCover(CoverEntity): value_template: Template | None, timeout: int, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the cover.""" self._attr_name = name @@ -131,17 +138,32 @@ class CommandCover(CoverEntity): self._value_template = value_template self._timeout = timeout self._attr_unique_id = unique_id - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) def _move_cover(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) returncode = call_shell_with_timeout(command, self._timeout) success = returncode == 0 if not success: - _LOGGER.error( + LOGGER.error( "Command failed (with return code %s): %s", returncode, command ) @@ -165,12 +187,27 @@ class CommandCover(CoverEntity): def _query_state(self) -> str | None: """Query for the state.""" if self._command_state: - _LOGGER.info("Running state value command: %s", self._command_state) + LOGGER.info("Running state value command: %s", self._command_state) return check_output_or_log(self._command_state, self._timeout) if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Cover %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) @@ -181,15 +218,19 @@ class CommandCover(CoverEntity): self._state = None if payload: self._state = int(payload) + await self.async_update_ha_state(True) - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._move_cover(self._command_open) + await self.hass.async_add_executor_job(self._move_cover, self._command_open) + await self._update_entity_state(None) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - self._move_cover(self._command_close) + await self.hass.async_add_executor_job(self._move_cover, self._command_close) + await self._update_entity_state(None) - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._move_cover(self._command_stop) + await self.hass.async_add_executor_job(self._move_cover, self._command_stop) + await self._update_entity_state(None) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 1689b136f2f..f42ac062081 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -1,10 +1,10 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta import json -import logging import voluptuous as vol @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -28,15 +29,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import check_output_or_log -_LOGGER = logging.getLogger(__name__) - CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" @@ -88,6 +88,7 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) + scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) data = CommandSensorData(hass, command, command_timeout) async_add_entities( @@ -99,15 +100,17 @@ async def async_setup_platform( value_template, json_attributes, unique_id, + scan_interval, ) - ], - True, + ] ) class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -116,6 +119,7 @@ class CommandSensor(SensorEntity): value_template: Template | None, json_attributes: list[str] | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the sensor.""" self._attr_name = name @@ -126,8 +130,39 @@ class CommandSensor(SensorEntity): self._value_template = value_template self._attr_native_unit_of_measurement = unit_of_measurement self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -144,11 +179,11 @@ class CommandSensor(SensorEntity): if k in json_dict } else: - _LOGGER.warning("JSON result was not a dictionary") + LOGGER.warning("JSON result was not a dictionary") except ValueError: - _LOGGER.warning("Unable to parse output as JSON: %s", value) + LOGGER.warning("Unable to parse output as JSON: %s", value) else: - _LOGGER.warning("Empty reply found when expecting JSON data") + LOGGER.warning("Empty reply found when expecting JSON data") if self._value_template is None: self._attr_native_value = None return @@ -163,6 +198,8 @@ class CommandSensor(SensorEntity): else: self._attr_native_value = value + self.async_write_ha_state() + class CommandSensorData: """The class for handling the data retrieval.""" @@ -191,7 +228,7 @@ class CommandSensorData: args_to_render = {"arguments": args} rendered_args = args_compiled.render(args_to_render) except TemplateError as ex: - _LOGGER.exception("Error rendering command template: %s", ex) + LOGGER.exception("Error rendering command template: %s", ex) return else: rendered_args = None @@ -203,5 +240,5 @@ class CommandSensorData: # Template used. Construct the string used in the shell command = f"{prog} {rendered_args}" - _LOGGER.debug("Running command: %s", command) + LOGGER.debug("Running command: %s", command) self.value = check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7936bacd432..1a3dd39a342 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,7 +1,8 @@ """Support for custom shell commands to turn a switch on/off.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -20,6 +21,7 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -27,16 +29,17 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) SWITCH_SCHEMA = vol.Schema( { @@ -112,11 +115,12 @@ async def async_setup_platform( device_config.get(CONF_COMMAND_STATE), value_template, device_config[CONF_COMMAND_TIMEOUT], + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not switches: - _LOGGER.error("No switches added") + LOGGER.error("No switches added") return async_add_entities(switches) @@ -125,6 +129,8 @@ async def async_setup_platform( class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Representation a switch that can be toggled using shell commands.""" + _attr_should_poll = False + def __init__( self, config: ConfigType, @@ -134,6 +140,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): command_state: str | None, value_template: Template | None, timeout: int, + scan_interval: timedelta, ) -> None: """Initialize the switch.""" super().__init__(self.hass, config) @@ -144,11 +151,26 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) async def _switch(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) success = ( await self.hass.async_add_executor_job( @@ -158,18 +180,18 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): ) if not success: - _LOGGER.error("Command failed: %s", command) + LOGGER.error("Command failed: %s", command) return success def _query_state_value(self, command: str) -> str | None: """Execute state command for return value.""" - _LOGGER.info("Running state value command: %s", command) + LOGGER.info("Running state value command: %s", command) return check_output_or_log(command, self._timeout) def _query_state_code(self, command: str) -> bool: """Execute state command for return code.""" - _LOGGER.info("Running state code command: %s", command) + LOGGER.info("Running state code command: %s", command) return ( call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 ) @@ -188,7 +210,22 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Switch %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) @@ -201,15 +238,18 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if payload or value: self._attr_is_on = (value or payload).lower() == "true" self._process_manual_data(payload) + await self.async_update_ha_state(True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True self.async_schedule_update_ha_state() + await self._update_entity_state(None) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False self.async_schedule_update_ha_state() + await self._update_entity_state(None) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 6f79b6bdacf..eb6b52a66be 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -1,17 +1,24 @@ """The tests for the Command line Binary sensor platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta from typing import Any +from unittest.mock import patch import pytest from homeassistant import setup from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.command_line.binary_sensor import CommandBinarySensor from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed async def test_setup_platform_yaml(hass: HomeAssistant) -> None: @@ -189,3 +196,59 @@ async def test_return_code( ) await hass.async_block_till_done() assert "return code 33" in caplog.text + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandBinarySensor(CommandBinarySensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", + side_effect=MockCommandBinarySensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 057e632c325..d977c202b04 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -1,6 +1,8 @@ """The tests the cover command line platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import os import tempfile from unittest.mock import patch @@ -9,6 +11,7 @@ import pytest from homeassistant import config as hass_config, setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.cover import CommandCover from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, @@ -320,3 +323,58 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "cover", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandCover(CommandCover): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.cover.CommandCover", + side_effect=MockCommandCover, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "cover": { + "command_state": "echo 1", + "value_template": "{{ value }}", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 7491e7011f5..87360d0e251 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Command line sensor platform.""" from __future__ import annotations +import asyncio from datetime import timedelta from typing import Any from unittest.mock import patch @@ -9,6 +10,7 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.sensor import CommandSensor from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -530,3 +532,57 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "sensor", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSensor(CommandSensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.sensor.CommandSensor", + side_effect=MockCommandSensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 1", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 017c453aa8b..88a87588375 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -1,6 +1,8 @@ """The tests for the Command line switch platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import json import os import subprocess @@ -11,6 +13,7 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.switch import CommandSwitch from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, @@ -637,3 +640,59 @@ async def test_templating(hass: HomeAssistant) -> None: assert entity_state.attributes.get("icon") == "mdi:on" assert entity_state2.state == STATE_ON assert entity_state2.attributes.get("icon") == "mdi:on" + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSwitch(CommandSwitch): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.switch.CommandSwitch", + side_effect=MockCommandSwitch, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) From faacf1658fba939b1f10731917f0caa9eaff2390 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 3 Jun 2023 06:25:39 -0700 Subject: [PATCH 066/857] Fix error in tibber while fetching latest statistics (#93998) --- homeassistant/components/tibber/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index a2f1db7536f..242c2179a05 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -606,7 +606,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): ) last_stats = await get_instance(self.hass).async_add_executor_job( - get_last_statistics, self.hass, 1, statistic_id, True, {} + get_last_statistics, self.hass, 1, statistic_id, True, set() ) if not last_stats: From 65b62d877db60208bdb35f45d8dd5238d8d1cc79 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 3 Jun 2023 09:26:28 -0400 Subject: [PATCH 067/857] Keep track what devices support Assist features (#93990) --- .../components/assist_pipeline/pipeline.py | 1 + .../components/assist_pipeline/select.py | 19 ++++++++--- .../assist_pipeline/websocket_api.py | 1 - tests/components/assist_pipeline/conftest.py | 15 +++++++-- .../components/assist_pipeline/test_select.py | 33 +++++++++++++++++-- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 031053e8a45..d08e1fc3e50 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -949,6 +949,7 @@ class PipelineData: pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]] pipeline_store: PipelineStorageCollection + pipeline_devices: set[str] = field(default_factory=set, init=False) @dataclass diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 9ac1d6b5888..8e9f11252be 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state from .const import DOMAIN -from .pipeline import PipelineStorageCollection +from .pipeline import PipelineData, PipelineStorageCollection OPTION_PREFERRED = "preferred" @@ -60,15 +60,24 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): """When entity is added to Home Assistant.""" await super().async_added_to_hass() - pipeline_store: PipelineStorageCollection = self.hass.data[ - DOMAIN - ].pipeline_store - pipeline_store.async_add_change_set_listener(self._pipelines_updated) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + self.async_on_remove( + pipeline_store.async_add_change_set_listener(self._pipelines_updated) + ) state = await self.async_get_last_state() if state is not None and state.state in self.options: self._attr_current_option = state.state + if self.registry_entry and (device_id := self.registry_entry.device_id): + pipeline_data.pipeline_devices.add(device_id) + self.async_on_remove( + lambda: pipeline_data.pipeline_devices.discard( + device_id # type: ignore[arg-type] + ) + ) + async def async_select_option(self, option: str) -> None: """Select an option.""" self._attr_current_option = option diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 3d8a07dc0b3..bd2ec53db40 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -280,7 +280,6 @@ def websocket_get_run( ) -@callback @websocket_api.websocket_command( { vol.Required("type"): "assist_pipeline/language/list", diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 7b0b98d65a3..5aa760cc606 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -9,7 +9,10 @@ import pytest from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import DOMAIN -from homeassistant.components.assist_pipeline.pipeline import PipelineStorageCollection +from homeassistant.components.assist_pipeline.pipeline import ( + PipelineData, + PipelineStorageCollection, +) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -260,6 +263,12 @@ async def init_components(hass: HomeAssistant, init_supporting_components): @pytest.fixture -def pipeline_storage(hass: HomeAssistant, init_components) -> PipelineStorageCollection: +def pipeline_data(hass: HomeAssistant, init_components) -> PipelineData: + """Return pipeline data.""" + return hass.data[DOMAIN] + + +@pytest.fixture +def pipeline_storage(pipeline_data) -> PipelineStorageCollection: """Return pipeline storage collection.""" - return hass.data[DOMAIN].pipeline_store + return pipeline_data.pipeline_store diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 30874e7b756..2bc580864d7 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -5,10 +5,15 @@ from __future__ import annotations import pytest from homeassistant.components.assist_pipeline import Pipeline -from homeassistant.components.assist_pipeline.pipeline import PipelineStorageCollection +from homeassistant.components.assist_pipeline.pipeline import ( + PipelineData, + PipelineStorageCollection, +) from homeassistant.components.assist_pipeline.select import AssistPipelineSelect from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import MockConfigEntry, MockPlatform, mock_entity_platform @@ -25,7 +30,11 @@ class SelectPlatform(MockPlatform): async_add_entities: AddEntitiesCallback, ) -> None: """Set up fake select platform.""" - async_add_entities([AssistPipelineSelect(hass, "test")]) + entity = AssistPipelineSelect(hass, "test") + entity._attr_device_info = DeviceInfo( + identifiers={("test", "test")}, + ) + async_add_entities([entity]) @pytest.fixture @@ -33,6 +42,7 @@ async def init_select(hass: HomeAssistant, init_components) -> ConfigEntry: """Initialize select entity.""" mock_entity_platform(hass, "select.assist_pipeline", SelectPlatform()) config_entry = MockConfigEntry(domain="assist_pipeline") + config_entry.add_to_hass(hass) assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") return config_entry @@ -77,6 +87,25 @@ async def pipeline_2( ) +async def test_select_entity_registering_device( + hass: HomeAssistant, + init_select: ConfigEntry, + pipeline_data: PipelineData, +) -> None: + """Test entity registering as an assist device.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device({("test", "test")}) + + # Test device is registered + assert pipeline_data.pipeline_devices == {device.id} + + await hass.config_entries.async_remove(init_select.entry_id) + await hass.async_block_till_done() + + # Test device is removed + assert pipeline_data.pipeline_devices == set() + + async def test_select_entity_changing_pipelines( hass: HomeAssistant, init_select: ConfigEntry, From 305fa128fbe0bfe5dda085c485636560fbf6ac0a Mon Sep 17 00:00:00 2001 From: Michael Benz Date: Sun, 4 Jun 2023 04:02:23 +1000 Subject: [PATCH 068/857] Add reload support to intent_script (#93404) * support live reload of intent_script * add services.yaml * update tesls for full code coverage * Update based on feedback * fix intent_script reload when no intent_script config * Update homeassistant/helpers/intent.py * update tests to handle no_existing better --------- Co-authored-by: Paulus Schoutsen --- .../components/intent_script/__init__.py | 53 ++++++++++++++-- .../components/intent_script/services.yaml | 3 + homeassistant/helpers/intent.py | 10 +++ .../intent_script/fixtures/configuration.yaml | 4 ++ .../fixtures/configuration_no_entry.yaml | 0 tests/components/intent_script/test_init.py | 53 +++++++++++++++- tests/helpers/test_intent.py | 62 +++++++++++++++++++ 7 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/intent_script/services.yaml create mode 100644 tests/components/intent_script/fixtures/configuration.yaml create mode 100644 tests/components/intent_script/fixtures/configuration_no_entry.yaml diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 2ec898bfb0e..55c4947fe4a 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -5,9 +5,16 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, intent, script, template +from homeassistant.const import CONF_TYPE, SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import ( + config_validation as cv, + intent, + script, + service, + template, +) +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -55,10 +62,27 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the intent script component.""" - intents = config[DOMAIN] +async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: + """Handle start Intent Script service call.""" + new_config = await async_integration_yaml_config(hass, DOMAIN) + existing_intents = hass.data[DOMAIN] + + for intent_type in existing_intents: + intent.async_remove(hass, intent_type) + + if not new_config or DOMAIN not in new_config: + hass.data[DOMAIN] = {} + return + + new_intents = new_config[DOMAIN] + + async_load_intents(hass, new_intents) + + +def async_load_intents(hass: HomeAssistant, intents: dict): + """Load YAML intents into the intent system.""" template.attach(hass, intents) + hass.data[DOMAIN] = intents for intent_type, conf in intents.items(): if CONF_ACTION in conf: @@ -67,6 +91,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the intent script component.""" + intents = config[DOMAIN] + + async_load_intents(hass, intents) + + async def _handle_reload(servie_call: ServiceCall) -> None: + return await async_reload(hass, servie_call) + + service.async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + _handle_reload, + ) + return True diff --git a/homeassistant/components/intent_script/services.yaml b/homeassistant/components/intent_script/services.yaml new file mode 100644 index 00000000000..bb981dbc69c --- /dev/null +++ b/homeassistant/components/intent_script/services.yaml @@ -0,0 +1,3 @@ +reload: + name: Reload + description: Reload the intent_script configuration. diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 8b07c2adc9a..f2b29c0040b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -57,6 +57,16 @@ def async_register(hass: HomeAssistant, handler: IntentHandler) -> None: intents[handler.intent_type] = handler +@callback +@bind_hass +def async_remove(hass: HomeAssistant, intent_type: str) -> None: + """Remove an intent from Home Assistant.""" + if (intents := hass.data.get(DATA_KEY)) is None: + return + + intents.pop(intent_type, None) + + @bind_hass async def async_handle( hass: HomeAssistant, diff --git a/tests/components/intent_script/fixtures/configuration.yaml b/tests/components/intent_script/fixtures/configuration.yaml new file mode 100644 index 00000000000..93b4ebe5e26 --- /dev/null +++ b/tests/components/intent_script/fixtures/configuration.yaml @@ -0,0 +1,4 @@ +intent_script: + NewIntent2: + speech: + text: Hello World diff --git a/tests/components/intent_script/fixtures/configuration_no_entry.yaml b/tests/components/intent_script/fixtures/configuration_no_entry.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 044bbcaa7e7..a68b2a9be24 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -1,9 +1,14 @@ """Test intent_script component.""" +from unittest.mock import patch + +from homeassistant import config as hass_config from homeassistant.bootstrap import async_setup_component +from homeassistant.components.intent_script import DOMAIN +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from tests.common import async_mock_service +from tests.common import async_mock_service, get_fixture_path async def test_intent_script(hass: HomeAssistant) -> None: @@ -134,3 +139,49 @@ async def test_intent_script_falsy_reprompt(hass: HomeAssistant) -> None: assert response.card["simple"]["title"] == "Hello Paulus" assert response.card["simple"]["content"] == "Content for Paulus" + + +async def test_reload(hass: HomeAssistant) -> None: + """Verify we can reload intent config.""" + + config = {"intent_script": {"NewIntent1": {"speech": {"text": "HelloWorld123"}}}} + + await async_setup_component(hass, "intent_script", config) + await hass.async_block_till_done() + + intents = hass.data.get(intent.DATA_KEY) + + assert len(intents) == 1 + assert intents.get("NewIntent1") + + yaml_path = get_fixture_path("configuration.yaml", "intent_script") + + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(intents) == 1 + + assert intents.get("NewIntent1") is None + assert intents.get("NewIntent2") + + yaml_path = get_fixture_path("configuration_no_entry.yaml", "intent_script") + + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # absence of intent_script from the configuration.yaml should delete all intents. + assert len(intents) == 0 + assert intents.get("NewIntent1") is None + assert intents.get("NewIntent2") is None diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index edc6a281172..f3256e90b62 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,4 +1,6 @@ """Tests for the intent helpers.""" +from unittest.mock import MagicMock, patch + import pytest import voluptuous as vol @@ -184,3 +186,63 @@ async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + + +def test_async_register(hass: HomeAssistant) -> None: + """Test registering an intent and verifying it is stored correctly.""" + handler = MagicMock() + handler.intent_type = "test_intent" + + intent.async_register(hass, handler) + + assert hass.data[intent.DATA_KEY]["test_intent"] == handler + + +def test_async_register_overwrite(hass: HomeAssistant) -> None: + """Test registering multiple intents with the same type, ensuring the last one overwrites the previous one and a warning is emitted.""" + handler1 = MagicMock() + handler1.intent_type = "test_intent" + + handler2 = MagicMock() + handler2.intent_type = "test_intent" + + with patch.object(intent._LOGGER, "warning") as mock_warning: + intent.async_register(hass, handler1) + intent.async_register(hass, handler2) + + mock_warning.assert_called_once_with( + "Intent %s is being overwritten by %s", "test_intent", handler2 + ) + + assert hass.data[intent.DATA_KEY]["test_intent"] == handler2 + + +def test_async_remove(hass: HomeAssistant) -> None: + """Test removing an intent and verifying it is no longer present in the Home Assistant data.""" + handler = MagicMock() + handler.intent_type = "test_intent" + + intent.async_register(hass, handler) + intent.async_remove(hass, "test_intent") + + assert "test_intent" not in hass.data[intent.DATA_KEY] + + +def test_async_remove_no_existing_entry(hass: HomeAssistant) -> None: + """Test the removal of a non-existing intent from Home Assistant's data.""" + handler = MagicMock() + handler.intent_type = "test_intent" + intent.async_register(hass, handler) + + intent.async_remove(hass, "test_intent2") + + assert "test_intent2" not in hass.data[intent.DATA_KEY] + + +def test_async_remove_no_existing(hass: HomeAssistant) -> None: + """Test the removal of an intent where no config exists.""" + + intent.async_remove(hass, "test_intent2") + # simply shouldn't cause an exception + + assert intent.DATA_KEY not in hass.data From 391c63640a2c76f1e9b555c94e79834de613d286 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Jun 2023 20:11:42 +0200 Subject: [PATCH 069/857] Refactor Command Line binary sensor to use ManualTriggerEntity (#94000) Refactor binary sensor ManualTriggerEntity --- .../components/command_line/binary_sensor.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 9c5a1ce1bbe..d18321d6f02 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -29,6 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER @@ -94,16 +95,20 @@ async def async_setup_platform( value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) + trigger_entity_config = { + CONF_UNIQUE_ID: unique_id, + CONF_NAME: Template(name, hass), + CONF_DEVICE_CLASS: device_class, + } + async_add_entities( [ CommandBinarySensor( data, - name, - device_class, + trigger_entity_config, payload_on, payload_off, value_template, - unique_id, scan_interval, ) ], @@ -111,7 +116,7 @@ async def async_setup_platform( ) -class CommandBinarySensor(BinarySensorEntity): +class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): """Representation of a command line binary sensor.""" _attr_should_poll = False @@ -119,23 +124,19 @@ class CommandBinarySensor(BinarySensorEntity): def __init__( self, data: CommandSensorData, - name: str, - device_class: BinarySensorDeviceClass | None, + config: ConfigType, payload_on: str, payload_off: str, value_template: Template | None, - unique_id: str | None, scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" + super().__init__(self.hass, config) self.data = data - self._attr_name = name - self._attr_device_class = device_class self._attr_is_on = None self._payload_on = payload_on self._payload_off = payload_off self._value_template = value_template - self._attr_unique_id = unique_id self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None @@ -183,4 +184,5 @@ class CommandBinarySensor(BinarySensorEntity): elif value == self._payload_off: self._attr_is_on = False + self._process_manual_data(value) self.async_write_ha_state() From 76d8c047ec33ee7e3151d0ce88e1b39a35cd57f1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Jun 2023 20:19:59 +0200 Subject: [PATCH 070/857] Refactor Command Line cover to use ManualTriggerEntity (#93997) Refactor command_line cover --- homeassistant/components/command_line/cover.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 2d2dc8c5fc2..29236bbed08 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -30,6 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -90,16 +91,20 @@ async def async_setup_platform( ): # Backward compatibility. Can be removed after deprecation device_config[CONF_NAME] = name + trigger_entity_config = { + CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), + CONF_NAME: Template(device_config.get(CONF_NAME, device_name), hass), + } + covers.append( CommandCover( - device_config.get(CONF_NAME, device_name), + trigger_entity_config, device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], device_config[CONF_COMMAND_STOP], device_config.get(CONF_COMMAND_STATE), value_template, device_config[CONF_COMMAND_TIMEOUT], - device_config.get(CONF_UNIQUE_ID), device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) @@ -111,25 +116,24 @@ async def async_setup_platform( async_add_entities(covers) -class CommandCover(CoverEntity): +class CommandCover(ManualTriggerEntity, CoverEntity): """Representation a command line cover.""" _attr_should_poll = False def __init__( self, - name: str, + config: ConfigType, command_open: str, command_close: str, command_stop: str, command_state: str | None, value_template: Template | None, timeout: int, - unique_id: str | None, scan_interval: timedelta, ) -> None: """Initialize the cover.""" - self._attr_name = name + super().__init__(self.hass, config) self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -137,7 +141,6 @@ class CommandCover(CoverEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_unique_id = unique_id self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None @@ -218,6 +221,7 @@ class CommandCover(CoverEntity): self._state = None if payload: self._state = int(payload) + self._process_manual_data(payload) await self.async_update_ha_state(True) async def async_open_cover(self, **kwargs: Any) -> None: From efb92ca9eeb9d31bebc8493bfe8431c1aee0bd1f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 3 Jun 2023 20:35:57 +0200 Subject: [PATCH 071/857] Show the sensor state using the coordinatordata instead of initial data (#94008) * Show the sensor state using the coordinatordata instead of initial data * Add test * Remove part --- homeassistant/components/youtube/entity.py | 17 +- homeassistant/components/youtube/sensor.py | 14 +- tests/components/youtube/__init__.py | 35 ++- .../youtube/fixtures/get_channel_2.json | 6 + .../fixtures/get_playlist_items_2.json | 215 ++++++++++++++++++ tests/components/youtube/test_sensor.py | 34 ++- 6 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 tests/components/youtube/fixtures/get_playlist_items_2.json diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index cdc2f98faac..2f9238dec26 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -1,9 +1,6 @@ """Entity representing a YouTube account.""" from __future__ import annotations -from typing import Any - -from homeassistant.const import ATTR_ID from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -21,20 +18,18 @@ class YouTubeChannelEntity(CoordinatorEntity): self, coordinator: YouTubeDataUpdateCoordinator, description: EntityDescription, - channel: dict[str, Any], + channel_id: str, ) -> None: - """Initialize a Google Mail entity.""" + """Initialize a YouTube entity.""" super().__init__(coordinator) self.entity_description = description self._attr_unique_id = ( - f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}_{description.key}" + f"{coordinator.config_entry.entry_id}_{channel_id}_{description.key}" ) + self._channel_id = channel_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}") - }, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{channel_id}")}, manufacturer=MANUFACTURER, - name=channel[ATTR_TITLE], + name=coordinator.data[channel_id][ATTR_TITLE], ) - self._channel = channel diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 7f92ec0786a..c605b960475 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -70,8 +70,8 @@ async def async_setup_entry( COORDINATOR ] async_add_entities( - YouTubeSensor(coordinator, sensor_type, channel) - for channel in coordinator.data.values() + YouTubeSensor(coordinator, sensor_type, channel_id) + for channel_id in coordinator.data for sensor_type in SENSOR_TYPES ) @@ -84,16 +84,20 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.entity_description.value_fn(self._channel) + return self.entity_description.value_fn(self.coordinator.data[self._channel_id]) @property def entity_picture(self) -> str: """Return the value reported by the sensor.""" - return self.entity_description.entity_picture_fn(self._channel) + return self.entity_description.entity_picture_fn( + self.coordinator.data[self._channel_id] + ) @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the extra state attributes.""" if self.entity_description.attributes_fn: - return self.entity_description.attributes_fn(self._channel) + return self.entity_description.attributes_fn( + self.coordinator.data[self._channel_id] + ) return None diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py index 289a5e8793f..391ff4b3a22 100644 --- a/tests/components/youtube/__init__.py +++ b/tests/components/youtube/__init__.py @@ -20,6 +20,10 @@ class MockRequest: class MockChannels: """Mock object for channels.""" + def __init__(self, fixture: str): + """Initialize mock channels.""" + self._fixture = fixture + def list( self, part: str, @@ -28,12 +32,16 @@ class MockChannels: maxResults: int | None = None, ) -> MockRequest: """Return a fixture.""" - return MockRequest(fixture="youtube/get_channel.json") + return MockRequest(fixture=self._fixture) class MockPlaylistItems: """Mock object for playlist items.""" + def __init__(self, fixture: str): + """Initialize mock playlist items.""" + self._fixture = fixture + def list( self, part: str, @@ -41,28 +49,43 @@ class MockPlaylistItems: maxResults: int | None = None, ) -> MockRequest: """Return a fixture.""" - return MockRequest(fixture="youtube/get_playlist_items.json") + return MockRequest(fixture=self._fixture) class MockSubscriptions: """Mock object for subscriptions.""" + def __init__(self, fixture: str): + """Initialize mock subscriptions.""" + self._fixture = fixture + def list(self, part: str, mine: bool, maxResults: int | None = None) -> MockRequest: """Return a fixture.""" - return MockRequest(fixture="youtube/get_subscriptions.json") + return MockRequest(fixture=self._fixture) class MockService: """Service which returns mock objects.""" + def __init__( + self, + channel_fixture: str = "youtube/get_channel.json", + playlist_items_fixture: str = "youtube/get_playlist_items.json", + subscriptions_fixture: str = "youtube/get_subscriptions.json", + ): + """Initialize mock service.""" + self._channel_fixture = channel_fixture + self._playlist_items_fixture = playlist_items_fixture + self._subscriptions_fixture = subscriptions_fixture + def channels(self) -> MockChannels: """Return a mock object.""" - return MockChannels() + return MockChannels(self._channel_fixture) def playlistItems(self) -> MockPlaylistItems: """Return a mock object.""" - return MockPlaylistItems() + return MockPlaylistItems(self._playlist_items_fixture) def subscriptions(self) -> MockSubscriptions: """Return a mock object.""" - return MockSubscriptions() + return MockSubscriptions(self._subscriptions_fixture) diff --git a/tests/components/youtube/fixtures/get_channel_2.json b/tests/components/youtube/fixtures/get_channel_2.json index 81da13f7d19..24e71ad91ab 100644 --- a/tests/components/youtube/fixtures/get_channel_2.json +++ b/tests/components/youtube/fixtures/get_channel_2.json @@ -36,6 +36,12 @@ "totalItemCount": 6178, "newItemCount": 0, "activityType": "all" + }, + "statistics": { + "viewCount": "214141263", + "subscriberCount": "2290000", + "hiddenSubscriberCount": false, + "videoCount": "5798" } } ] diff --git a/tests/components/youtube/fixtures/get_playlist_items_2.json b/tests/components/youtube/fixtures/get_playlist_items_2.json new file mode 100644 index 00000000000..2311d7219c2 --- /dev/null +++ b/tests/components/youtube/fixtures/get_playlist_items_2.json @@ -0,0 +1,215 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "pU0v49jXONlQfIJEX7ldINttRYM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmhsZUxsY0h3UUxN", + "snippet": { + "publishedAt": "2023-05-10T22:30:48Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Google I/O 2023 Developer Keynote in 5 minutes", + "description": "Discover what’s new from Google, including top takeaways and highlights announced at Google I/O 2023. From deep investments in the largest mobile platform, to breakthroughs in AI, learn about the latest capabilities in mobile, web, Cloud, AI, and more. \n\nCatch the full Developer Keynote →https://goo.gle/dev-keynote-23 \nWatch all the Keynotes from Google I/O 2023→ https://goo.gle/IO23_keynotes\nWatch all the Google I/O 2023 Sessions → https://goo.gle/IO23_all \n\n0:00 - Welcome\n0:25 - MakerSuite\n0:49 - Android Studio Bot\n1:38 - Large screens\n2:04 - Wear OS\n2:34 - WebGPU\n2:58 - Baseline\n3:27 - MediaPipe\n3:57 - Duet AI for Google Cloud\n4:59 - Closing\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO #developers", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 1, + "resourceId": { + "kind": "youtube#video", + "videoId": "hleLlcHwQLM" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "hleLlcHwQLM", + "videoPublishedAt": "2023-05-10T22:30:48Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "fht9mKDuIBXcO75k21ZB_gC_4vM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmxNS2p0U0Z1amN3", + "snippet": { + "publishedAt": "2023-05-10T21:25:47Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Pay and Wallet in less than 1 minute", + "description": "A quick recap on the latest updates to Google Pay and Wallet from Google I/O 2023.\n\nTo learn more about what's new in Google Pay and Wallet, check out the keynote → https://goo.gle/IO23_paywallet\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 2, + "resourceId": { + "kind": "youtube#video", + "videoId": "lMKjtSFujcw" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "lMKjtSFujcw", + "videoPublishedAt": "2023-05-10T21:25:47Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nYKXoKd8eePAZ_xFa3dL5ZmvM5c", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmMwbXFCdVhQcnBB", + "snippet": { + "publishedAt": "2023-05-10T20:47:57Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Developers guide to BigQuery export for Google Analytics 4", + "description": "With Google Analytics 4 (GA4), anyone can set up export of granular measurement data to BigQuery.\n\nIn this session, you will learn how to use the BigQuery export for solving business problems, doing complex reporting, implementing advanced use cases with ML models, and creating custom audiences by joining with first-party data. You can use this framework for detailed or large-scale data analysis. We will also share some best practices to get you started.\n\nResources:\nDevelopers guide to BigQuery export for Google Analytics 4 → https://goo.gle/ga-io23\n\nSpeaker: Minhaz Kazi\n\nWatch more:\nWatch all the Technical Sessions from Google I/O 2023 → https://goo.gle/IO23_sessions\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nWatch more Web Sessions → https://goo.gle/IO23_web\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 3, + "resourceId": { + "kind": "youtube#video", + "videoId": "c0mqBuXPrpA" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "c0mqBuXPrpA", + "videoPublishedAt": "2023-05-10T20:47:57Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "--gb8pSHDwp9c-fyjhZ0K2DklLE", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Ll9uOXh3dVRPUmFz", + "snippet": { + "publishedAt": "2023-05-10T20:46:29Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home - American Sign Language", + "description": "To watch this Session without American Sign Language (ASL) interpretation, please click here → https://goo.gle/IO23_homekey\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations.\n\nResources:\nGoogle Home Developer Center → https://goo.gle/3KcD5xr\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations\nGoogle Home APIs Developer Preview → https://goo.gle/3UakRl0\nAutomations Developer Preview → https://goo.gle/3KgEcMy\n\nSpeakers: Taylor Lehman, Indu Ramamurthi\n\nWatch more:\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 4, + "resourceId": { + "kind": "youtube#video", + "videoId": "_n9xwuTORas" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "_n9xwuTORas", + "videoPublishedAt": "2023-05-10T20:46:29Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 5 + } +} diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index 1363a4468a7..3462e291af8 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -9,9 +9,11 @@ from homeassistant.components.youtube import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from ...common import async_fire_time_changed +from . import MockService from .conftest import TOKEN, ComponentSetup +from tests.common import async_fire_time_changed + async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: """Test sensor.""" @@ -37,6 +39,36 @@ async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> ) +async def test_sensor_updating( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test updating sensor.""" + await setup_integration() + + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.attributes["video_id"] == "wysukDrMdqU" + + with patch( + "homeassistant.components.youtube.api.build", + return_value=MockService( + playlist_items_fixture="youtube/get_playlist_items_2.json" + ), + ): + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.name == "Google for Developers Latest upload" + assert state.state == "Google I/O 2023 Developer Keynote in 5 minutes" + assert ( + state.attributes["entity_picture"] + == "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg" + ) + assert state.attributes["video_id"] == "hleLlcHwQLM" + + async def test_sensor_reauth_trigger( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: From 53e456f4530dd7d4b1722b2a5f39fda1f3bb2452 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 4 Jun 2023 01:49:18 -0700 Subject: [PATCH 072/857] Android TV Remote: Abort zeroconf if mac address is missing (#94026) Abort zeroconf if mac address is missing --- .../androidtv_remote/config_flow.py | 3 ++- .../androidtv_remote/test_config_flow.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 24b64c622a9..f7e1078d3fa 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -135,7 +135,8 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host = discovery_info.host self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") self.mac = discovery_info.properties.get("bt") - assert self.mac + if not self.mac: + return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(format_mac(self.mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: self.host, CONF_NAME: self.name} diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index ea1f4abfc1d..ec368081a95 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -712,6 +712,30 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry assert len(mock_setup_entry.mock_calls) == 0 +async def test_zeroconf_flow_abort_if_mac_is_missing( + hass: HomeAssistant, +) -> None: + """Test when mac is missing in the zeroconf discovery we abort.""" + host = "1.2.3.4" + name = "My Android TV" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={}, + ), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + async def test_reauth_flow_success( hass: HomeAssistant, mock_setup_entry: AsyncMock, From 07e26f96393953e7aeda32f75aaf66a47098eb5a Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sun, 4 Jun 2023 19:59:03 +0300 Subject: [PATCH 073/857] Drop codeowner for IMAP (#94033) --- CODEOWNERS | 4 ++-- homeassistant/components/imap/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index ed111835aa0..0b5b6f221f9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -565,8 +565,8 @@ build.json @home-assistant/supervisor /tests/components/image_processing/ @home-assistant/core /homeassistant/components/image_upload/ @home-assistant/core /tests/components/image_upload/ @home-assistant/core -/homeassistant/components/imap/ @engrbm87 @jbouwh -/tests/components/imap/ @engrbm87 @jbouwh +/homeassistant/components/imap/ @jbouwh +/tests/components/imap/ @jbouwh /homeassistant/components/incomfort/ @zxdavb /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 39dfc6c0d48..3c35d00f714 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -1,7 +1,7 @@ { "domain": "imap", "name": "IMAP", - "codeowners": ["@engrbm87", "@jbouwh"], + "codeowners": ["@jbouwh"], "config_flow": true, "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/imap", From 1fe4c4fa59d1da63a5af64ffb52d06fcede6b88a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Jun 2023 20:53:34 +0200 Subject: [PATCH 074/857] Remove update_before_add from binary_sensor in Command Line (#94040) Remove update_before_add --- homeassistant/components/command_line/binary_sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index d18321d6f02..06aa58ca068 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -112,7 +112,6 @@ async def async_setup_platform( scan_interval, ) ], - True, ) From b5b9a06c2c1c83533dea74390a14d30cf23f9040 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Jun 2023 21:00:53 +0200 Subject: [PATCH 075/857] Refactor Command Line sensor to use ManualTriggerEntity (#93999) * Refactor sensor to use ManualTriggerEntity * Add device and state class --- .../components/command_line/sensor.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index f42ac062081..b9dffd3ca45 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Mapping from datetime import timedelta import json +from typing import Any, cast import voluptuous as vol @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, SensorEntity, + SensorStateClass, ) from homeassistant.const import ( CONF_COMMAND, @@ -32,6 +34,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER @@ -89,24 +92,31 @@ async def async_setup_platform( value_template.hass = hass json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + state_class: SensorStateClass | None = sensor_config.get(CONF_STATE_CLASS) data = CommandSensorData(hass, command, command_timeout) + trigger_entity_config = { + CONF_UNIQUE_ID: unique_id, + CONF_NAME: Template(name, hass), + CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), + } + async_add_entities( [ CommandSensor( data, - name, + trigger_entity_config, unit, + state_class, value_template, json_attributes, - unique_id, scan_interval, ) ] ) -class CommandSensor(SensorEntity): +class CommandSensor(ManualTriggerEntity, SensorEntity): """Representation of a sensor that is using shell commands.""" _attr_should_poll = False @@ -114,25 +124,30 @@ class CommandSensor(SensorEntity): def __init__( self, data: CommandSensorData, - name: str, + config: ConfigType, unit_of_measurement: str | None, + state_class: SensorStateClass | None, value_template: Template | None, json_attributes: list[str] | None, - unique_id: str | None, scan_interval: timedelta, ) -> None: """Initialize the sensor.""" - self._attr_name = name + super().__init__(self.hass, config) self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_unique_id = unique_id + self._attr_state_class = state_class self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra state attributes.""" + return cast(dict, self._attr_extra_state_attributes) + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() @@ -186,6 +201,7 @@ class CommandSensor(SensorEntity): LOGGER.warning("Empty reply found when expecting JSON data") if self._value_template is None: self._attr_native_value = None + self._process_manual_data(value) return if self._value_template is not None: @@ -197,7 +213,7 @@ class CommandSensor(SensorEntity): ) else: self._attr_native_value = value - + self._process_manual_data(value) self.async_write_ha_state() From 5461d0e28fbc7821a78d29dde16cd1f8869f00d4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 4 Jun 2023 18:35:17 -0400 Subject: [PATCH 076/857] Fix zwave_js.update entity restore logic (#94043) --- homeassistant/components/zwave_js/update.py | 25 ++++++++------ tests/components/zwave_js/test_update.py | 36 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 8403e28a68b..5b7c157552a 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -42,6 +42,7 @@ PARALLEL_UPDATES = 1 UPDATE_DELAY_STRING = "delay" UPDATE_DELAY_INTERVAL = 5 # In minutes +ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware" @dataclass @@ -53,7 +54,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" return { - "latest_version_firmware": asdict(self.latest_version_firmware) + ATTR_LATEST_VERSION_FIRMWARE: asdict(self.latest_version_firmware) if self.latest_version_firmware else None } @@ -61,7 +62,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): @classmethod def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: """Initialize the extra data from a dict.""" - if not (firmware_dict := data["latest_version_firmware"]): + if not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]): return cls(None) return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) @@ -326,20 +327,24 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) # If we have a complete previous state, use that to set the latest version - if (state := await self.async_get_last_state()) and ( - extra_data := await self.async_get_last_extra_data() + if ( + (state := await self.async_get_last_state()) + and (latest_version := state.attributes.get(ATTR_LATEST_VERSION)) + is not None + and (extra_data := await self.async_get_last_extra_data()) ): - self._attr_latest_version = state.attributes[ATTR_LATEST_VERSION] + self._attr_latest_version = latest_version self._latest_version_firmware = ( ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) - # If we have no state to restore, we can set the latest version to installed - # so that the entity starts as off. If we have partial restore data due to an - # upgrade to an HA version where this feature is released from one that is not - # the entity will start in an unknown state until we can correct on next update - elif not state: + # If we have no state or latest version to restore, we can set the latest + # version to installed so that the entity starts as off. If we have partial + # restore data due to an upgrade to an HA version where this feature is released + # from one that is not the entity will start in an unknown state until we can + # correct on next update + elif not state or not latest_version: self._attr_latest_version = self._attr_installed_version # Spread updates out in 5 minute increments to avoid flooding the network diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 1a783f06bea..6a8cbdd724a 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -778,6 +778,42 @@ async def test_update_entity_full_restore_data_no_update_available( assert state.attributes[ATTR_LATEST_VERSION] == "10.7" +async def test_update_entity_no_latest_version( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test entity with no `latest_version` attr restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: None, + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": None}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + + async def test_update_entity_unload_asleep_node( hass: HomeAssistant, client, wallmote_central_scene, integration ) -> None: From 09882923d2c14e5dc576bb554c2b1cdd1c83fbb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jun 2023 18:42:37 -0500 Subject: [PATCH 077/857] Drop codeowner for gogogate2 (#94049) --- CODEOWNERS | 4 ++-- homeassistant/components/gogogate2/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 0b5b6f221f9..30fd1f58370 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -450,8 +450,8 @@ build.json @home-assistant/supervisor /tests/components/glances/ @engrbm87 /homeassistant/components/goalzero/ @tkdrob /tests/components/goalzero/ @tkdrob -/homeassistant/components/gogogate2/ @vangorra @bdraco -/tests/components/gogogate2/ @vangorra @bdraco +/homeassistant/components/gogogate2/ @vangorra +/tests/components/gogogate2/ @vangorra /homeassistant/components/goodwe/ @mletenay @starkillerOG /tests/components/goodwe/ @mletenay @starkillerOG /homeassistant/components/google/ @allenporter diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index ec2834d00d8..faebcf7e353 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -1,7 +1,7 @@ { "domain": "gogogate2", "name": "Gogogate2 and ismartgate", - "codeowners": ["@vangorra", "@bdraco"], + "codeowners": ["@vangorra"], "config_flow": true, "dhcp": [ { From 5d9c36ac44c7e958c89160ec27b354e4e67e5577 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 4 Jun 2023 20:02:13 -0400 Subject: [PATCH 078/857] Bump Roborock to 0.21.0 (#94035) bump to 21.0 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 44a4cba89c9..41e4a359e2e 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.17.0"] + "requirements": ["python-roborock==0.21.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86054ffca98..15fd2da8a3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.17.0 +python-roborock==0.21.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 415372dd835..e089d17291e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1557,7 +1557,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.17.0 +python-roborock==0.21.0 # homeassistant.components.smarttub python-smarttub==0.0.33 From a08e516da03dd9fea48a81fbb5866cd3f7436faf Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Mon, 5 Jun 2023 02:06:38 +0200 Subject: [PATCH 079/857] Update pynuki to 1.6.2 (#94041) chore(component/nuki): update pynuki to 1.6.2 --- homeassistant/components/nuki/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 8b87816fb7d..b84bee660c1 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.1"] + "requirements": ["pynuki==1.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15fd2da8a3f..72d6ec8f348 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1853,7 +1853,7 @@ pynetio==0.1.9.1 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.1 +pynuki==1.6.2 # homeassistant.components.nut pynut2==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e089d17291e..bbe7b27bf51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1363,7 +1363,7 @@ pynetgear==0.10.9 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.1 +pynuki==1.6.2 # homeassistant.components.nut pynut2==2.1.2 From 7c02e1ca9901519f8207946b1e307c18d16f9218 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 5 Jun 2023 02:07:37 +0200 Subject: [PATCH 080/857] Bump xiaomi-ble to 0.17.2 (#94011) Bump xiaomi-ble Co-authored-by: J. Nick Koston --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/xiaomi_ble/test_binary_sensor.py | 4 ++-- tests/components/xiaomi_ble/test_config_flow.py | 12 ++++++------ 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 4d5cddd9517..69a95ea8a9c 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.17.0"] + "requirements": ["xiaomi-ble==0.17.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 72d6ec8f348..ccfa091b490 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2682,7 +2682,7 @@ wyoming==0.0.1 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.0 +xiaomi-ble==0.17.2 # homeassistant.components.knx xknx==2.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbe7b27bf51..d666f8cebe1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1952,7 +1952,7 @@ wyoming==0.0.1 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.0 +xiaomi-ble==0.17.2 # homeassistant.components.knx xknx==2.10.0 diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 9345660f21c..235be5c6cd8 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -253,10 +253,10 @@ async def test_smoke(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 - smoke_sensor = hass.states.get("binary_sensor.thermometer_9cbc_smoke") + smoke_sensor = hass.states.get("binary_sensor.smoke_detector_9cbc_smoke") smoke_sensor_attribtes = smoke_sensor.attributes assert smoke_sensor.state == STATE_ON - assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Thermometer 9CBC Smoke" + assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Smoke Detector 9CBC Smoke" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/xiaomi_ble/test_config_flow.py b/tests/components/xiaomi_ble/test_config_flow.py index 3f537c2afa0..97aa878e1fb 100644 --- a/tests/components/xiaomi_ble/test_config_flow.py +++ b/tests/components/xiaomi_ble/test_config_flow.py @@ -248,7 +248,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -284,7 +284,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption_wrong_key( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -320,7 +320,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption_wrong_key_length( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -501,7 +501,7 @@ async def test_async_step_user_with_found_devices_v4_encryption( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -549,7 +549,7 @@ async def test_async_step_user_with_found_devices_v4_encryption_wrong_key( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -599,7 +599,7 @@ async def test_async_step_user_with_found_devices_v4_encryption_wrong_key_length ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" From fe616727927be17b79d4767732b5d14b4fabad15 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 5 Jun 2023 00:14:08 +0000 Subject: [PATCH 081/857] Don't inherit SensorEntity/NumberEntity and RestoreEntity in Shelly integration (#93531) * Use RestoreNumber and Restore Sensor for block entities * Update tests * Use RestoreSensor for RPC entities * Fix test for number platform --- .../components/shelly/binary_sensor.py | 19 +++++++- homeassistant/components/shelly/entity.py | 15 +----- homeassistant/components/shelly/number.py | 31 +++++++++++-- homeassistant/components/shelly/sensor.py | 46 ++++++++++++++----- homeassistant/components/shelly/update.py | 10 +++- tests/components/shelly/test_number.py | 11 ++++- tests/components/shelly/test_sensor.py | 13 ++++-- 7 files changed, 106 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 449fc142218..1474906cacb 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -14,6 +14,7 @@ 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 from .entity import ( @@ -290,11 +291,18 @@ class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class BlockSleepingBinarySensor(ShellySleepingBlockAttributeEntity, BinarySensorEntity): +class BlockSleepingBinarySensor( + ShellySleepingBlockAttributeEntity, BinarySensorEntity, RestoreEntity +): """Represent a block sleeping binary sensor.""" entity_description: BlockBinarySensorDescription + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self.last_state = await self.async_get_last_state() + @property def is_on(self) -> bool | None: """Return true if sensor state is on.""" @@ -307,11 +315,18 @@ class BlockSleepingBinarySensor(ShellySleepingBlockAttributeEntity, BinarySensor return self.last_state.state == STATE_ON -class RpcSleepingBinarySensor(ShellySleepingRpcAttributeEntity, BinarySensorEntity): +class RpcSleepingBinarySensor( + ShellySleepingRpcAttributeEntity, BinarySensorEntity, RestoreEntity +): """Represent a RPC sleeping binary sensor entity.""" entity_description: RpcBinarySensorDescription + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self.last_state = await self.async_get_last_state() + @property def is_on(self) -> bool | None: """Return true if RPC sensor state is on.""" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1f18a5f8e18..50d41899800 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity_registry import ( async_entries_for_config_entry, async_get as er_async_get, ) -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -552,7 +551,7 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity): return self.entity_description.available(self.sub_status) -class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEntity): +class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): """Represent a shelly sleeping block attribute entity.""" # pylint: disable=super-init-not-called @@ -589,11 +588,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self._attr_unique_id = entry.unique_id self._attr_name = cast(str, entry.original_name) - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self.last_state = await self.async_get_last_state() - @callback def _update_callback(self) -> None: """Handle device update.""" @@ -629,7 +623,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti return -class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity, RestoreEntity): +class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): """Helper class to represent a sleeping rpc attribute.""" entity_description: RpcEntityDescription @@ -665,8 +659,3 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity, RestoreEntity): ) elif entry is not None: self._attr_name = cast(str, entry.original_name) - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self.last_state = await self.async_get_last_state() diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index a89c74f9e50..f2b6bedb443 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -1,15 +1,18 @@ """Number for Shelly.""" from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Final, cast +from aioshelly.block_device import Block from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( - NumberEntity, NumberEntityDescription, + NumberExtraStoredData, NumberMode, + RestoreNumber, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory @@ -19,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from .const import CONF_SLEEP_PERIOD, LOGGER +from .coordinator import ShellyBlockCoordinator from .entity import ( BlockEntityDescription, ShellySleepingBlockAttributeEntity, @@ -85,22 +89,39 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, NumberEntity): +class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): """Represent a block sleeping number.""" entity_description: BlockNumberDescription + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block | None, + 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) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self.restored_data = await self.async_get_last_number_data() + @property def native_value(self) -> float | None: """Return value of number.""" if self.block is not None: return cast(float, self.attribute_value) - if self.last_state is None: + if self.restored_data is None: return None - return cast(float, self.last_state.state) + return cast(float, self.restored_data.native_value) async def async_set_native_value(self, value: float) -> None: """Set value.""" diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 4a88157efc6..0260a540f0c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -8,14 +8,15 @@ from typing import Final, cast from aioshelly.block_device import Block from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorExtraStoredData, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_PARTS_PER_MILLION, DEGREE, LIGHT_LUX, @@ -35,7 +36,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS -from .coordinator import ShellyBlockCoordinator +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RestEntityDescription, @@ -776,8 +777,7 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): return self.attribute_value -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): +class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor): """Represent a block sleeping sensor.""" entity_description: BlockSensorDescription @@ -793,6 +793,12 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): ) -> None: """Initialize the sleeping sensor.""" super().__init__(coordinator, block, attribute, description, entry, sensors) + self.restored_data: SensorExtraStoredData | None = None + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self.restored_data = await self.async_get_last_sensor_data() @property def native_value(self) -> StateType: @@ -800,10 +806,10 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): if self.block is not None: return self.attribute_value - if self.last_state is None: + if self.restored_data is None: return None - return self.last_state.state + return cast(StateType, self.restored_data.native_value) @property def native_unit_of_measurement(self) -> str | None: @@ -811,28 +817,44 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): if self.block is not None: return self.entity_description.native_unit_of_measurement - if self.last_state is None: + if self.restored_data is None: return None - return self.last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return self.restored_data.native_unit_of_measurement -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, SensorEntity): +class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, RestoreSensor): """Represent a RPC sleeping sensor.""" entity_description: RpcSensorDescription + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + entry: RegistryEntry | None = None, + ) -> None: + """Initialize the sleeping sensor.""" + super().__init__(coordinator, key, attribute, description, entry) + self.restored_data: SensorExtraStoredData | None = None + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self.restored_data = await self.async_get_last_sensor_data() + @property def native_value(self) -> StateType: """Return value of sensor.""" if self.coordinator.device.initialized: return self.attribute_value - if self.last_state is None: + if self.restored_data is None: return None - return self.last_state.state + return cast(StateType, self.restored_data.native_value) @property def native_unit_of_measurement(self) -> str | None: diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 47a39105ac4..3b2096f0c1a 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -21,6 +21,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator @@ -282,11 +283,18 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): LOGGER.debug("OTA update call successful") -class RpcSleepingUpdateEntity(ShellySleepingRpcAttributeEntity, UpdateEntity): +class RpcSleepingUpdateEntity( + ShellySleepingRpcAttributeEntity, UpdateEntity, RestoreEntity +): """Represent a RPC sleeping update entity.""" entity_description: RpcUpdateDescription + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self.last_state = await self.async_get_last_state() + @property def installed_version(self) -> str | None: """Version currently in use.""" diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 57a8e801b92..403d2f2993d 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -17,7 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from . import init_integration, register_device, register_entity -from tests.common import mock_restore_cache +from tests.common import mock_restore_cache_with_extra_data DEVICE_BLOCK_ID = 4 @@ -62,7 +62,14 @@ async def test_block_restored_number( entry, capabilities, ) - mock_restore_cache(hass, [State(entity_id, "40")]) + extra_data = { + "native_max_value": 100, + "native_min_value": 0, + "native_step": 1, + "native_unit_of_measurement": "%", + "native_value": "40", + } + mock_restore_cache_with_extra_data(hass, ((State(entity_id, ""), extra_data),)) monkeypatch.setattr(mock_block_device, "initialized", False) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 0b906d60079..d87460fb17d 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -20,7 +20,7 @@ from . import ( register_entity, ) -from tests.common import mock_restore_cache +from tests.common import mock_restore_cache_with_extra_data RELAY_BLOCK_ID = 0 SENSOR_BLOCK_ID = 3 @@ -137,7 +137,9 @@ async def test_block_restored_sleeping_sensor( entity_id = register_entity( hass, SENSOR_DOMAIN, "test_name_temperature", "sensor_0-temp", entry ) - mock_restore_cache(hass, [State(entity_id, "20.4")]) + extra_data = {"native_value": "20.4", "native_unit_of_measurement": "°C"} + + mock_restore_cache_with_extra_data(hass, ((State(entity_id, ""), extra_data),)) monkeypatch.setattr(mock_block_device, "initialized", False) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -216,7 +218,9 @@ async def test_block_not_matched_restored_sleeping_sensor( entity_id = register_entity( hass, SENSOR_DOMAIN, "test_name_temperature", "sensor_0-temp", entry ) - mock_restore_cache(hass, [State(entity_id, "20.4")]) + extra_data = {"native_value": "20.4", "native_unit_of_measurement": "°C"} + + mock_restore_cache_with_extra_data(hass, ((State(entity_id, ""), extra_data),)) monkeypatch.setattr(mock_block_device, "initialized", False) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -357,8 +361,9 @@ async def test_rpc_restored_sleeping_sensor( "temperature:0-temperature_0", entry, ) + extra_data = {"native_value": "21.0", "native_unit_of_measurement": "°C"} - mock_restore_cache(hass, [State(entity_id, "21.0")]) + mock_restore_cache_with_extra_data(hass, ((State(entity_id, ""), extra_data),)) monkeypatch.setattr(mock_rpc_device, "initialized", False) await hass.config_entries.async_setup(entry.entry_id) From 06bcb69a564521d4092e0ad6e5b7ee77dc0fa435 Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Mon, 5 Jun 2023 03:26:26 +0300 Subject: [PATCH 082/857] Add media stop to LG Netcast TV (#93615) --- homeassistant/components/lg_netcast/media_player.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index c7a5281bf61..2b59e628705 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -40,6 +40,7 @@ SUPPORT_LGTV = ( | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.STOP ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -240,6 +241,10 @@ class LgTVDevice(MediaPlayerEntity): """Send media pause command to media player.""" self.send_command(LG_COMMAND.PAUSE) + def media_stop(self) -> None: + """Send media stop command to media player.""" + self.send_command(LG_COMMAND.STOP) + def media_next_track(self) -> None: """Send next track command.""" self.send_command(LG_COMMAND.FAST_FORWARD) From 5078bb3bef62c86c920193674c7f6aad7a682787 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jun 2023 19:27:42 -0500 Subject: [PATCH 083/857] Remove miniaudio pin now that upstream package has been fixed (#94034) see https://github.com/irmen/pyminiaudio/issues/67#issuecomment-1575602707 --- homeassistant/package_constraints.txt | 4 ---- script/gen_requirements_all.py | 4 ---- 2 files changed, 8 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c3117734018..6ad11ff808d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -176,10 +176,6 @@ pysnmplib==5.0.21 # python pysnmp==1000000000.0.0 -# pyminiaudio 1.58 is missing files in the package -# https://github.com/irmen/pyminiaudio/issues/67 -miniaudio==1.57 - # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e7356d710c0..ca39d78c4c6 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -180,10 +180,6 @@ pysnmplib==5.0.21 # python pysnmp==1000000000.0.0 -# pyminiaudio 1.58 is missing files in the package -# https://github.com/irmen/pyminiaudio/issues/67 -miniaudio==1.57 - # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 From 7f480849e23a49666fe6c8d84e43726948b26314 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 4 Jun 2023 20:28:28 -0400 Subject: [PATCH 084/857] Add camera platform to Dremel (#93882) * Add camera platform to Dremel * unload and tests --- .../components/dremel_3d_printer/__init__.py | 17 ++++--- .../components/dremel_3d_printer/camera.py | 44 +++++++++++++++++++ .../components/dremel_3d_printer/const.py | 2 + .../components/dremel_3d_printer/test_init.py | 18 +++++--- 4 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/dremel_3d_printer/camera.py diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py index eaf22383839..db17e594cc4 100644 --- a/homeassistant/components/dremel_3d_printer/__init__.py +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -9,10 +9,10 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN +from .const import CAMERA_MODEL, DOMAIN from .coordinator import Dremel3DPrinterDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -30,12 +30,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + platforms = list(PLATFORMS) + if api.get_model() != CAMERA_MODEL: + platforms.remove(Platform.CAMERA) + await hass.config_entries.async_forward_entry_setups(config_entry, platforms) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Dremel config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data.pop(DOMAIN) + platforms = list(PLATFORMS) + api: Dremel3DPrinter = hass.data[DOMAIN][entry.entry_id].api + if api.get_model() != CAMERA_MODEL: + platforms.remove(Platform.CAMERA) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/dremel_3d_printer/camera.py b/homeassistant/components/dremel_3d_printer/camera.py new file mode 100644 index 00000000000..7468400ec35 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/camera.py @@ -0,0 +1,44 @@ +"""Support for Dremel 3D45 Camera.""" +from __future__ import annotations + +from homeassistant.components.camera import CameraEntityDescription +from homeassistant.components.mjpeg import MjpegCamera +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Dremel3DPrinterDataUpdateCoordinator +from .const import DOMAIN +from .entity import Dremel3DPrinterEntity + +CAMERA_TYPE = CameraEntityDescription( + key="camera", + name="Camera", +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a MJPEG IP Camera for the 3D45 Model. The 3D20 and 3D40 models don't have built in cameras.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([Dremel3D45Camera(coordinator, CAMERA_TYPE)]) + + +class Dremel3D45Camera(Dremel3DPrinterEntity, MjpegCamera): + """Dremel 3D45 Camera.""" + + def __init__( + self, + coordinator: Dremel3DPrinterDataUpdateCoordinator, + description: CameraEntityDescription, + ) -> None: + """Initialize a new Dremel 3D Printer integration camera for the 3D45 model.""" + super().__init__(coordinator, description) + MjpegCamera.__init__( + self, + mjpeg_url=coordinator.api.get_stream_url(), + still_image_url=coordinator.api.get_snapshot_url(), + ) diff --git a/homeassistant/components/dremel_3d_printer/const.py b/homeassistant/components/dremel_3d_printer/const.py index 611b3b86306..cccdeb937cb 100644 --- a/homeassistant/components/dremel_3d_printer/const.py +++ b/homeassistant/components/dremel_3d_printer/const.py @@ -5,6 +5,8 @@ import logging LOGGER = logging.getLogger(__package__) +CAMERA_MODEL = "3D45" + DOMAIN = "dremel_3d_printer" ATTR_EXTRUDER = "extruder" diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index 5d97c89b9cd..a77c6159927 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +import pytest from requests.exceptions import ConnectTimeout from homeassistant.components.dremel_3d_printer.const import DOMAIN @@ -14,20 +15,27 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +MOCKED_MODEL = "homeassistant.components.dremel_3d_printer.Dremel3DPrinter.get_model" + +@pytest.mark.parametrize("model", ["3D45", "3D20"]) async def test_setup( - hass: HomeAssistant, connection, config_entry: MockConfigEntry + hass: HomeAssistant, connection, config_entry: MockConfigEntry, model: str ) -> None: """Test load and unload.""" - await hass.config_entries.async_setup(config_entry.entry_id) - assert await async_setup_component(hass, DOMAIN, {}) + with patch(MOCKED_MODEL, return_value=model) as mock: + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) assert config_entry.state == ConfigEntryState.LOADED + assert mock.called - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + with patch(MOCKED_MODEL, return_value=model) as mock: + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + assert mock.called async def test_async_setup_entry_not_ready( From d8b4d71475c115b92170510c9bf0842f4b35b382 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sun, 4 Jun 2023 21:14:31 -0500 Subject: [PATCH 085/857] Update pyipp to 0.14.0 (#94050) --- homeassistant/components/ipp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index e93f9832722..59b8b4b070e 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.13.0"], + "requirements": ["pyipp==0.14.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ccfa091b490..596ae4984f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1733,7 +1733,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.13.0 +pyipp==0.14.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d666f8cebe1..f72fccc8c9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1273,7 +1273,7 @@ pyinsteon==1.4.2 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.13.0 +pyipp==0.14.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 From be2389a9dbaa7be276a7311dd1c8ff63ef1a3a30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jun 2023 21:14:44 -0500 Subject: [PATCH 086/857] Bump zeroconf to 0.64.0 (#94052) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 442f3297467..85cf503bb0d 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.63.0"] + "requirements": ["zeroconf==0.64.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6ad11ff808d..0e628ccbde3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.63.0 +zeroconf==0.64.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 596ae4984f6..9c709de46d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2733,7 +2733,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.63.0 +zeroconf==0.64.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f72fccc8c9f..8da7ec02f45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.63.0 +zeroconf==0.64.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 5a80eddbd7549baf91815cdf0c12d9fc47f7e340 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Mon, 5 Jun 2023 22:21:23 +0800 Subject: [PATCH 087/857] Clean up error logging in qbittorrent (#94071) --- homeassistant/components/qbittorrent/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 5154ae155ec..53e8d4b9660 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -35,11 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_VERIFY_SSL], ) except LoginRequired as err: - _LOGGER.error("Invalid credentials") - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady("Invalid credentials") from err except RequestException as err: - _LOGGER.error("Failed to connect") - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady("Failed to connect") from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True From 437de7c2a33484dfb2add134910431323ae0e53d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 5 Jun 2023 17:02:31 +0200 Subject: [PATCH 088/857] Fix mqtt climate initial temperature conversion and precision (#93965) * Fix mqtt climate initial temperature conversion * Avoid changing hass temperature_unit * Update comment --- homeassistant/components/mqtt/climate.py | 52 +++++++++----- tests/components/mqtt/test_climate.py | 92 +++++++++++++++++++++++- 2 files changed, 125 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index f580df9eab1..275c024667f 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -15,9 +15,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_HUMIDITY, - DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, - DEFAULT_MIN_TEMP, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -42,12 +40,14 @@ from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.unit_conversion import TemperatureConverter from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA @@ -150,6 +150,8 @@ CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" CONF_TEMP_STEP = "temp_step" +DEFAULT_INITIAL_TEMPERATURE = 21.0 + MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( { climate.ATTR_AUX_HEAT, @@ -338,9 +340,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ): cv.ensure_list, vol.Optional(CONF_SWING_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_TEMP_INITIAL, default=21): cv.positive_int, - vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float), - vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_TEMP_INITIAL): cv.positive_int, + vol.Optional(CONF_TEMP_MIN): vol.Coerce(float), + vol.Optional(CONF_TEMP_MAX): vol.Coerce(float), vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float), vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_TEMP_COMMAND_TOPIC): valid_publish_topic, @@ -443,6 +445,9 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ + _attr_target_temperature_low: float | None + _attr_target_temperature_high: float | None + _optimistic: bool _topic: dict[str, Any] @@ -637,7 +642,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): self.async_write_ha_state() -class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[misc] +class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): """Representation of an MQTT climate device.""" _entity_id_format = climate.ENTITY_ID_FORMAT @@ -668,28 +673,41 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[ def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" self._attr_hvac_modes = config[CONF_MODE_LIST] - self._attr_min_temp = config[CONF_TEMP_MIN] - self._attr_max_temp = config[CONF_TEMP_MAX] - self._attr_min_humidity = config[CONF_HUMIDITY_MIN] - self._attr_max_humidity = config[CONF_HUMIDITY_MAX] - self._attr_precision = config.get(CONF_PRECISION, super().precision) - self._attr_fan_modes = config[CONF_FAN_MODE_LIST] - self._attr_swing_modes = config[CONF_SWING_MODE_LIST] - self._attr_target_temperature_step = config[CONF_TEMP_STEP] + # Make sure the min an max temp is converted to the correct when not set self._attr_temperature_unit = config.get( CONF_TEMPERATURE_UNIT, self.hass.config.units.temperature_unit ) + if (min_temp := config.get(CONF_TEMP_MIN)) is not None: + self._attr_min_temp = min_temp + if (max_temp := config.get(CONF_TEMP_MAX)) is not None: + self._attr_max_temp = max_temp + self._attr_min_humidity = config[CONF_HUMIDITY_MIN] + self._attr_max_humidity = config[CONF_HUMIDITY_MAX] + if (precision := config.get(CONF_PRECISION)) is not None: + self._attr_precision = precision + self._attr_fan_modes = config[CONF_FAN_MODE_LIST] + self._attr_swing_modes = config[CONF_SWING_MODE_LIST] + self._attr_target_temperature_step = config[CONF_TEMP_STEP] self._topic = {key: config.get(key) for key in TOPIC_KEYS} self._optimistic = config[CONF_OPTIMISTIC] + # Set init temp, if it is missing convert the default to the temperature units + init_temp: float = config.get( + CONF_TEMP_INITIAL, + TemperatureConverter.convert( + DEFAULT_INITIAL_TEMPERATURE, + UnitOfTemperature.CELSIUS, + self.temperature_unit, + ), + ) if self._topic[CONF_TEMP_STATE_TOPIC] is None or self._optimistic: - self._attr_target_temperature = config[CONF_TEMP_INITIAL] + self._attr_target_temperature = init_temp if self._topic[CONF_TEMP_LOW_STATE_TOPIC] is None or self._optimistic: - self._attr_target_temperature_low = config[CONF_TEMP_INITIAL] + self._attr_target_temperature_low = init_temp if self._topic[CONF_TEMP_HIGH_STATE_TOPIC] is None or self._optimistic: - self._attr_target_temperature_high = config[CONF_TEMP_INITIAL] + self._attr_target_temperature_high = init_temp if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_fan_mode = FAN_LOW diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 384f03a317c..452a9b862ff 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -27,8 +27,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.mqtt.climate import MQTT_CLIMATE_ATTRIBUTES_BLOCKED -from homeassistant.const import ATTR_TEMPERATURE, Platform +from homeassistant.components.mqtt.climate import ( + DEFAULT_INITIAL_TEMPERATURE, + MQTT_CLIMATE_ATTRIBUTES_BLOCKED, +) +from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from .test_common import ( @@ -1690,12 +1693,97 @@ async def test_temperature_unit( """Test that setting temperature unit converts temperature values.""" await mqtt_mock_entry() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("temperature") == DEFAULT_INITIAL_TEMPERATURE + assert state.attributes.get("min_temp") == DEFAULT_MIN_TEMP + assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP + async_fire_mqtt_message(hass, "current_temperature", "77") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_temperature") == 25 +@pytest.mark.parametrize( + ("hass_config", "temperature_unit", "initial", "min", "max", "current"), + [ + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.CELSIUS, + DEFAULT_INITIAL_TEMPERATURE, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + 25, + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.KELVIN, + 294, + 280, + 308, + 298, + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.FAHRENHEIT, + 70, + 45, + 95, + 77, + ), + ], +) +async def test_alt_temperature_unit( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + temperature_unit: UnitOfTemperature, + initial: float, + min: float, + max: float, + current: float, +) -> None: + """Test deriving the systems temperature unit.""" + with patch.object(hass.config.units, "temperature_unit", temperature_unit): + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min + assert state.attributes.get("max_temp") == max + + async_fire_mqtt_message(hass, "current_temperature", "77") + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("current_temperature") == current + + async def test_setting_attribute_via_mqtt_json_message( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: From d9ac041e172a72e23a41427bf19ecf7884068fb4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 5 Jun 2023 18:14:07 +0200 Subject: [PATCH 089/857] Remove qbittorrent YAML configuration (#93548) Remove platform yaml qbittorrent --- .../components/qbittorrent/config_flow.py | 24 +----------- .../components/qbittorrent/sensor.py | 27 +------------ .../components/qbittorrent/strings.json | 6 --- .../qbittorrent/test_config_flow.py | 38 +------------------ 4 files changed, 3 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py index 54c47c53895..ac41d03e998 100644 --- a/homeassistant/components/qbittorrent/config_flow.py +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -1,7 +1,6 @@ """Config flow for qBittorrent.""" from __future__ import annotations -import logging from typing import Any from qbittorrent.client import LoginRequired @@ -9,20 +8,12 @@ from requests.exceptions import RequestException import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_URL, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN from .helpers import setup_client -_LOGGER = logging.getLogger(__name__) - USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_URL, default=DEFAULT_URL): str, @@ -61,16 +52,3 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) - return self.async_create_entry( - title=config.get(CONF_NAME, DEFAULT_NAME), - data={ - CONF_URL: config[CONF_URL], - CONF_USERNAME: config[CONF_USERNAME], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_VERIFY_SSL: True, - }, - ) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 6b758daab0a..b6d9fe63e4b 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -24,10 +24,8 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DEFAULT_NAME, DOMAIN @@ -70,29 +68,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the qBittorrent platform.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 24d1885a917..66c9430911e 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -17,11 +17,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The qBittorrent YAML configuration is being removed", - "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py index b7244ccef8d..bbfeee20d8a 100644 --- a/tests/components/qbittorrent/test_config_flow.py +++ b/tests/components/qbittorrent/test_config_flow.py @@ -4,7 +4,7 @@ from requests.exceptions import RequestException import requests_mock from homeassistant.components.qbittorrent.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_PASSWORD, CONF_SOURCE, @@ -26,12 +26,6 @@ USER_INPUT = { CONF_VERIFY_SSL: True, } -YAML_IMPORT = { - CONF_URL: "http://localhost:8080", - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", -} - async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> None: """Test the user flow.""" @@ -104,33 +98,3 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_flow_import(hass: HomeAssistant) -> None: - """Test import step.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_IMPORT}, - data=YAML_IMPORT, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_URL: "http://localhost:8080", - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - CONF_VERIFY_SSL: True, - } - - -async def test_flow_import_already_configured(hass: HomeAssistant) -> None: - """Test import step already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_IMPORT}, - data=YAML_IMPORT, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" From 40cb9337c5b9c9c585ecbc72b1d4666037b12c69 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 5 Jun 2023 18:28:08 +0200 Subject: [PATCH 090/857] Remove snapcast YAML configuration (#93547) Remove platform yaml snapcast --- .../components/snapcast/config_flow.py | 10 ----- .../components/snapcast/media_player.py | 41 +------------------ .../components/snapcast/strings.json | 6 --- tests/components/snapcast/test_config_flow.py | 15 ------- 4 files changed, 2 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py index 896d3f8b5a8..479d1d648b8 100644 --- a/homeassistant/components/snapcast/config_flow.py +++ b/homeassistant/components/snapcast/config_flow.py @@ -51,13 +51,3 @@ class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors ) - - async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - self._async_abort_entries_match( - { - CONF_HOST: (import_config[CONF_HOST]), - CONF_PORT: (import_config[CONF_PORT]), - } - ) - return self.async_create_entry(title=DEFAULT_TITLE, data=import_config) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 624bf7463ba..377a3d1e2b6 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -1,24 +1,19 @@ """Support for interacting with Snapcast clients.""" from __future__ import annotations -import logging - -from snapcast.control.server import CONTROL_PORT, Snapserver +from snapcast.control.server import Snapserver import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( ATTR_LATENCY, @@ -35,12 +30,6 @@ from .const import ( SERVICE_UNJOIN, ) -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} -) - STREAM_STATUS = { "idle": MediaPlayerState.IDLE, "playing": MediaPlayerState.PLAYING, @@ -93,32 +82,6 @@ async def async_setup_entry( ].hass_async_add_entities = async_add_entities -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Snapcast platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - async def handle_async_join(entity, service_call): """Handle the entity service join.""" if not isinstance(entity, SnapcastClientDevice): diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0087b70d820..766bca63495 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -17,11 +17,5 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Snapcast YAML configuration is being removed", - "description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index b6ff43503a6..bb07eae2140 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -93,18 +93,3 @@ async def test_abort( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import(hass: HomeAssistant) -> None: - """Test successful import.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Snapcast" - assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} From fb9a9aea122c849d958be481e1821a21178b03ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Jun 2023 12:48:18 -0500 Subject: [PATCH 091/857] Fix wheel builds on armhf and armv7 (#94053) --- .github/workflows/wheels.yml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 961459090c6..323376dacd1 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -159,7 +159,7 @@ jobs: # This is to prevent the build from running out of memory when # resolving packages on 32-bits systems (like armhf, armv7). - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 2) requirements_all.txt requirements_all.txt + split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all.txt requirements_all.txt - name: Adjust build env run: | @@ -204,6 +204,20 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" + - name: Build wheels (part 3) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtac" + # Wheels building for the cp311 ABI is currently split # This is mainly until we have figured out to get all wheels built. # Without harming our current workflow. @@ -297,7 +311,7 @@ jobs: # This is to prevent the build from running out of memory when # resolving packages on 32-bits systems (like armhf, armv7). - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 2) requirements_all.txt requirements_all.txt + split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all.txt requirements_all.txt - name: Adjust build env run: | @@ -342,3 +356,17 @@ jobs: constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" + + - name: Build wheels (part 3) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtac" From e2c2262719fbfebbd0cde00e2f45d9cc5e12fcad Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 5 Jun 2023 12:49:01 -0500 Subject: [PATCH 092/857] Bump intents to 2023.6.5 (#94077) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 06666af815a..01276d56081 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.5.30"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.6.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0e628ccbde3..3cbe1fb40c8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230601.1 -home-assistant-intents==2023.5.30 +home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9c709de46d0..ba5e6a3c6ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ holidays==0.21.13 home-assistant-frontend==20230601.1 # homeassistant.components.conversation -home-assistant-intents==2023.5.30 +home-assistant-intents==2023.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8da7ec02f45..54bfcfe265a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -770,7 +770,7 @@ holidays==0.21.13 home-assistant-frontend==20230601.1 # homeassistant.components.conversation -home-assistant-intents==2023.5.30 +home-assistant-intents==2023.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 From 28f6062bab4b01e62c643e9a9ba07da5691f69fb Mon Sep 17 00:00:00 2001 From: j4n-e4t <130256240+j4n-e4t@users.noreply.github.com> Date: Mon, 5 Jun 2023 19:53:24 +0200 Subject: [PATCH 093/857] Add error handling to input_select integration (#93940) --- .../components/input_select/__init__.py | 7 ++--- tests/components/input_select/test_init.py | 26 ++++++++++--------- .../input_select/test_reproduce_state.py | 4 ++- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 186ab84fb81..2c5a1c87f29 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -302,12 +302,9 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): async def async_select_option(self, option: str) -> None: """Select new option.""" if option not in self.options: - _LOGGER.warning( - "Invalid option: %s (possible options: %s)", - option, - ", ".join(self.options), + raise HomeAssistantError( + f"Invalid option: {option} (possible options: {', '.join(self.options)})" ) - return self._attr_current_option = option self.async_write_ha_state() diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 315392702eb..6908a1c532e 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -102,12 +102,13 @@ async def test_select_option(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == "another option" - await hass.services.async_call( - DOMAIN, - SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "non existing option"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "non existing option"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "another option" @@ -305,12 +306,13 @@ async def test_set_options_service(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == "test1" - await hass.services.async_call( - DOMAIN, - SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "first option"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "first option"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "test1" diff --git a/tests/components/input_select/test_reproduce_state.py b/tests/components/input_select/test_reproduce_state.py index d6e9274fa8d..a00b6b02ade 100644 --- a/tests/components/input_select/test_reproduce_state.py +++ b/tests/components/input_select/test_reproduce_state.py @@ -2,6 +2,7 @@ import pytest from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.state import async_reproduce_state from homeassistant.setup import async_setup_component @@ -60,7 +61,8 @@ async def test_reproducing_states( assert hass.states.get(ENTITY).state == VALID_OPTION3 # Test setting state to invalid state - await async_reproduce_state(hass, [State(ENTITY, INVALID_OPTION)]) + with pytest.raises(HomeAssistantError): + await async_reproduce_state(hass, [State(ENTITY, INVALID_OPTION)]) # The entity state should be unchanged assert hass.states.get(ENTITY).state == VALID_OPTION3 From 7e72d3c56202aef3cbb2b25abb29ba6975ddea0b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 5 Jun 2023 20:16:11 +0200 Subject: [PATCH 094/857] Update frontend to 20230605.0 (#94083) Co-authored-by: Paulus Schoutsen --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 838294f7ba5..b82ece33315 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230601.1"] + "requirements": ["home-assistant-frontend==20230605.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3cbe1fb40c8..e9b4359c937 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230601.1 +home-assistant-frontend==20230605.0 home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ba5e6a3c6ac..a608956145b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -987,7 +987,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.1 +home-assistant-frontend==20230605.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54bfcfe265a..19d6887b11b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -767,7 +767,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.1 +home-assistant-frontend==20230605.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 From e30e423091f8edcc00a12253549d2dc31034ee01 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 5 Jun 2023 15:52:40 -0400 Subject: [PATCH 095/857] Don't add Roborock switches if it is not supported (#94069) * don't add switches if it is not supported * don't create entity unless if it is valid * Raise on other exceptions * rework valid_enties --- homeassistant/components/roborock/switch.py | 52 +++++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 6971ff9d900..d8ff50430cb 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -1,9 +1,11 @@ """Support for Roborock switch.""" +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any +from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -30,6 +32,8 @@ class RoborockSwitchDescriptionMixin: evaluate_value: Callable[[dict], bool] # Sets the status of the switch set_command: Callable[[RoborockEntity, bool], Coroutine[Any, Any, dict]] + # Check support of this feature + check_support: Callable[[RoborockDataUpdateCoordinator], Coroutine[Any, Any, dict]] @dataclass @@ -45,6 +49,9 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ RoborockCommand.SET_CHILD_LOCK_STATUS, {"lock_status": 1 if value else 0} ), get_value=lambda data: data.send(RoborockCommand.GET_CHILD_LOCK_STATUS), + check_support=lambda data: data.api.send_command( + RoborockCommand.GET_CHILD_LOCK_STATUS + ), evaluate_value=lambda data: data["lock_status"] == 1, key="child_lock", translation_key="child_lock", @@ -56,6 +63,9 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ RoborockCommand.SET_FLOW_LED_STATUS, {"status": 1 if value else 0} ), get_value=lambda data: data.send(RoborockCommand.GET_FLOW_LED_STATUS), + check_support=lambda data: data.api.send_command( + RoborockCommand.GET_FLOW_LED_STATUS + ), evaluate_value=lambda data: data["status"] == 1, key="status_indicator", translation_key="status_indicator", @@ -75,16 +85,38 @@ async def async_setup_entry( coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] - async_add_entities( - ( - RoborockSwitchEntity( - f"{description.key}_{slugify(device_id)}", - coordinator, - description, - ) - for device_id, coordinator in coordinators.items() - for description in SWITCH_DESCRIPTIONS + possible_entities: list[ + tuple[str, RoborockDataUpdateCoordinator, RoborockSwitchDescription] + ] = [ + (device_id, coordinator, description) + for device_id, coordinator in coordinators.items() + for description in SWITCH_DESCRIPTIONS + ] + # We need to check if this function is supported by the device. + results = await asyncio.gather( + *( + description.check_support(coordinator) + for _, coordinator, description in possible_entities ), + return_exceptions=True, + ) + valid_entities: list[RoborockSwitchEntity] = [] + for posible_entity, result in zip(possible_entities, results): + if isinstance(result, Exception): + if not isinstance(result, RoborockException): + raise result + _LOGGER.debug("Not adding entity because of %s", result) + else: + valid_entities.append( + RoborockSwitchEntity( + f"{posible_entity[2].key}_{slugify(posible_entity[0])}", + posible_entity[1], + posible_entity[2], + result, + ) + ) + async_add_entities( + valid_entities, True, ) @@ -99,10 +131,12 @@ class RoborockSwitchEntity(RoborockEntity, SwitchEntity): unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSwitchDescription, + initial_value: bool, ) -> None: """Create a switch entity.""" self.entity_description = entity_description super().__init__(unique_id, coordinator.device_info, coordinator.api) + self._attr_is_on = initial_value async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" From 228da71cc40b609c8da40623a8c6e4f2e8488b9a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 5 Jun 2023 21:54:51 +0200 Subject: [PATCH 096/857] Fix reload service in Command Line (#94085) Fix multi platform reload service in command line --- homeassistant/components/command_line/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c9c18fe54a8..906e28052da 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -173,7 +173,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: platforms: list[Platform] = [] for platform_config in command_line_config: for platform, _config in platform_config.items(): - platforms.append(PLATFORM_MAPPING[platform]) + if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms: + platforms.append(mapped_platform) _LOGGER.debug( "Loading config %s for platform %s", platform_config, From 4b4660994cdba5b58e9d21e793a5088fba1748b5 Mon Sep 17 00:00:00 2001 From: Ian Date: Mon, 5 Jun 2023 22:08:42 -0700 Subject: [PATCH 097/857] Use shorthand attributes in NextBus (#94084) * NextBus: Use attr with unique id * Feedback * Remove unique id --- homeassistant/components/nextbus/sensor.py | 57 ++++++++-------------- 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 02f5d8695ca..b8f36e10fa1 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -108,41 +108,19 @@ class NextBusDepartureSensor(SensorEntity): self.agency = agency self.route = route self.stop = stop - self._custom_name = name - # Maybe pull a more user friendly name from the API here - self._name = f"{agency} {route}" - self._client = client + self._attr_extra_state_attributes = {} - # set up default state attributes - self._state = None - self._attributes = {} + # Maybe pull a more user friendly name from the API here + self._attr_name = f"{agency} {route}" + if name: + self._attr_name = name + + self._client = client def _log_debug(self, message, *args): """Log debug message with prefix.""" _LOGGER.debug(":".join((self.agency, self.route, self.stop, message)), *args) - @property - def name(self): - """Return sensor name. - - Uses an auto generated name based on the data from the API unless a - custom name is provided in the configuration. - """ - if self._custom_name: - return self._custom_name - - return self._name - - @property - def native_value(self): - """Return current state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return additional state attributes.""" - return self._attributes - def update(self) -> None: """Update sensor with new departures times.""" # Note: using Multi because there is a bug with the single stop impl @@ -151,21 +129,22 @@ class NextBusDepartureSensor(SensorEntity): ) self._log_debug("Predictions results: %s", results) + self._attr_attribution = results.get("copyright") if "Error" in results: self._log_debug("Could not get predictions: %s", results) if not results.get("predictions"): self._log_debug("No predictions available") - self._state = None + self._attr_native_value = None # Remove attributes that may now be outdated - self._attributes.pop("upcoming", None) + self._attr_extra_state_attributes.pop("upcoming", None) return results = results["predictions"] # Set detailed attributes - self._attributes.update( + self._attr_extra_state_attributes.update( { "agency": results.get("agencyTitle"), "route": results.get("routeTitle"), @@ -176,13 +155,13 @@ class NextBusDepartureSensor(SensorEntity): # List all messages in the attributes messages = listify(results.get("message", [])) self._log_debug("Messages: %s", messages) - self._attributes["message"] = " -- ".join( + self._attr_extra_state_attributes["message"] = " -- ".join( message.get("text", "") for message in messages ) # List out all directions in the attributes directions = listify(results.get("direction", [])) - self._attributes["direction"] = ", ".join( + self._attr_extra_state_attributes["direction"] = ", ".join( direction.get("title", "") for direction in directions ) @@ -196,14 +175,16 @@ class NextBusDepartureSensor(SensorEntity): # Short circuit if we don't have any actual bus predictions if not predictions: self._log_debug("No upcoming predictions available") - self._state = None - self._attributes["upcoming"] = "No upcoming predictions" + self._attr_native_value = None + self._attr_extra_state_attributes["upcoming"] = "No upcoming predictions" return # Generate list of upcoming times - self._attributes["upcoming"] = ", ".join( + self._attr_extra_state_attributes["upcoming"] = ", ".join( sorted((p["minutes"] for p in predictions), key=int) ) latest_prediction = maybe_first(predictions) - self._state = utc_from_timestamp(int(latest_prediction["epochTime"]) / 1000) + self._attr_native_value = utc_from_timestamp( + int(latest_prediction["epochTime"]) / 1000 + ) From 9cbb993296415aad695ea54e320721cee2c6a35e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 6 Jun 2023 08:22:12 +0200 Subject: [PATCH 098/857] Remove Xbox YAML configuration (#94094) --- homeassistant/components/xbox/__init__.py | 57 ++-------------------- homeassistant/components/xbox/strings.json | 6 --- tests/components/xbox/test_config_flow.py | 14 +++--- 3 files changed, 10 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 4263059f0fa..7f1f11ba25d 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from datetime import timedelta import logging -import voluptuous as vol from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.catalog.const import SYSTEM_PFN_ID_MAP from xbox.webapi.api.provider.catalog.models import AlternateIdType, Product @@ -20,17 +19,14 @@ from xbox.webapi.api.provider.smartglass.models import ( SmartglassConsoleStatus, ) -from homeassistant.components import application_credentials from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, config_validation as cv, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import api @@ -38,20 +34,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -61,40 +44,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the xbox component.""" - hass.data[DOMAIN] = {} - - if DOMAIN not in config: - return True - - await application_credentials.async_import_client_credential( - hass, - DOMAIN, - application_credentials.ClientCredential( - config[DOMAIN][CONF_CLIENT_ID], config[DOMAIN][CONF_CLIENT_SECRET] - ), - ) - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - _LOGGER.warning( - "Configuration of Xbox integration in YAML is deprecated and " - "will be removed in Home Assistant 2022.9.; Your existing configuration " - "(including OAuth Application Credentials) has been imported into " - "the UI automatically and can be safely removed from your " - "configuration.yaml file" - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up xbox from a config entry.""" implementation = ( @@ -118,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = XboxUpdateCoordinator(hass, client, consoles) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "client": XboxLiveClient(auth), "consoles": consoles, "coordinator": coordinator, diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 68af0176fa8..accd6775941 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -13,11 +13,5 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Xbox YAML configuration is being removed", - "description": "Configuring the Xbox in configuration.yaml is being removed in Home Assistant 2022.9.\n\nYour existing OAuth Application Credentials and access settings have been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index 2eb91f225bd..9738dc70148 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -3,6 +3,10 @@ from http import HTTPStatus from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.xbox.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow @@ -33,13 +37,9 @@ async def test_full_flow( current_request_with_host: None, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - "xbox", - { - "xbox": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, + assert await setup.async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" ) result = await hass.config_entries.flow.async_init( From 6019ec305adf30fa7d6c8ad05e464838b7101b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 6 Jun 2023 08:23:48 +0200 Subject: [PATCH 099/857] Update aioairzone to v0.6.3 and fix issue with latest firmware update (#94100) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/test_climate.py | 3 --- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 9fbdd95518e..637066629db 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.1"] + "requirements": ["aioairzone==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index a608956145b..8da04bddf43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -197,7 +197,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.7 # homeassistant.components.airzone -aioairzone==0.6.1 +aioairzone==0.6.3 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19d6887b11b..d5b4f76a559 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -172,7 +172,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.7 # homeassistant.components.airzone -aioairzone==0.6.1 +aioairzone==0.6.3 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index f51dd318240..2c66adcb974 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -1,14 +1,12 @@ """The climate tests for the Airzone platform.""" from unittest.mock import patch -from aioairzone.common import OperationMode from aioairzone.const import ( API_COOL_SET_POINT, API_DATA, API_HEAT_SET_POINT, API_MAX_TEMP, API_MIN_TEMP, - API_MODE, API_ON, API_SET_POINT, API_SPEED, @@ -336,7 +334,6 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: { API_SYSTEM_ID: 1, API_ZONE_ID: 1, - API_MODE: OperationMode.COOLING.value, API_ON: 1, } ] From 554ed1e9577d385c625146a9e3cbc8b4f22a0d64 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 6 Jun 2023 02:25:25 -0400 Subject: [PATCH 100/857] Add missing translation keys for Roborock mop intensity (#94088) --- homeassistant/components/roborock/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 1a65b636dfc..00ebd3833a8 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -90,8 +90,11 @@ "name": "Mop intensity", "state": { "off": "Off", + "low": "Low", "mild": "Mild", + "medium": "Medium", "moderate": "Moderate", + "high": "High", "intense": "Intense", "custom": "Custom" } From 13da90da90dcf912040b914061245acb18f9be68 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 6 Jun 2023 08:25:59 +0200 Subject: [PATCH 101/857] Remove left-over issue from platform YAML in Radarr (#94091) --- homeassistant/components/radarr/sensor.py | 20 -------------------- homeassistant/components/radarr/strings.json | 6 ------ 2 files changed, 26 deletions(-) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 0ed64ce3035..64e5356aeb3 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -18,8 +18,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import RadarrEntity from .const import DOMAIN @@ -104,24 +102,6 @@ BYTE_SIZES = [ PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Radarr platform.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index 299dd0a56b0..6b014b95d5e 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -34,11 +34,5 @@ } } } - }, - "issues": { - "removed_yaml": { - "title": "The Radarr YAML configuration has been removed", - "description": "Configuring Radarr using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } From 49a394547aa23951df9f6c0d039b128c096df10f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 6 Jun 2023 08:26:36 +0200 Subject: [PATCH 102/857] Remove platform YAML for Bose SoundTouch (#94090) --- .../components/soundtouch/config_flow.py | 11 ---- .../components/soundtouch/media_player.py | 55 +------------------ .../components/soundtouch/strings.json | 6 -- 3 files changed, 2 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py index 489dfff6feb..8f9b993d0d8 100644 --- a/homeassistant/components/soundtouch/config_flow.py +++ b/homeassistant/components/soundtouch/config_flow.py @@ -26,17 +26,6 @@ class SoundtouchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host = None self.name = None - async def async_step_import(self, import_data): - """Handle a flow initiated by configuration file.""" - self.host = import_data[CONF_HOST] - - try: - await self._async_get_device_id() - except RequestException: - return self.async_abort(reason="cannot_connect") - - return await self._async_create_soundtouch_entry() - async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" errors = {} diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 72118431330..9cd94330812 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -8,11 +8,9 @@ from typing import Any from libsoundtouch.device import SoundTouchDevice from libsoundtouch.utils import Source -import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEntity, @@ -21,20 +19,12 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PORT, - EVENT_HOMEASSISTANT_START, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -50,47 +40,6 @@ MAP_STATUS = { ATTR_SOUNDTOUCH_GROUP = "soundtouch_group" ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone" -PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_NAME, default=""): cv.string, - } - ), -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Bose SoundTouch platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - _LOGGER.warning( - "Configuration of the Bose SoundTouch integration in YAML is " - "deprecated and will be removed in Home Assistant 2022.10; Your " - "existing configuration has been imported into the UI automatically " - "and can be safely removed from your configuration.yaml file" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 6a8896c8f56..7ebcd4c5285 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -17,11 +17,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Bose SoundTouch YAML configuration is being removed", - "description": "Configuring Bose SoundTouch using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Bose SoundTouch YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } From 0b880598ed74bb39a3b11d2391fae5efb8a1ec22 Mon Sep 17 00:00:00 2001 From: Justin Vanderhooft Date: Tue, 6 Jun 2023 07:32:07 +0100 Subject: [PATCH 103/857] fix: Bump melnor-bluetooth to fix deadlock (#94098) --- homeassistant/components/melnor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 87a5583fa4f..185899a9656 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.22"] + "requirements": ["melnor-bluetooth==0.0.24"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8da04bddf43..afb781dcfb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1182,7 +1182,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.22 +melnor-bluetooth==0.0.24 # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5b4f76a559..a490a04235c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -899,7 +899,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.22 +melnor-bluetooth==0.0.24 # homeassistant.components.meteo_france meteofrance-api==1.2.0 From ebafb1f2c85ed9e2d900112b75617da4f65da622 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 6 Jun 2023 09:08:17 +0200 Subject: [PATCH 104/857] Bump aiounifi to v48 - Fix fail to initialise due to board_rev not exist (#94093) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f43e3030916..f48191e471a 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==47"], + "requirements": ["aiounifi==48"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index afb781dcfb1..108462817c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==47 +aiounifi==48 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a490a04235c..f9a104627a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==47 +aiounifi==48 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From c67f32b924d7c3b1960149eb5499568aefa14bc7 Mon Sep 17 00:00:00 2001 From: Paul Frank Date: Tue, 6 Jun 2023 14:16:27 +0200 Subject: [PATCH 105/857] Add config flow to mystrom (#74719) * rebase * fixed review comments * fix test * Update tests * increase test coverage * implement some review comments * Enhance device check for old FWs and add tests * Reworked exception handling * small code optimizations * fix test * Increase test coverage * Update __init__.py changed from hass.config_entries.async_setup_platforms(entry, PLATFORMS) to await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) * Update __init__.py remove spaces * Bump python-mystrom to 2.2.0 * Migrate to get_device_info from python-mystrom * Migrate to get_device_info from python-mystrom * Update __init__.py * update requirements_all.txt * update config_flow * fix tests * Update homeassistant/components/mystrom/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Implemented review changes * increase test coverage * Implemented user defined title * implemented user defined title * Additional review comments * fix test * fix linter * fix linter * Update homeassistant/components/mystrom/models.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * fix review comments * fix missing import * simplify * Update homeassistant/components/mystrom/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/light.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * fix review comments * fix review comments * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * fix review comment * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/mystrom/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- CODEOWNERS | 1 + homeassistant/components/mystrom/__init__.py | 80 ++++++++++- .../components/mystrom/config_flow.py | 57 ++++++++ homeassistant/components/mystrom/const.py | 2 + homeassistant/components/mystrom/light.py | 53 ++++--- .../components/mystrom/manifest.json | 1 + homeassistant/components/mystrom/models.py | 14 ++ homeassistant/components/mystrom/strings.json | 24 ++++ homeassistant/components/mystrom/switch.py | 46 ++++-- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/mystrom/__init__.py | 1 + tests/components/mystrom/conftest.py | 37 +++++ tests/components/mystrom/test_config_flow.py | 133 ++++++++++++++++++ tests/components/mystrom/test_init.py | 132 +++++++++++++++++ 16 files changed, 553 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/mystrom/config_flow.py create mode 100644 homeassistant/components/mystrom/models.py create mode 100644 homeassistant/components/mystrom/strings.json create mode 100644 tests/components/mystrom/__init__.py create mode 100644 tests/components/mystrom/conftest.py create mode 100644 tests/components/mystrom/test_config_flow.py create mode 100644 tests/components/mystrom/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 30fd1f58370..669294af12f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -786,6 +786,7 @@ build.json @home-assistant/supervisor /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff +/tests/components/mystrom/ @fabaff /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 54a24b9b4af..160cd0e8634 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -1 +1,79 @@ -"""The mystrom component.""" +"""The myStrom integration.""" +from __future__ import annotations + +import logging + +import pymystrom +from pymystrom.bulb import MyStromBulb +from pymystrom.exceptions import MyStromConnectionError +from pymystrom.switch import MyStromSwitch + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .models import MyStromData + +PLATFORMS_SWITCH = [Platform.SWITCH] +PLATFORMS_BULB = [Platform.LIGHT] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up myStrom from a config entry.""" + host = entry.data[CONF_HOST] + device = None + try: + info = await pymystrom.get_device_info(host) + except MyStromConnectionError as err: + _LOGGER.error("No route to myStrom plug: %s", host) + raise ConfigEntryNotReady() from err + + device_type = info["type"] + if device_type in [101, 106, 107]: + device = MyStromSwitch(host) + platforms = PLATFORMS_SWITCH + elif device_type == 102: + mac = info["mac"] + device = MyStromBulb(host, mac) + platforms = PLATFORMS_BULB + if device.bulb_type not in ["rgblamp", "strip"]: + _LOGGER.error( + "Device %s (%s) is not a myStrom bulb nor myStrom LED Strip", + host, + mac, + ) + return False + else: + _LOGGER.error("Unsupported myStrom device type: %s", device_type) + return False + + try: + await device.get_state() + except MyStromConnectionError as err: + _LOGGER.error("No route to myStrom plug: %s", info["ip"]) + raise ConfigEntryNotReady() from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = MyStromData( + device=device, + info=info, + ) + await hass.config_entries.async_forward_entry_setups(entry, platforms) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + device_type = hass.data[DOMAIN][entry.entry_id].info["type"] + if device_type in [101, 106, 107]: + platforms = PLATFORMS_SWITCH + elif device_type == 102: + platforms = PLATFORMS_BULB + if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/mystrom/config_flow.py b/homeassistant/components/mystrom/config_flow.py new file mode 100644 index 00000000000..3dc334d8252 --- /dev/null +++ b/homeassistant/components/mystrom/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for myStrom integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import pymystrom +from pymystrom.exceptions import MyStromConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "myStrom Device" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for myStrom.""" + + VERSION = 1 + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Handle import from config.""" + return await self.async_step_user(import_config) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + info = await pymystrom.get_device_info(user_input[CONF_HOST]) + except MyStromConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(info["mac"]) + self._abort_if_unique_id_configured() + data = {CONF_HOST: user_input[CONF_HOST]} + title = user_input.get(CONF_NAME) or DEFAULT_NAME + return self.async_create_entry(title=title, data=data) + + schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/mystrom/const.py b/homeassistant/components/mystrom/const.py index 87697acbe96..5641463abf1 100644 --- a/homeassistant/components/mystrom/const.py +++ b/homeassistant/components/mystrom/const.py @@ -1,2 +1,4 @@ """Constants for the myStrom integration.""" DOMAIN = "mystrom" +DEFAULT_NAME = "myStrom" +MANUFACTURER = "myStrom" diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index e01cebb818d..14badde17d2 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from typing import Any -from pymystrom.bulb import MyStromBulb from pymystrom.exceptions import MyStromConnectionError import voluptuous as vol @@ -17,13 +16,17 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN, MANUFACTURER + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "myStrom bulb" @@ -40,6 +43,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the myStrom entities.""" + info = hass.data[DOMAIN][entry.entry_id].info + device = hass.data[DOMAIN][entry.entry_id].device + async_add_entities([MyStromLight(device, entry.title, info["mac"])]) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -47,23 +59,20 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the myStrom light integration.""" - host = config.get(CONF_HOST) - mac = config.get(CONF_MAC) - name = config.get(CONF_NAME) - - bulb = MyStromBulb(host, mac) - try: - await bulb.get_state() - if bulb.bulb_type not in ["rgblamp", "strip"]: - _LOGGER.error( - "Device %s (%s) is not a myStrom bulb nor myStrom LED Strip", host, mac - ) - return - except MyStromConnectionError as err: - _LOGGER.warning("No route to myStrom bulb: %s", host) - raise PlatformNotReady() from err - - async_add_entities([MyStromLight(bulb, name, mac)], True) + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class MyStromLight(LightEntity): @@ -81,6 +90,12 @@ class MyStromLight(LightEntity): self._attr_available = False self._attr_unique_id = mac self._attr_hs_color = 0, 0 + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac)}, + name=name, + manufacturer=MANUFACTURER, + sw_version=self._bulb.firmware, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index 7659b1d8025..eaf9eb6acdc 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -2,6 +2,7 @@ "domain": "mystrom", "name": "myStrom", "codeowners": ["@fabaff"], + "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", diff --git a/homeassistant/components/mystrom/models.py b/homeassistant/components/mystrom/models.py new file mode 100644 index 00000000000..96cc40996ef --- /dev/null +++ b/homeassistant/components/mystrom/models.py @@ -0,0 +1,14 @@ +"""Models for the mystrom integration.""" +from dataclasses import dataclass +from typing import Any + +from pymystrom.bulb import MyStromBulb +from pymystrom.switch import MyStromSwitch + + +@dataclass +class MyStromData: + """Data class for mystrom device data.""" + + device: MyStromSwitch | MyStromBulb + info: dict[str, Any] diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json new file mode 100644 index 00000000000..259501e1486 --- /dev/null +++ b/homeassistant/components/mystrom/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The myStrom YAML configuration is being removed", + "description": "Configuring myStrom using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the myStrom YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 3d073693b1e..8e89bb5f151 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -5,17 +5,20 @@ import logging from typing import Any from pymystrom.exceptions import MyStromConnectionError -from pymystrom.switch import MyStromSwitch as _MyStromSwitch import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN, MANUFACTURER + DEFAULT_NAME = "myStrom Switch" _LOGGER = logging.getLogger(__name__) @@ -28,6 +31,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the myStrom entities.""" + device = hass.data[DOMAIN][entry.entry_id].device + async_add_entities([MyStromSwitch(device, entry.title)]) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -35,17 +46,20 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the myStrom switch/plug integration.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - - try: - plug = _MyStromSwitch(host) - await plug.get_state() - except MyStromConnectionError as err: - _LOGGER.error("No route to myStrom plug: %s", host) - raise PlatformNotReady() from err - - async_add_entities([MyStromSwitch(plug, name)]) + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class MyStromSwitch(SwitchEntity): @@ -56,6 +70,12 @@ class MyStromSwitch(SwitchEntity): self.plug = plug self._attr_name = name self._attr_unique_id = self.plug.mac + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.plug.mac)}, + name=name, + manufacturer=MANUFACTURER, + sw_version=self.plug.firmware, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index efb821a3b2b..7d98aacbe77 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -285,6 +285,7 @@ FLOWS = { "mutesync", "myq", "mysensors", + "mystrom", "nam", "nanoleaf", "neato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4306aa981fa..f1b8d0f1ca6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3556,7 +3556,7 @@ "mystrom": { "name": "myStrom", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "mythicbeastsdns": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9a104627a9..0dc53a8af4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1543,6 +1543,9 @@ python-matter-server==3.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 +# homeassistant.components.mystrom +python-mystrom==2.2.0 + # homeassistant.components.nest python-nest==4.2.0 diff --git a/tests/components/mystrom/__init__.py b/tests/components/mystrom/__init__.py new file mode 100644 index 00000000000..f0cc6224191 --- /dev/null +++ b/tests/components/mystrom/__init__.py @@ -0,0 +1 @@ +"""Tests for the myStrom integration.""" diff --git a/tests/components/mystrom/conftest.py b/tests/components/mystrom/conftest.py new file mode 100644 index 00000000000..04b8fc221ed --- /dev/null +++ b/tests/components/mystrom/conftest.py @@ -0,0 +1,37 @@ +"""Provide common mystrom fixtures and mocks.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mystrom.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DEVICE_NAME = "myStrom Device" +DEVICE_MAC = "6001940376EB" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mystrom.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create and add a config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DEVICE_MAC, + data={CONF_HOST: "1.1.1.1"}, + title=DEVICE_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mystrom/test_config_flow.py b/tests/components/mystrom/test_config_flow.py new file mode 100644 index 00000000000..97823681b8e --- /dev/null +++ b/tests/components/mystrom/test_config_flow.py @@ -0,0 +1,133 @@ +"""Test the myStrom config flow.""" +from unittest.mock import AsyncMock, patch + +from pymystrom.exceptions import MyStromConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.mystrom.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import DEVICE_MAC + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form_combined(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "pymystrom.get_device_info", + side_effect=AsyncMock(return_value={"type": 101, "mac": DEVICE_MAC}), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "myStrom Device" + assert result2["data"] == {"host": "1.1.1.1"} + + +async def test_form_duplicates( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test abort on duplicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "pymystrom.get_device_info", + return_value={"type": 101, "mac": DEVICE_MAC}, + ) as mock_session: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + mock_session.assert_called_once() + + +async def test_step_import(hass: HomeAssistant) -> None: + """Test the import step.""" + conf = { + CONF_HOST: "1.1.1.1", + } + with patch("pymystrom.switch.MyStromSwitch.get_state"), patch( + "pymystrom.get_device_info", + return_value={"type": 101, "mac": DEVICE_MAC}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "myStrom Device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + + +async def test_wong_answer_from_device(hass: HomeAssistant) -> None: + """Test handling of wrong answers from the device.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + with patch( + "pymystrom.get_device_info", + side_effect=MyStromConnectionError(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "pymystrom.get_device_info", + return_value={"type": 101, "mac": DEVICE_MAC}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "myStrom Device" + assert result2["data"] == {"host": "1.1.1.1"} diff --git a/tests/components/mystrom/test_init.py b/tests/components/mystrom/test_init.py new file mode 100644 index 00000000000..01b52d2cb94 --- /dev/null +++ b/tests/components/mystrom/test_init.py @@ -0,0 +1,132 @@ +"""Test the myStrom init.""" +from unittest.mock import AsyncMock, PropertyMock, patch + +from pymystrom.exceptions import MyStromConnectionError + +from homeassistant.components.mystrom.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import DEVICE_MAC + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_type: int, + bulb_type: str = "strip", +) -> None: + """Inititialize integration for testing.""" + with patch( + "pymystrom.get_device_info", + side_effect=AsyncMock(return_value={"type": device_type, "mac": DEVICE_MAC}), + ), patch("pymystrom.switch.MyStromSwitch.get_state", return_value={}), patch( + "pymystrom.bulb.MyStromBulb.get_state", return_value={} + ), patch( + "pymystrom.bulb.MyStromBulb.bulb_type", bulb_type + ), patch( + "pymystrom.switch.MyStromSwitch.mac", + new_callable=PropertyMock, + return_value=DEVICE_MAC, + ), patch( + "pymystrom.bulb.MyStromBulb.mac", + new_callable=PropertyMock, + return_value=DEVICE_MAC, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_init_switch_and_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test the initialization of a myStrom switch.""" + await init_integration(hass, config_entry, 101) + state = hass.states.get("switch.mystrom_device") + assert state is not None + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_init_bulb(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test the initialization of a myStrom bulb.""" + await init_integration(hass, config_entry, 102) + state = hass.states.get("light.mystrom_device") + assert state is not None + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_init_of_unknown_bulb( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test the initialization of a unknown myStrom bulb.""" + with patch( + "pymystrom.get_device_info", + side_effect=AsyncMock(return_value={"type": 102, "mac": DEVICE_MAC}), + ), patch("pymystrom.bulb.MyStromBulb.get_state", return_value={}), patch( + "pymystrom.bulb.MyStromBulb.bulb_type", "new_type" + ), patch( + "pymystrom.bulb.MyStromBulb.mac", + new_callable=PropertyMock, + return_value=DEVICE_MAC, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_init_of_unknown_device( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test the initialization of a unsupported myStrom device.""" + with patch( + "pymystrom.get_device_info", + side_effect=AsyncMock(return_value={"type": 103, "mac": DEVICE_MAC}), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_init_cannot_connect_because_of_device_info( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test error handling for failing get_device_info.""" + with patch( + "pymystrom.get_device_info", + side_effect=MyStromConnectionError(), + ), patch("pymystrom.switch.MyStromSwitch.get_state", return_value={}), patch( + "pymystrom.bulb.MyStromBulb.get_state", return_value={} + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_init_cannot_connect_because_of_get_state( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test error handling for failing get_state.""" + with patch( + "pymystrom.get_device_info", + side_effect=AsyncMock(return_value={"type": 101, "mac": DEVICE_MAC}), + ), patch( + "pymystrom.switch.MyStromSwitch.get_state", side_effect=MyStromConnectionError() + ), patch( + "pymystrom.bulb.MyStromBulb.get_state", side_effect=MyStromConnectionError() + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR From a1a20fab336521dce48d7e88f001aca7d9956b54 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 6 Jun 2023 16:00:59 +0200 Subject: [PATCH 106/857] Remove leftover issue warning in SimpliSafe (#94104) --- .../components/simplisafe/__init__.py | 40 ------------------- .../components/simplisafe/strings.json | 6 --- 2 files changed, 46 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 6a3523f07c9..17fc6f3cc4d 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -71,7 +71,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import ( async_register_admin_service, verify_domain_control, @@ -252,45 +251,6 @@ def _async_get_system_for_service_call( raise ValueError(f"No system for device ID: {device_id}") -@callback -def _async_log_deprecated_service_call( - hass: HomeAssistant, - call: ServiceCall, - alternate_service: str, - alternate_target: str, - breaks_in_ha_version: str, -) -> None: - """Log a warning about a deprecated service call.""" - deprecated_service = f"{call.domain}.{call.service}" - - async_create_issue( - hass, - DOMAIN, - f"deprecated_service_{deprecated_service}", - breaks_in_ha_version=breaks_in_ha_version, - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders={ - "alternate_service": alternate_service, - "alternate_target": alternate_target, - "deprecated_service": deprecated_service, - }, - ) - - LOGGER.warning( - ( - 'The "%s" service is deprecated and will be removed in %s; use the "%s" ' - 'service and pass it a target entity ID of "%s"' - ), - deprecated_service, - breaks_in_ha_version, - alternate_service, - alternate_target, - ) - - @callback def _async_register_base_station( hass: HomeAssistant, entry: ConfigEntry, system: SystemType diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index bc03ab4c514..618c21566f7 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -29,11 +29,5 @@ } } } - }, - "issues": { - "deprecated_service": { - "title": "The {deprecated_service} service is being removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`. Then, click SUBMIT below to mark this issue as resolved." - } } } From 05fbc09ef044769c0ddf85ed2ce6ebe37b1edca4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 6 Jun 2023 16:01:40 +0200 Subject: [PATCH 107/857] Remove Slack YAML configuration (#94106) --- homeassistant/components/slack/__init__.py | 15 +----- homeassistant/components/slack/config_flow.py | 13 ----- homeassistant/components/slack/notify.py | 34 ++++--------- tests/components/slack/test_config_flow.py | 48 ------------------- 4 files changed, 10 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index ee4935c7ead..076dcf7e590 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -7,8 +7,8 @@ from aiohttp.client_exceptions import ClientError from slack import WebClient from slack.errors import SlackApiError -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery @@ -36,17 +36,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Slack component.""" hass.data[DATA_HASS_CONFIG] = config - - # Iterate all entries for notify to only get Slack - if Platform.NOTIFY in config: - for entry in config[Platform.NOTIFY]: - if entry[CONF_PLATFORM] == DOMAIN: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - return True diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index 253750a2310..187cef057a0 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -55,19 +55,6 @@ class SlackFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - _LOGGER.warning( - "Configuration of the Slack integration in YAML is deprecated and " - "will be removed in a future release; Your existing configuration " - "has been imported into the UI automatically and can be safely removed " - "from your configuration.yaml file" - ) - entries = self._async_current_entries() - if any(x.data[CONF_API_KEY] == import_config[CONF_API_KEY] for x in entries): - return self.async_abort(reason="already_configured") - return await self.async_step_user(import_config) - async def _async_try_connect( self, token: str ) -> tuple[str, None] | tuple[None, dict[str, str]]: diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index d587f960704..498eddffa3d 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -17,16 +17,9 @@ from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, ATTR_TITLE, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import ( - ATTR_ICON, - CONF_API_KEY, - CONF_ICON, - CONF_PATH, - CONF_USERNAME, -) +from homeassistant.const import ATTR_ICON, CONF_PATH from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv, template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,16 +66,6 @@ DATA_SCHEMA = vol.All( cv.ensure_list, [vol.Any(DATA_FILE_SCHEMA, DATA_TEXT_ONLY_SCHEMA)] ) -# Deprecated in Home Assistant 2022.5 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_DEFAULT_CHANNEL): cv.string, - vol.Optional(CONF_ICON): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - } -) - class AuthDictT(TypedDict, total=False): """Type for auth request data.""" @@ -117,14 +100,13 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> SlackNotificationService | None: """Set up the Slack notification service.""" - if discovery_info is None: - return None - - return SlackNotificationService( - hass, - discovery_info[SLACK_DATA][DATA_CLIENT], - discovery_info, - ) + if discovery_info: + return SlackNotificationService( + hass, + discovery_info[SLACK_DATA][DATA_CLIENT], + discovery_info, + ) + return None @callback diff --git a/tests/components/slack/test_config_flow.py b/tests/components/slack/test_config_flow.py index 97c8e6ee743..e941e4ba47c 100644 --- a/tests/components/slack/test_config_flow.py +++ b/tests/components/slack/test_config_flow.py @@ -90,51 +90,3 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} - - -async def test_flow_import( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test an import flow.""" - mock_connection(aioclient_mock) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONF_DATA, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == TEAM_NAME - assert result["data"] == CONF_DATA - - -async def test_flow_import_no_name( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test import flow with no name in config.""" - mock_connection(aioclient_mock) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONF_INPUT, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == TEAM_NAME - assert result["data"] == CONF_DATA - - -async def test_flow_import_already_configured( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test an import flow already configured.""" - create_entry(hass) - mock_connection(aioclient_mock) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONF_DATA, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" From 0d4af6fb1e131e32cd96a949283403deb6689ab6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 6 Jun 2023 16:02:46 +0200 Subject: [PATCH 108/857] Stale doc string for mqtt light async_setup_entry (#94109) --- homeassistant/components/mqtt/light/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 5cd42ef1934..2c70490ac5e 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -69,7 +69,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT lights configured under the light platform key (deprecated).""" + """Set up MQTT lights through YAML and through MQTT discovery.""" setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) From 83b0ec5136a2fd8aae1b5f9d7ec9e0130873a5d3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 6 Jun 2023 16:18:25 +0200 Subject: [PATCH 109/857] Freeze time on `timer` tests that assert on remaining time (#94078) --- tests/components/timer/test_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 76d92db3702..eabc5e04e0b 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -265,6 +265,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: assert len(results) == expected_events +@pytest.mark.freeze_time("2023-06-05 17:47:50") async def test_start_service(hass: HomeAssistant) -> None: """Test the start/stop service.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -880,6 +881,7 @@ async def test_restore_idle(hass: HomeAssistant) -> None: assert entity.extra_state_attributes[ATTR_RESTORE] +@pytest.mark.freeze_time("2023-06-05 17:47:50") async def test_restore_paused(hass: HomeAssistant) -> None: """Test entity restore logic when timer is paused.""" utc_now = utcnow() @@ -917,6 +919,7 @@ async def test_restore_paused(hass: HomeAssistant) -> None: assert entity.extra_state_attributes[ATTR_RESTORE] +@pytest.mark.freeze_time("2023-06-05 17:47:50") async def test_restore_active_resume(hass: HomeAssistant) -> None: """Test entity restore logic when timer is active and end time is after startup.""" events = async_capture_events(hass, EVENT_TIMER_RESTARTED) From b03c429db72d12d39a7a31981280d664e8c61241 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 6 Jun 2023 11:44:29 -0400 Subject: [PATCH 110/857] Revert "Increase Zigbee command retries (#93877)" (#94123) --- .../zha/core/cluster_handlers/__init__.py | 4 - .../zha/test_alarm_control_panel.py | 5 - tests/components/zha/test_device_action.py | 4 +- tests/components/zha/test_discover.py | 2 +- tests/components/zha/test_light.py | 98 ++++++++----------- tests/components/zha/test_switch.py | 4 +- 6 files changed, 46 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index ec29e4e53eb..7863b043455 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -45,8 +45,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -DEFAULT_REQUEST_RETRIES = 3 - class AttrReportConfig(TypedDict, total=True): """Configuration to report for the attributes.""" @@ -80,8 +78,6 @@ def decorate_command(cluster_handler, command): @wraps(command) async def wrapper(*args, **kwds): - kwds.setdefault("tries", DEFAULT_REQUEST_RETRIES) - try: result = await command(*args, **kwds) cluster_handler.debug( diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index 34ce746e128..319301cf7dc 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -96,7 +96,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) # disarm from HA @@ -135,7 +134,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.Emergency, - tries=3, ) # reset the panel @@ -159,7 +157,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) # arm_night from HA @@ -180,7 +177,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) # reset the panel @@ -278,6 +274,5 @@ async def reset_alarm_panel(hass, cluster, entity_id): 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) cluster.client_command.reset_mock() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 9d9a4bc2a54..f1ab44f69eb 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -328,7 +328,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=3, + tries=1, tsn=None, ) in cluster.request.call_args_list @@ -345,7 +345,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=3, + tries=1, tsn=None, ) in cluster.request.call_args_list diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index a87d624ec00..236a3c4ad86 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -131,7 +131,7 @@ async def test_devices( ), expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) ] diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 5ea71573a27..c4751f7e7f6 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -553,7 +553,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -589,7 +589,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -600,7 +600,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -637,7 +637,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -674,7 +674,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -685,7 +685,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -696,7 +696,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -758,7 +758,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -769,7 +769,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -780,7 +780,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -838,7 +838,7 @@ async def test_transitions( dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -850,7 +850,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -910,7 +910,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -968,7 +968,7 @@ async def test_transitions( transition_time=1, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev2_cluster_color.request.call_args == call( @@ -979,7 +979,7 @@ async def test_transitions( transition_time=1, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev2_cluster_level.request.call_args_list[1] == call( @@ -990,7 +990,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1121,7 +1121,7 @@ async def test_transitions( transition_time=20, # transition time expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1151,7 +1151,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1184,7 +1184,7 @@ async def test_transitions( eWeLink_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1195,7 +1195,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1261,7 +1261,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1272,7 +1272,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1319,7 +1319,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1330,7 +1330,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -1341,7 +1341,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1373,9 +1373,7 @@ async def async_test_on_from_light(hass, cluster, entity_id): assert hass.states.get(entity_id).state == STATE_ON -async def async_test_on_off_from_hass( - hass, cluster, entity_id, expected_tries: int = 3 -): +async def async_test_on_off_from_hass(hass, cluster, entity_id): """Test on off functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1390,16 +1388,14 @@ async def async_test_on_off_from_hass( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) - await async_test_off_from_hass( - hass, cluster, entity_id, expected_tries=expected_tries - ) + await async_test_off_from_hass(hass, cluster, entity_id) -async def async_test_off_from_hass(hass, cluster, entity_id, expected_tries: int = 3): +async def async_test_off_from_hass(hass, cluster, entity_id): """Test turning off the light from Home Assistant.""" # turn off via UI @@ -1415,18 +1411,13 @@ async def async_test_off_from_hass(hass, cluster, entity_id, expected_tries: int cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) async def async_test_level_on_off_from_hass( - hass, - on_off_cluster, - level_cluster, - entity_id, - expected_default_transition: int = 0, - expected_tries: int = 3, + hass, on_off_cluster, level_cluster, entity_id, expected_default_transition: int = 0 ): """Test on off functionality from hass.""" @@ -1448,7 +1439,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1472,7 +1463,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) assert level_cluster.request.call_args == call( @@ -1483,7 +1474,7 @@ async def async_test_level_on_off_from_hass( transition_time=100, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1508,15 +1499,13 @@ async def async_test_level_on_off_from_hass( transition_time=int(expected_default_transition), expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() - await async_test_off_from_hass( - hass, on_off_cluster, entity_id, expected_tries=expected_tries - ) + await async_test_off_from_hass(hass, on_off_cluster, entity_id) async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected_state): @@ -1533,9 +1522,7 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected assert hass.states.get(entity_id).attributes.get("brightness") == level -async def async_test_flash_from_hass( - hass, cluster, entity_id, flash, expected_tries: int = 3 -): +async def async_test_flash_from_hass(hass, cluster, entity_id, flash): """Test flash functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1555,7 +1542,7 @@ async def async_test_flash_from_hass( effect_variant=general.Identify.EffectVariant.Default, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) @@ -1655,15 +1642,13 @@ async def test_zha_group_light_entity( assert "color_mode" not in group_state.attributes # test turning the lights on and off from the HA - await async_test_on_off_from_hass( - hass, group_cluster_on_off, group_entity_id, expected_tries=1 - ) + await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) await async_shift_time(hass) # test short flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_SHORT, expected_tries=1 + hass, group_cluster_identify, group_entity_id, FLASH_SHORT ) await async_shift_time(hass) @@ -1678,7 +1663,6 @@ async def test_zha_group_light_entity( group_cluster_level, group_entity_id, expected_default_transition=1, # a Sengled light is in that group and needs a minimum 0.1s transition - expected_tries=1, ) await async_shift_time(hass) @@ -1699,7 +1683,7 @@ async def test_zha_group_light_entity( # test long flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_LONG, expected_tries=1 + hass, group_cluster_identify, group_entity_id, FLASH_LONG ) await async_shift_time(hass) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 8fb7825a953..9f98acb9359 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -176,7 +176,7 @@ async def test_switch( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -196,7 +196,7 @@ async def test_switch( cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) From 5ab4bf218ee6d025eb20fc4937db76a3ecd037ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roy?= Date: Tue, 6 Jun 2023 13:42:49 -0400 Subject: [PATCH 111/857] Bump aiobafi6 to 0.8.2 (#94125) --- homeassistant/components/baf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index b5b5b76967e..37fd5cee7c6 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.8.0"], + "requirements": ["aiobafi6==0.8.2"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 108462817c6..6fd8f259923 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.0 +aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0dc53a8af4d..17ae48d7355 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -187,7 +187,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.0 +aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 From d8ceb6463e8c3446d6d27ba4e8e1cfb26e729cf9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 6 Jun 2023 19:44:00 +0200 Subject: [PATCH 112/857] Add new integration Discovergy (#54280) * Add discovergy integration * Capitalize measurement type as it is in uppercase * Some logging and typing * Add all-time total production power and check if meter has value before adding it * Add tests for Discovergy and changing therefor library import * Disable phase-specific sensor per default, set user_input as default for schema and implement some other suggestions form code review * Removing translation, fixing import and some more review implementation * Fixing CI issues * Check if acces token keys are in dict the correct way * Implement suggestions after code review * Correcting property function * Change state class to STATE_CLASS_TOTAL_INCREASING * Add reauth workflow for Discovergy * Bump pydiscovergy * Implement code review * Remove _meter from __init__ * Bump pydiscovergy & minor changes * Add gas meter support * bump pydiscovergy & error handling * Add myself to CODEOWNERS for test directory * Resorting CODEOWNERS * Implement diagnostics and reduce API use * Make homeassistant imports absolute * Exclude diagnostics.py from coverage report * Add sensors with different keys * Reformatting files * Use new naming style * Refactoring and moving to basic auth for API authentication * Remove device name form entity name * Add integration type to discovergy and implement new unit of measurement * Add system health to discovergy integration * Use right array key when using an alternative_key & using UnitOfElectricPotential.VOLT * Add options for precision and update interval to Discovergy * Remove precision config option and let it handle HA * Rename precision attribute and remove translation file * Some formatting tweaks * Some more tests * Move sensor names to strings.json * Redacting title and unique_id as it contains user email address --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/discovergy/__init__.py | 84 ++++++ .../components/discovergy/config_flow.py | 167 +++++++++++ homeassistant/components/discovergy/const.py | 8 + .../components/discovergy/diagnostics.py | 50 ++++ .../components/discovergy/manifest.json | 10 + homeassistant/components/discovergy/sensor.py | 274 ++++++++++++++++++ .../components/discovergy/strings.json | 75 +++++ .../components/discovergy/system_health.py | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/discovergy/__init__.py | 75 +++++ tests/components/discovergy/conftest.py | 14 + .../components/discovergy/test_config_flow.py | 145 +++++++++ .../components/discovergy/test_diagnostics.py | 78 +++++ .../discovergy/test_system_health.py | 48 +++ 19 files changed, 1067 insertions(+) create mode 100644 homeassistant/components/discovergy/__init__.py create mode 100644 homeassistant/components/discovergy/config_flow.py create mode 100644 homeassistant/components/discovergy/const.py create mode 100644 homeassistant/components/discovergy/diagnostics.py create mode 100644 homeassistant/components/discovergy/manifest.json create mode 100644 homeassistant/components/discovergy/sensor.py create mode 100644 homeassistant/components/discovergy/strings.json create mode 100644 homeassistant/components/discovergy/system_health.py create mode 100644 tests/components/discovergy/__init__.py create mode 100644 tests/components/discovergy/conftest.py create mode 100644 tests/components/discovergy/test_config_flow.py create mode 100644 tests/components/discovergy/test_diagnostics.py create mode 100644 tests/components/discovergy/test_system_health.py diff --git a/.coveragerc b/.coveragerc index e653b35b62d..445be38ab05 100644 --- a/.coveragerc +++ b/.coveragerc @@ -202,6 +202,8 @@ omit = homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py homeassistant/components/discord/notify.py + homeassistant/components/discovergy/__init__.py + homeassistant/components/discovergy/sensor.py homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/data.py diff --git a/CODEOWNERS b/CODEOWNERS index 669294af12f..63833d6c1fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -275,6 +275,8 @@ build.json @home-assistant/supervisor /homeassistant/components/discogs/ @thibmaek /homeassistant/components/discord/ @tkdrob /tests/components/discord/ @tkdrob +/homeassistant/components/discovergy/ @jpbede +/tests/components/discovergy/ @jpbede /homeassistant/components/discovery/ @home-assistant/core /tests/components/discovery/ @home-assistant/core /homeassistant/components/dlink/ @tkdrob diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py new file mode 100644 index 00000000000..d36100f611d --- /dev/null +++ b/homeassistant/components/discovergy/__init__.py @@ -0,0 +1,84 @@ +"""The Discovergy integration.""" +from __future__ import annotations + +from dataclasses import dataclass, field +import logging + +import pydiscovergy +from pydiscovergy.authentication import BasicAuth +import pydiscovergy.error as discovergyError +from pydiscovergy.models import Meter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import APP_NAME, DOMAIN + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DiscovergyData: + """Discovergy data class to share meters and api client.""" + + api_client: pydiscovergy.Discovergy = field(default_factory=lambda: None) + meters: list[Meter] = field(default_factory=lambda: []) + coordinators: dict[str, DataUpdateCoordinator] = field(default_factory=lambda: {}) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Discovergy from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # init discovergy data class + discovergy_data = DiscovergyData( + api_client=pydiscovergy.Discovergy( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + app_name=APP_NAME, + httpx_client=get_async_client(hass), + authentication=BasicAuth(), + ), + meters=[], + coordinators={}, + ) + + try: + # try to get meters from api to check if access token is still valid and later use + # if no exception is raised everything is fine to go + discovergy_data.meters = await discovergy_data.api_client.get_meters() + except discovergyError.InvalidLogin as err: + _LOGGER.debug("Invalid email or password: %s", err) + raise ConfigEntryAuthFailed("Invalid email or password") from err + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected error while communicating with API: %s", err) + raise ConfigEntryNotReady( + "Unexpected error while communicating with API" + ) from err + + hass.data[DOMAIN][entry.entry_id] = discovergy_data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py new file mode 100644 index 00000000000..1f685f3e23a --- /dev/null +++ b/homeassistant/components/discovergy/config_flow.py @@ -0,0 +1,167 @@ +"""Config flow for Discovergy integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import pydiscovergy +from pydiscovergy.authentication import BasicAuth +import pydiscovergy.error as discovergyError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client + +from .const import ( + APP_NAME, + CONF_TIME_BETWEEN_UPDATE, + DEFAULT_TIME_BETWEEN_UPDATE, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def make_schema(email: str = "", password: str = "") -> vol.Schema: + """Create schema for config flow.""" + return vol.Schema( + { + vol.Required( + CONF_EMAIL, + default=email, + ): str, + vol.Required( + CONF_PASSWORD, + default=password, + ): str, + } + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Discovergy.""" + + VERSION = 1 + + existing_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=make_schema(), + ) + + return await self._validate_and_save(user_input) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle the initial step.""" + self.existing_entry = await self.async_set_unique_id(self.context["unique_id"]) + + if entry_data is None: + return self.async_show_form( + step_id="reauth", + data_schema=make_schema( + self.existing_entry.data[CONF_EMAIL] or "", + self.existing_entry.data[CONF_PASSWORD] or "", + ), + ) + + return await self._validate_and_save(dict(entry_data), step_id="reauth") + + async def _validate_and_save( + self, user_input: dict[str, Any] | None = None, step_id: str = "user" + ) -> FlowResult: + """Validate user input and create config entry.""" + errors = {} + + if user_input: + try: + await pydiscovergy.Discovergy( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + app_name=APP_NAME, + httpx_client=get_async_client(self.hass), + authentication=BasicAuth(), + ).get_meters() + + result = {"title": user_input[CONF_EMAIL], "data": user_input} + except discovergyError.HTTPError: + errors["base"] = "cannot_connect" + except discovergyError.InvalidLogin: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.existing_entry: + self.hass.config_entries.async_update_entry( + self.existing_entry, + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + await self.hass.config_entries.async_reload( + self.existing_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + + # set unique id to title which is the account email + await self.async_set_unique_id(result["title"].lower()) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=result["title"], data=result["data"] + ) + + return self.async_show_form( + step_id=step_id, + data_schema=make_schema(), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return DiscovergyOptionsFlowHandler(config_entry) + + +class DiscovergyOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Discovergy options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_TIME_BETWEEN_UPDATE, + default=self.config_entry.options.get( + CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE + ), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ), + ) diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py new file mode 100644 index 00000000000..31d834156d4 --- /dev/null +++ b/homeassistant/components/discovergy/const.py @@ -0,0 +1,8 @@ +"""Constants for the Discovergy integration.""" +from __future__ import annotations + +DOMAIN = "discovergy" +MANUFACTURER = "Discovergy" +APP_NAME = "homeassistant" +CONF_TIME_BETWEEN_UPDATE = "time_between_update" +DEFAULT_TIME_BETWEEN_UPDATE = 30 diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py new file mode 100644 index 00000000000..a6ce3aea40a --- /dev/null +++ b/homeassistant/components/discovergy/diagnostics.py @@ -0,0 +1,50 @@ +"""Diagnostics support for discovergy.""" +from __future__ import annotations + +from typing import Any + +from pydiscovergy.models import Meter + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import DiscovergyData +from .const import DOMAIN + +TO_REDACT_CONFIG_ENTRY = {CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, "title"} + +TO_REDACT_METER = { + "serial_number", + "full_serial_number", + "location", + "fullSerialNumber", + "printedFullSerialNumber", + "administrationNumber", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + flattened_meter: list[dict] = [] + last_readings: dict[str, dict] = {} + data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + meters: list[Meter] = data.meters # always returns a list + + for meter in meters: + # make a dict of meter data and redact some data + flattened_meter.append(async_redact_data(meter.__dict__, TO_REDACT_METER)) + + # get last reading for meter and make a dict of it + coordinator: DataUpdateCoordinator = data.coordinators[meter.get_meter_id()] + last_readings[meter.get_meter_id()] = coordinator.data.__dict__ + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY), + "meters": flattened_meter, + "readings": last_readings, + } diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json new file mode 100644 index 00000000000..c929386e8e8 --- /dev/null +++ b/homeassistant/components/discovergy/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "discovergy", + "name": "Discovergy", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/discovergy", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["pydiscovergy==1.2.1"] +} diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py new file mode 100644 index 00000000000..3084e578525 --- /dev/null +++ b/homeassistant/components/discovergy/sensor.py @@ -0,0 +1,274 @@ +"""Discovergy sensor entity.""" +from dataclasses import dataclass, field +from datetime import timedelta +import logging + +from pydiscovergy import Discovergy +from pydiscovergy.error import AccessTokenExpired, HTTPError +from pydiscovergy.models import Meter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from . import DiscovergyData +from .const import ( + CONF_TIME_BETWEEN_UPDATE, + DEFAULT_TIME_BETWEEN_UPDATE, + DOMAIN, + MANUFACTURER, +) + +PARALLEL_UPDATES = 1 +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DiscovergyMixin: + """Mixin for alternative keys.""" + + alternative_keys: list = field(default_factory=lambda: []) + scale: int = field(default_factory=lambda: 1000) + + +@dataclass +class DiscovergySensorEntityDescription(DiscovergyMixin, SensorEntityDescription): + """Define Sensor entity description class.""" + + +GAS_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + DiscovergySensorEntityDescription( + key="volume", + translation_key="total_gas_consumption", + suggested_display_precision=4, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + +ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + # power sensors + DiscovergySensorEntityDescription( + key="power", + translation_key="total_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + DiscovergySensorEntityDescription( + key="power1", + translation_key="phase_1_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase1Power"], + ), + DiscovergySensorEntityDescription( + key="power2", + translation_key="phase_2_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase2Power"], + ), + DiscovergySensorEntityDescription( + key="power3", + translation_key="phase_3_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase3Power"], + ), + # voltage sensors + DiscovergySensorEntityDescription( + key="phase1Voltage", + translation_key="phase_1_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + DiscovergySensorEntityDescription( + key="phase2Voltage", + translation_key="phase_2_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + DiscovergySensorEntityDescription( + key="phase3Voltage", + translation_key="phase_3_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + # energy sensors + DiscovergySensorEntityDescription( + key="energy", + translation_key="total_consumption", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=4, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + scale=10000000000, + ), + DiscovergySensorEntityDescription( + key="energyOut", + translation_key="total_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=4, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + scale=10000000000, + ), +) + + +def get_coordinator_for_meter( + hass: HomeAssistant, + meter: Meter, + discovergy_instance: Discovergy, + update_interval: timedelta, +) -> DataUpdateCoordinator: + """Create a new DataUpdateCoordinator for given meter.""" + + async def async_update_data(): + """Fetch data from API endpoint.""" + try: + return await discovergy_instance.get_last_reading(meter.get_meter_id()) + except AccessTokenExpired as err: + raise ConfigEntryAuthFailed( + "Got token expired while communicating with API" + ) from err + except HTTPError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + except Exception as err: # pylint: disable=broad-except + raise UpdateFailed( + f"Unexpected error while communicating with API: {err}" + ) from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="sensor", + update_method=async_update_data, + update_interval=update_interval, + ) + return coordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Discovergy sensors.""" + data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + discovergy_instance: Discovergy = data.api_client + meters: list[Meter] = data.meters # always returns a list + + min_time_between_updates = timedelta( + seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) + ) + + entities = [] + for meter in meters: + # Get coordinator for meter, set config entry and fetch initial data + # so we have data when entities are added + coordinator = get_coordinator_for_meter( + hass, meter, discovergy_instance, min_time_between_updates + ) + coordinator.config_entry = entry + await coordinator.async_config_entry_first_refresh() + + # add coordinator to data for diagnostics + data.coordinators[meter.get_meter_id()] = coordinator + + sensors = None + if meter.measurement_type == "ELECTRICITY": + sensors = ELECTRICITY_SENSORS + elif meter.measurement_type == "GAS": + sensors = GAS_SENSORS + + if sensors is not None: + for description in sensors: + keys = [description.key] + description.alternative_keys + + # check if this meter has this data, then add this sensor + for key in keys: + if key in coordinator.data.values: + entities.append( + DiscovergySensor(key, description, meter, coordinator) + ) + + async_add_entities(entities, False) + + +class DiscovergySensor(CoordinatorEntity, SensorEntity): + """Represents a discovergy smart meter sensor.""" + + entity_description: DiscovergySensorEntityDescription + data_key: str + _attr_has_entity_name = True + + def __init__( + self, + data_key: str, + description: DiscovergySensorEntityDescription, + meter: Meter, + coordinator: DataUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.data_key = data_key + + self.entity_description = description + self._attr_unique_id = f"{meter.full_serial_number}-{description.key}" + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, meter.get_meter_id())}, + ATTR_NAME: f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", + ATTR_MODEL: f"{meter.type} {meter.full_serial_number}", + ATTR_MANUFACTURER: MANUFACTURER, + } + + @property + def native_value(self) -> StateType: + """Return the sensor state.""" + return float( + self.coordinator.data.values[self.data_key] / self.entity_description.scale + ) diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json new file mode 100644 index 00000000000..11d6b74a822 --- /dev/null +++ b/homeassistant/components/discovergy/strings.json @@ -0,0 +1,75 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "Discovergy API endpoint reachable" + } + }, + "options": { + "step": { + "init": { + "data": { + "time_between_update": "Minimum time between entity updates [s]" + } + } + } + }, + "entity": { + "sensor": { + "total_gas_consumption": { + "name": "Total gas consumption" + }, + "total_power": { + "name": "Total power" + }, + "total_consumption": { + "name": "Total consumption" + }, + "total_production": { + "name": "Total production" + }, + "phase_1_voltage": { + "name": "Phase 1 voltage" + }, + "phase_2_voltage": { + "name": "Phase 2 voltage" + }, + "phase_3_voltage": { + "name": "Phase 3 voltage" + }, + "phase_1_power": { + "name": "Phase 1 power" + }, + "phase_2_power": { + "name": "Phase 2 power" + }, + "phase_3_power": { + "name": "Phase 3 power" + } + } + } +} diff --git a/homeassistant/components/discovergy/system_health.py b/homeassistant/components/discovergy/system_health.py new file mode 100644 index 00000000000..2baeb0e5f6e --- /dev/null +++ b/homeassistant/components/discovergy/system_health.py @@ -0,0 +1,22 @@ +"""Provide info to system health.""" +from pydiscovergy.const import API_BASE + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info) + + +async def system_health_info(hass): + """Get info for the info page.""" + return { + "api_endpoint_reachable": system_health.async_check_can_reach_url( + hass, API_BASE + ) + } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7d98aacbe77..f938bdfd8d1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -95,6 +95,7 @@ FLOWS = { "dialogflow", "directv", "discord", + "discovergy", "dlink", "dlna_dmr", "dlna_dms", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f1b8d0f1ca6..b9c2ac57553 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1091,6 +1091,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "discovergy": { + "name": "Discovergy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "dlib_face_detect": { "name": "Dlib Face Detect", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6fd8f259923..b32104a5826 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1633,6 +1633,9 @@ pydelijn==1.0.0 # homeassistant.components.dexcom pydexcom==0.2.3 +# homeassistant.components.discovergy +pydiscovergy==1.2.1 + # homeassistant.components.doods pydoods==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17ae48d7355..dadad78574d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1203,6 +1203,9 @@ pydeconz==113 # homeassistant.components.dexcom pydexcom==0.2.3 +# homeassistant.components.discovergy +pydiscovergy==1.2.1 + # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/tests/components/discovergy/__init__.py b/tests/components/discovergy/__init__.py new file mode 100644 index 00000000000..f721b5842c9 --- /dev/null +++ b/tests/components/discovergy/__init__.py @@ -0,0 +1,75 @@ +"""Tests for the Discovergy integration.""" +import datetime +from unittest.mock import patch + +from pydiscovergy.models import Meter, Reading + +from homeassistant.components.discovergy import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +GET_METERS = [ + Meter( + meterId="f8d610b7a8cc4e73939fa33b990ded54", + serialNumber="abc123", + fullSerialNumber="abc123", + type="TST", + measurementType="ELECTRICITY", + loadProfileType="SLP", + location={ + "city": "Testhause", + "street": "Teststraße", + "streetNumber": "1", + "country": "Germany", + }, + manufacturerId="TST", + printedFullSerialNumber="abc123", + administrationNumber="12345", + scalingFactor=1, + currentScalingFactor=1, + voltageScalingFactor=1, + internalMeters=1, + firstMeasurementTime=1517569090926, + lastMeasurementTime=1678430543742, + ), +] + +LAST_READING = Reading( + time=datetime.datetime(2023, 3, 10, 7, 32, 6, 702000), + values={ + "energy": 119348699715000.0, + "energy1": 2254180000.0, + "energy2": 119346445534000.0, + "energyOut": 55048723044000.0, + "energyOut1": 0.0, + "energyOut2": 0.0, + "power": 531750.0, + "power1": 142680.0, + "power2": 138010.0, + "power3": 251060.0, + "voltage1": 239800.0, + "voltage2": 239700.0, + "voltage3": 239000.0, + }, +) + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Discovergy integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="user@example.org", + unique_id="user@example.org", + data={CONF_EMAIL: "user@example.org", CONF_PASSWORD: "supersecretpassword"}, + ) + + with patch("pydiscovergy.Discovergy.get_meters", return_value=GET_METERS), patch( + "pydiscovergy.Discovergy.get_last_reading", return_value=LAST_READING + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py new file mode 100644 index 00000000000..40bd0bb8aa1 --- /dev/null +++ b/tests/components/discovergy/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures for Discovergy integration tests.""" +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from tests.components.discovergy import GET_METERS + + +@pytest.fixture +def mock_meters() -> Mock: + """Patch libraries.""" + with patch("pydiscovergy.Discovergy.get_meters") as discovergy: + discovergy.side_effect = AsyncMock(return_value=GET_METERS) + yield discovergy diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py new file mode 100644 index 00000000000..312828a7997 --- /dev/null +++ b/tests/components/discovergy/test_config_flow.py @@ -0,0 +1,145 @@ +"""Test the Discovergy config flow.""" +from unittest.mock import patch + +from pydiscovergy.error import HTTPError, InvalidLogin + +from homeassistant import data_entry_flow, setup +from homeassistant.components.discovergy.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.components.discovergy import init_integration + + +async def test_form(hass: HomeAssistant, mock_meters) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.discovergy.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == "test@example.com" + assert result2["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant, mock_meters) -> None: + """Test reauth flow.""" + entry = await init_integration(hass) + + init_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "unique_id": entry.unique_id}, + data=None, + ) + + assert init_result["type"] == data_entry_flow.FlowResultType.FORM + assert init_result["step_id"] == "reauth" + + configure_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert configure_result["type"] == data_entry_flow.FlowResultType.ABORT + assert configure_result["reason"] == "reauth_successful" + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "pydiscovergy.Discovergy.get_meters", + side_effect=InvalidLogin, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pydiscovergy.Discovergy.get_meters", side_effect=HTTPError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pydiscovergy.Discovergy.get_meters", side_effect=Exception): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_options_flow_init(hass: HomeAssistant) -> None: + """Test the options flow.""" + entry = await init_integration(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + create_result = await hass.config_entries.options.async_configure( + result["flow_id"], {"time_between_update": 2} + ) + + assert create_result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert create_result["data"] == {"time_between_update": 2} diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py new file mode 100644 index 00000000000..7d7b3508f95 --- /dev/null +++ b/tests/components/discovergy/test_diagnostics.py @@ -0,0 +1,78 @@ +"""Test Discovergy diagnostics.""" +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test config entry diagnostics.""" + entry = await init_integration(hass) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["entry"] == { + "entry_id": entry.entry_id, + "version": 1, + "domain": "discovergy", + "title": REDACTED, + "data": {"email": REDACTED, "password": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + } + + assert result["meters"] == [ + { + "additional": { + "administrationNumber": REDACTED, + "currentScalingFactor": 1, + "firstMeasurementTime": 1517569090926, + "fullSerialNumber": REDACTED, + "internalMeters": 1, + "lastMeasurementTime": 1678430543742, + "loadProfileType": "SLP", + "manufacturerId": "TST", + "printedFullSerialNumber": REDACTED, + "scalingFactor": 1, + "type": "TST", + "voltageScalingFactor": 1, + }, + "full_serial_number": REDACTED, + "load_profile_type": "SLP", + "location": REDACTED, + "measurement_type": "ELECTRICITY", + "meter_id": "f8d610b7a8cc4e73939fa33b990ded54", + "serial_number": REDACTED, + "type": "TST", + } + ] + + assert result["readings"] == { + "f8d610b7a8cc4e73939fa33b990ded54": { + "time": "2023-03-10T07:32:06.702000", + "values": { + "energy": 119348699715000.0, + "energy1": 2254180000.0, + "energy2": 119346445534000.0, + "energyOut": 55048723044000.0, + "energyOut1": 0.0, + "energyOut2": 0.0, + "power": 531750.0, + "power1": 142680.0, + "power2": 138010.0, + "power3": 251060.0, + "voltage1": 239800.0, + "voltage2": 239700.0, + "voltage3": 239000.0, + }, + } + } diff --git a/tests/components/discovergy/test_system_health.py b/tests/components/discovergy/test_system_health.py new file mode 100644 index 00000000000..91025b06dd7 --- /dev/null +++ b/tests/components/discovergy/test_system_health.py @@ -0,0 +1,48 @@ +"""Test Discovergy system health.""" +import asyncio + +from aiohttp import ClientError +from pydiscovergy.const import API_BASE + +from homeassistant.components.discovergy.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import get_system_health_info +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_discovergy_system_health( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test Discovergy system health.""" + aioclient_mock.get(API_BASE, text="") + hass.config.components.add(DOMAIN) + assert await async_setup_component(hass, "system_health", {}) + + info = await get_system_health_info(hass, DOMAIN) + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == {"api_endpoint_reachable": "ok"} + + +async def test_discovergy_system_health_fail( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test Discovergy system health.""" + aioclient_mock.get(API_BASE, exc=ClientError) + hass.config.components.add(DOMAIN) + assert await async_setup_component(hass, "system_health", {}) + + info = await get_system_health_info(hass, DOMAIN) + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == { + "api_endpoint_reachable": {"type": "failed", "error": "unreachable"} + } From deaf910a24e4cc0ea7da48f7e30c7277f7496180 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 6 Jun 2023 23:03:59 +0200 Subject: [PATCH 113/857] Correct zha device classes for voc and pm25 (#94130) Correct zha device classes --- homeassistant/components/zha/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index da1f1f6c04c..918458a32ad 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -722,7 +722,9 @@ class PPBVOCLevel(Sensor): """VOC Level sensor.""" SENSOR_ATTR = "measured_value" - _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + _attr_device_class: SensorDeviceClass = ( + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS + ) _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "VOC level" _decimals = 0 @@ -736,6 +738,7 @@ class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" SENSOR_ATTR = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "Particulate matter" _decimals = 0 From 4c80420d2d278dbd62b17ce4e7fc5a1a5e2cf093 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 7 Jun 2023 01:47:13 +0200 Subject: [PATCH 114/857] Improve discovergy generic typing (#94131) --- homeassistant/components/discovergy/__init__.py | 6 ++++-- homeassistant/components/discovergy/diagnostics.py | 3 +-- homeassistant/components/discovergy/sensor.py | 14 +++++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index d36100f611d..23687383dd9 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -7,7 +7,7 @@ import logging import pydiscovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError -from pydiscovergy.models import Meter +from pydiscovergy.models import Meter, Reading from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform @@ -29,7 +29,9 @@ class DiscovergyData: api_client: pydiscovergy.Discovergy = field(default_factory=lambda: None) meters: list[Meter] = field(default_factory=lambda: []) - coordinators: dict[str, DataUpdateCoordinator] = field(default_factory=lambda: {}) + coordinators: dict[str, DataUpdateCoordinator[Reading]] = field( + default_factory=lambda: {} + ) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index a6ce3aea40a..02d5585c1dc 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -9,7 +9,6 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import DiscovergyData from .const import DOMAIN @@ -40,7 +39,7 @@ async def async_get_config_entry_diagnostics( flattened_meter.append(async_redact_data(meter.__dict__, TO_REDACT_METER)) # get last reading for meter and make a dict of it - coordinator: DataUpdateCoordinator = data.coordinators[meter.get_meter_id()] + coordinator = data.coordinators[meter.get_meter_id()] last_readings[meter.get_meter_id()] = coordinator.data.__dict__ return { diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 3084e578525..d659ec8a106 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -5,7 +5,7 @@ import logging from pydiscovergy import Discovergy from pydiscovergy.error import AccessTokenExpired, HTTPError -from pydiscovergy.models import Meter +from pydiscovergy.models import Meter, Reading from homeassistant.components.sensor import ( SensorDeviceClass, @@ -50,7 +50,7 @@ _LOGGER = logging.getLogger(__name__) class DiscovergyMixin: """Mixin for alternative keys.""" - alternative_keys: list = field(default_factory=lambda: []) + alternative_keys: list[str] = field(default_factory=lambda: []) scale: int = field(default_factory=lambda: 1000) @@ -165,10 +165,10 @@ def get_coordinator_for_meter( meter: Meter, discovergy_instance: Discovergy, update_interval: timedelta, -) -> DataUpdateCoordinator: +) -> DataUpdateCoordinator[Reading]: """Create a new DataUpdateCoordinator for given meter.""" - async def async_update_data(): + async def async_update_data() -> Reading: """Fetch data from API endpoint.""" try: return await discovergy_instance.get_last_reading(meter.get_meter_id()) @@ -205,7 +205,7 @@ async def async_setup_entry( seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) ) - entities = [] + entities: list[DiscovergySensor] = [] for meter in meters: # Get coordinator for meter, set config entry and fetch initial data # so we have data when entities are added @@ -238,7 +238,7 @@ async def async_setup_entry( async_add_entities(entities, False) -class DiscovergySensor(CoordinatorEntity, SensorEntity): +class DiscovergySensor(CoordinatorEntity[DataUpdateCoordinator[Reading]], SensorEntity): """Represents a discovergy smart meter sensor.""" entity_description: DiscovergySensorEntityDescription @@ -250,7 +250,7 @@ class DiscovergySensor(CoordinatorEntity, SensorEntity): data_key: str, description: DiscovergySensorEntityDescription, meter: Meter, - coordinator: DataUpdateCoordinator, + coordinator: DataUpdateCoordinator[Reading], ) -> None: """Initialize the sensor.""" super().__init__(coordinator) From 66785bbfc7caf25f2dbd43eeb8bd0fd4229f27f2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jun 2023 02:04:22 +0200 Subject: [PATCH 115/857] Update frontend to 20230606.0 (#94119) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b82ece33315..47bdf60b8e7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230605.0"] + "requirements": ["home-assistant-frontend==20230606.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e9b4359c937..fad590e34d4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230605.0 +home-assistant-frontend==20230606.0 home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b32104a5826..0e2fefead5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -987,7 +987,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230605.0 +home-assistant-frontend==20230606.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dadad78574d..962ba12a3d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -767,7 +767,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230605.0 +home-assistant-frontend==20230606.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 From a6bb70c5d2c7ab324d1e96f10c613fdf3d94153c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 7 Jun 2023 03:04:53 +0300 Subject: [PATCH 116/857] Remove goalfeed integration (#94129) --- .coveragerc | 1 - homeassistant/components/goalfeed/__init__.py | 65 ------------------- .../components/goalfeed/manifest.json | 9 --- homeassistant/generated/integrations.json | 6 -- requirements_all.txt | 3 - 5 files changed, 84 deletions(-) delete mode 100644 homeassistant/components/goalfeed/__init__.py delete mode 100644 homeassistant/components/goalfeed/manifest.json diff --git a/.coveragerc b/.coveragerc index 445be38ab05..5167decbf8a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -423,7 +423,6 @@ omit = homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py homeassistant/components/glances/sensor.py - homeassistant/components/goalfeed/* homeassistant/components/goodwe/__init__.py homeassistant/components/goodwe/button.py homeassistant/components/goodwe/coordinator.py diff --git a/homeassistant/components/goalfeed/__init__.py b/homeassistant/components/goalfeed/__init__.py deleted file mode 100644 index f452b858e79..00000000000 --- a/homeassistant/components/goalfeed/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Component for the Goalfeed service.""" -import json - -import pysher -import requests -import voluptuous as vol - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -# Version downgraded due to regression in library -# For details: https://github.com/nlsdfnbch/Pysher/issues/38 -DOMAIN = "goalfeed" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -GOALFEED_HOST = "feed.goalfeed.ca" -GOALFEED_AUTH_ENDPOINT = "https://goalfeed.ca/feed/auth" -GOALFEED_APP_ID = "bfd4ed98c1ff22c04074" - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Goalfeed component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - - def goal_handler(data): - """Handle goal events.""" - goal = json.loads(json.loads(data)) - - hass.bus.fire("goal", event_data=goal) - - def connect_handler(data): - """Handle connection.""" - post_data = { - "username": username, - "password": password, - "connection_info": data, - } - resp = requests.post(GOALFEED_AUTH_ENDPOINT, post_data, timeout=30).json() - - channel = pusher.subscribe("private-goals", resp["auth"]) - channel.bind("goal", goal_handler) - - pusher = pysher.Pusher( - GOALFEED_APP_ID, secure=False, port=8080, custom_host=GOALFEED_HOST - ) - - pusher.connection.bind("pusher:connection_established", connect_handler) - pusher.connect() - - return True diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json deleted file mode 100644 index 3ce7ffb8065..00000000000 --- a/homeassistant/components/goalfeed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "goalfeed", - "name": "Goalfeed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/goalfeed", - "iot_class": "cloud_push", - "loggers": ["pysher"], - "requirements": ["Pysher==1.0.7"] -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b9c2ac57553..2203819cc85 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2007,12 +2007,6 @@ } } }, - "goalfeed": { - "name": "Goalfeed", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "goalzero": { "name": "Goal Zero Yeti", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 0e2fefead5e..dfbbf3e5f56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -120,9 +120,6 @@ PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 -# homeassistant.components.goalfeed -Pysher==1.0.7 - # homeassistant.components.rachio RachioPy==1.0.3 From 9b6a9147c7f34ea375457ee2ba96d2d74bb34247 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 6 Jun 2023 20:07:21 -0400 Subject: [PATCH 117/857] Fix multiple smart detects firing at once for UniFi Protect (#94133) * Fix multiple smart detects firing at once * Tweak * Clean up logging. Linting * Linting --- .../components/unifiprotect/binary_sensor.py | 20 ++++------- homeassistant/components/unifiprotect/data.py | 24 +++++++++++++ .../components/unifiprotect/manifest.json | 2 +- .../components/unifiprotect/models.py | 34 +++++++++---------- .../components/unifiprotect/sensor.py | 7 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifiprotect/test_sensor.py | 4 ++- 8 files changed, 55 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 7aa7c6d5cf1..fe4399c4c6d 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -14,8 +14,6 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, ProtectModelWithId, Sensor, - SmartDetectAudioType, - SmartDetectObjectType, ) from pyunifiprotect.data.nvr import UOSDisk @@ -364,8 +362,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.PERSON, + ufp_event_obj="last_person_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_vehicle", @@ -374,8 +371,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.VEHICLE, + ufp_event_obj="last_vehicle_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_face", @@ -384,8 +380,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_face", ufp_enabled="is_face_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.FACE, + ufp_event_obj="last_face_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_package", @@ -394,8 +389,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_package", ufp_enabled="is_package_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.PACKAGE, + ufp_event_obj="last_package_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_any", @@ -412,8 +406,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", - ufp_event_obj="last_smart_audio_detect_event", - ufp_smart_type=SmartDetectAudioType.SMOKE, + ufp_event_obj="last_smoke_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", @@ -422,8 +415,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", - ufp_event_obj="last_smart_audio_detect_event", - ufp_smart_type=SmartDetectAudioType.CMONX, + ufp_event_obj="last_cmonx_detect_event", ), ) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 68d48003ba6..88c500f18fd 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -40,6 +40,11 @@ from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type _LOGGER = logging.getLogger(__name__) ProtectDeviceType = ProtectAdoptableDeviceModel | NVR +SMART_EVENTS = { + EventType.SMART_DETECT, + EventType.SMART_AUDIO_DETECT, + EventType.SMART_DETECT_LINE, +} @callback @@ -223,6 +228,25 @@ class ProtectData: # trigger updates for camera that the event references elif isinstance(obj, Event): + if obj.type in SMART_EVENTS: + if obj.camera is not None: + if obj.end is None: + _LOGGER.debug( + "%s (%s): New smart detection started for %s (%s)", + obj.camera.name, + obj.camera.mac, + obj.smart_detect_types, + obj.id, + ) + else: + _LOGGER.debug( + "%s (%s): Smart detection ended for %s (%s)", + obj.camera.name, + obj.camera.mac, + obj.smart_detect_types, + obj.id, + ) + if obj.type == EventType.DEVICE_ADOPTED: if obj.metadata is not None and obj.metadata.device_id is not None: device = self.api.bootstrap.get_device_from_id( diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a2bb76c92b7..e16180b03bc 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.9.1", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 40280c02867..8c688231628 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +from datetime import timedelta from enum import Enum import logging from typing import Any, Generic, TypeVar, cast @@ -10,6 +11,7 @@ from typing import Any, Generic, TypeVar, cast from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription +from homeassistant.util import dt as dt_util from .utils import get_nested_attr @@ -67,7 +69,6 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" ufp_event_obj: str | None = None - ufp_smart_type: str | None = None def get_event_obj(self, obj: T) -> Event | None: """Return value from UniFi Protect device.""" @@ -79,23 +80,22 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): def get_is_on(self, obj: T) -> bool: """Return value if event is active.""" - value = bool(self.get_ufp_value(obj)) - if value: - event = self.get_event_obj(obj) - value = event is not None - if not value: - _LOGGER.debug("%s (%s): missing event", self.name, obj.mac) + event = self.get_event_obj(obj) + if event is None: + return False - if event is not None and self.ufp_smart_type is not None: - value = self.ufp_smart_type in event.smart_detect_types - if not value: - _LOGGER.debug( - "%s (%s): %s not in %s", - self.name, - obj.mac, - self.ufp_smart_type, - event.smart_detect_types, - ) + now = dt_util.utcnow() + value = now > event.start + if value and event.end is not None and now > event.end: + value = False + # only log if the recent ended recently + if event.end + timedelta(seconds=10) < now: + _LOGGER.debug( + "%s (%s): end ended at %s", + self.name, + obj.mac, + event.end.isoformat(), + ) if value: _LOGGER.debug("%s (%s): value is on", self.name, obj.mac) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 783955b3401..dec6f10a57f 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -15,7 +15,6 @@ from pyunifiprotect.data import ( ProtectDeviceModel, ProtectModelWithId, Sensor, - SmartDetectObjectType, ) from homeassistant.components.sensor import ( @@ -528,10 +527,9 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( name="License Plate Detected", icon="mdi:car", translation_key="license_plate", - ufp_smart_type=SmartDetectObjectType.LICENSE_PLATE, ufp_value="is_smart_detected", ufp_required_field="can_detect_license_plate", - ufp_event_obj="last_smart_detect_event", + ufp_event_obj="last_license_plate_detect_event", ), ) @@ -767,8 +765,7 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): EventEntityMixin._async_update_device_from_protect(self, device) is_on = self.entity_description.get_is_on(device) is_license_plate = ( - self.entity_description.ufp_smart_type - == SmartDetectObjectType.LICENSE_PLATE + self.entity_description.ufp_event_obj == "last_license_plate_detect_event" ) if ( not is_on diff --git a/requirements_all.txt b/requirements_all.txt index dfbbf3e5f56..f0e69631d5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.1 +pyunifiprotect==4.10.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 962ba12a3d0..55d4af9ecca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.1 +pyunifiprotect==4.10.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index db7cdc801bf..89a153caed2 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -537,7 +537,9 @@ async def test_camera_update_licenseplate( new_camera = camera.copy() new_camera.is_smart_detected = True - new_camera.last_smart_detect_event_id = event.id + new_camera.last_smart_detect_event_ids[ + SmartDetectObjectType.LICENSE_PLATE + ] = event.id mock_msg = Mock() mock_msg.changed_data = {} From 463945b86ed183ce374a4b08396215115f583945 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Jun 2023 19:40:32 -0500 Subject: [PATCH 118/857] Remove `mark_read` service from persistent_notification (#94122) * Remove mark_read from persistent_notification Nothing on the frontend uses this, and the service is not documented There is not much point in keeping this as the notifications are no longer stored in the state machine * adjust * adjust --- .../persistent_notification/__init__.py | 32 ---------- .../persistent_notification/services.yaml | 12 ---- .../persistent_notification/test_init.py | 61 ------------------- 3 files changed, 105 deletions(-) diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 81a9bc9de4d..fe8849c7788 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -29,8 +29,6 @@ ATTR_NOTIFICATION_ID: Final = "notification_id" ATTR_TITLE: Final = "title" ATTR_STATUS: Final = "status" -STATUS_UNREAD = "unread" -STATUS_READ = "read" # Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9 EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" @@ -43,7 +41,6 @@ class Notification(TypedDict): message: str notification_id: str title: str | None - status: str class UpdateType(StrEnum): @@ -98,7 +95,6 @@ def async_create( notifications[notification_id] = { ATTR_MESSAGE: message, ATTR_NOTIFICATION_ID: notification_id, - ATTR_STATUS: STATUS_UNREAD, ATTR_TITLE: title, ATTR_CREATED_AT: dt_util.utcnow(), } @@ -135,7 +131,6 @@ def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" - notifications = _async_get_or_create_notifications(hass) @callback def create_service(call: ServiceCall) -> None: @@ -152,29 +147,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Handle the dismiss notification service call.""" async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID]) - @callback - def mark_read_service(call: ServiceCall) -> None: - """Handle the mark_read notification service call.""" - notification_id = call.data.get(ATTR_NOTIFICATION_ID) - if notification_id not in notifications: - _LOGGER.error( - ( - "Marking persistent_notification read failed: " - "Notification ID %s not found" - ), - notification_id, - ) - return - - notification = notifications[notification_id] - notification[ATTR_STATUS] = STATUS_READ - async_dispatcher_send( - hass, - SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, - UpdateType.UPDATED, - {notification_id: notification}, - ) - hass.services.async_register( DOMAIN, "create", @@ -192,10 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, "dismiss", dismiss_service, SCHEMA_SERVICE_NOTIFICATION ) - hass.services.async_register( - DOMAIN, "mark_read", mark_read_service, SCHEMA_SERVICE_NOTIFICATION - ) - websocket_api.async_register_command(hass, websocket_get_notifications) websocket_api.async_register_command(hass, websocket_subscribe_notifications) diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 5695a3c3b82..60dbf5c864a 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -33,15 +33,3 @@ dismiss: example: 1234 selector: text: - -mark_read: - name: Mark read - description: Mark a notification read. - fields: - notification_id: - name: Notification ID - description: Target ID of the notification, which should be mark read. - required: true - example: 1234 - selector: - text: diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 7fd8b00d3e1..3a5e9ef6b74 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -25,7 +25,6 @@ async def test_create(hass: HomeAssistant) -> None: assert len(notifications) == 1 notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_UNREAD assert notification["message"] == "Hello World 2" assert notification["title"] == "2 beers" assert notification["created_at"] is not None @@ -66,39 +65,6 @@ async def test_dismiss_notification(hass: HomeAssistant) -> None: assert len(notifications) == 0 -async def test_mark_read(hass: HomeAssistant) -> None: - """Ensure notification is marked as Read.""" - notifications = pn._async_get_or_create_notifications(hass) - assert len(notifications) == 0 - - await hass.services.async_call( - pn.DOMAIN, - "create", - {"notification_id": "Beer 2", "message": "test"}, - blocking=True, - ) - - assert len(notifications) == 1 - notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_UNREAD - - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"}, blocking=True - ) - - assert len(notifications) == 1 - notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_READ - - await hass.services.async_call( - pn.DOMAIN, - "dismiss", - {"notification_id": "Beer 2"}, - blocking=True, - ) - assert len(notifications) == 0 - - async def test_ws_get_notifications( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -128,19 +94,8 @@ async def test_ws_get_notifications( assert notification["notification_id"] == "Beer 2" assert notification["message"] == "test" assert notification["title"] is None - assert notification["status"] == pn.STATUS_UNREAD assert notification["created_at"] is not None - # Mark Read - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"} - ) - await client.send_json({"id": 7, "type": "persistent_notification/get"}) - msg = await client.receive_json() - notifications = msg["result"] - assert len(notifications) == 1 - assert notifications[0]["status"] == pn.STATUS_READ - # Dismiss pn.async_dismiss(hass, "Beer 2") await client.send_json({"id": 8, "type": "persistent_notification/get"}) @@ -186,24 +141,8 @@ async def test_ws_get_subscribe( assert notification["notification_id"] == "Beer 2" assert notification["message"] == "test" assert notification["title"] is None - assert notification["status"] == pn.STATUS_UNREAD assert notification["created_at"] is not None - # Mark Read - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"} - ) - msg = await client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == "event" - assert msg["event"] - event = msg["event"] - assert event["type"] == "updated" - notifications = event["notifications"] - assert len(notifications) == 1 - notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_READ - # Dismiss pn.async_dismiss(hass, "Beer 2") msg = await client.receive_json() From 6ab7b0f99c61f4bf8cb3f8c01de817c020b7eb1d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 7 Jun 2023 02:40:59 +0200 Subject: [PATCH 119/857] Fix typo in Picnic strings (#94117) --- homeassistant/components/picnic/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index ff91b5259b2..f0e0d93231c 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -50,7 +50,7 @@ "name": "Status of last order" }, "last_order_max_order_time": { - "name": "Max order time of last slot" + "name": "Max order time of last order" }, "last_order_delivery_time": { "name": "Last order delivery time" From 001da39a47216a2c4dd1ac6d553947f129518ca5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Jun 2023 19:42:11 -0500 Subject: [PATCH 120/857] Verify persistant notifications can be dismissed by the id they are created with (#94112) --- .../persistent_notification/test_init.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 3a5e9ef6b74..4f0851dc477 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -151,3 +151,27 @@ async def test_ws_get_subscribe( assert msg["event"] event = msg["event"] assert event["type"] == "removed" + + +async def test_manual_notification_id_round_trip(hass: HomeAssistant) -> None: + """Test that a manual notification id can be round tripped.""" + notifications = pn._async_get_or_create_notifications(hass) + assert len(notifications) == 0 + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "synology_diskstation_hub_notification", "message": "test"}, + blocking=True, + ) + + assert len(notifications) == 1 + + await hass.services.async_call( + pn.DOMAIN, + "dismiss", + {"notification_id": "synology_diskstation_hub_notification"}, + blocking=True, + ) + + assert len(notifications) == 0 From bee428e9f640f8e76c8317b3a19751985be508a7 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 6 Jun 2023 20:42:59 -0400 Subject: [PATCH 121/857] Bump python-roborock to 23.4 (#94111) * bump to 23.0 * bump to 23.4 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 41e4a359e2e..0cd437278cf 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.21.0"] + "requirements": ["python-roborock==0.23.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f0e69631d5d..cc37fd39ba2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.21.0 +python-roborock==0.23.4 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55d4af9ecca..9f18ea53cab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1563,7 +1563,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.21.0 +python-roborock==0.23.4 # homeassistant.components.smarttub python-smarttub==0.0.33 From 0d0de4aa5067ba8a881ead75b131fb47c0ccfcb0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 7 Jun 2023 02:45:07 +0200 Subject: [PATCH 122/857] Remove left-over issue Simplepush (#94103) * Remove left-over issue simplepush * strings --- homeassistant/components/simplepush/notify.py | 23 ++++--------------- .../components/simplepush/strings.json | 6 ----- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 3e7fad8863f..cc6c61ced03 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -10,18 +10,13 @@ from homeassistant.components.notify import ( ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, BaseNotificationService, ) from homeassistant.const import CONF_EVENT, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN - -# Configuring Simplepush under the notify has been removed in 2022.9.0 -PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT _LOGGER = logging.getLogger(__name__) @@ -32,19 +27,9 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> SimplePushNotificationService | None: """Get the Simplepush notification service.""" - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - return None - - return SimplePushNotificationService(discovery_info) + if discovery_info: + return SimplePushNotificationService(discovery_info) + return None class SimplePushNotificationService(BaseNotificationService): diff --git a/homeassistant/components/simplepush/strings.json b/homeassistant/components/simplepush/strings.json index 68ee5c7a9ed..0031dc32340 100644 --- a/homeassistant/components/simplepush/strings.json +++ b/homeassistant/components/simplepush/strings.json @@ -17,11 +17,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Simplepush YAML configuration has been removed", - "description": "Configuring Simplepush using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the Simplepush YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } From 7d7920f4830e1d8d116d3bdd5b5a5d05a072d35b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 7 Jun 2023 02:47:32 +0200 Subject: [PATCH 123/857] Remove leftover issue in nVent RAYCHEM SENZ (#94105) * Remove leftover issue senz * strings --- homeassistant/components/senz/__init__.py | 20 +------------------- homeassistant/components/senz/strings.json | 6 ------ 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index e6ded8e0355..559760ec52d 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -16,8 +16,6 @@ from homeassistant.helpers import ( config_validation as cv, httpx_client, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import SENZConfigEntryAuth @@ -27,29 +25,13 @@ UPDATE_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.CLIMATE] SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SENZ integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SENZ from a config entry.""" implementation = ( diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json index 74ca9f5e3bf..316f7234f9b 100644 --- a/homeassistant/components/senz/strings.json +++ b/homeassistant/components/senz/strings.json @@ -16,11 +16,5 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } - }, - "issues": { - "removed_yaml": { - "title": "The nVent RAYCHEM SENZ YAML configuration has been removed", - "description": "Configuring nVent RAYCHEM SENZ using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } From c60e53b43f24cc7bed134a7b986e84022892d2e4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 7 Jun 2023 02:54:09 +0200 Subject: [PATCH 124/857] Remove left-over issue in Honeywell Lyric (#94092) --- homeassistant/components/lyric/__init__.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index f34366f24d0..c2423a7c47f 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -23,8 +23,6 @@ from homeassistant.helpers import ( device_registry as dr, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -38,29 +36,13 @@ from .api import ( ) from .const import DOMAIN -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Honeywell Lyric integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Honeywell Lyric from a config entry.""" implementation = ( From d3fca972a5a8ea0707c186c53c37fd5f4d4d8f20 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 6 Jun 2023 20:55:25 -0400 Subject: [PATCH 125/857] Bump waqiasync to 1.1.0 (#94136) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index d1c75217830..e5630d5fd29 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["waqiasync"], - "requirements": ["waqiasync==1.0.0"] + "requirements": ["waqiasync==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc37fd39ba2..28a3a0d12ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2640,7 +2640,7 @@ wakeonlan==2.1.0 wallbox==0.4.12 # homeassistant.components.waqi -waqiasync==1.0.0 +waqiasync==1.1.0 # homeassistant.components.folder_watcher watchdog==2.3.1 From fe11cae08d06629be986fc06f4386e715bc17f8e Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 6 Jun 2023 21:24:36 -0400 Subject: [PATCH 126/857] Add diagnostics to Roborock (#94099) * Add diagnostics * Update homeassistant/components/roborock/models.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * adds snapshot --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/roborock/diagnostics.py | 38 +++ homeassistant/components/roborock/models.py | 10 + tests/components/roborock/conftest.py | 5 +- tests/components/roborock/mock_data.py | 4 + .../roborock/snapshots/test_diagnostics.ambr | 303 ++++++++++++++++++ tests/components/roborock/test_diagnostics.py | 23 ++ 6 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/roborock/diagnostics.py create mode 100644 tests/components/roborock/snapshots/test_diagnostics.ambr create mode 100644 tests/components/roborock/test_diagnostics.py diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py new file mode 100644 index 00000000000..e5fcc834267 --- /dev/null +++ b/homeassistant/components/roborock/diagnostics.py @@ -0,0 +1,38 @@ +"""Support for the Airzone diagnostics.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator + +TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] + +TO_REDACT_COORD = ["duid", "localKey", "mac", "bssid"] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + + return { + "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG), + "coordinators": { + f"**REDACTED-{i}**": { + "roborock_device_info": async_redact_data( + coordinator.roborock_device_info.as_dict(), TO_REDACT_COORD + ), + "api": coordinator.api.diagnostic_data, + } + for i, coordinator in enumerate(coordinators.values()) + }, + } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index a30c84ce1da..c1d32df2d6d 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -1,5 +1,6 @@ """Roborock Models.""" from dataclasses import dataclass +from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.roborock_typing import DeviceProp @@ -13,3 +14,12 @@ class RoborockHassDeviceInfo: network_info: NetworkInfo product: HomeDataProduct props: DeviceProp + + def as_dict(self) -> dict[str, dict[str, Any]]: + """Turn RoborockHassDeviceInfo into a dictionary.""" + return { + "device": self.device.as_dict(), + "network_info": self.network_info.as_dict(), + "product": self.product.as_dict(), + "props": self.props.as_dict(), + } diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index b311f84f94a..d9c11bead74 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .mock_data import BASE_URL, HOME_DATA, PROP, USER_DATA, USER_EMAIL +from .mock_data import BASE_URL, HOME_DATA, NETWORK_INFO, PROP, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -54,7 +54,8 @@ async def setup_entry( "homeassistant.components.roborock.RoborockApiClient.get_home_data", return_value=HOME_DATA, ), patch( - "homeassistant.components.roborock.RoborockMqttClient.get_networking" + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 15e69cee9d9..8155c10fdbd 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1,6 +1,8 @@ """Mock data for Roborock tests.""" from __future__ import annotations +import datetime + from roborock.containers import ( CleanRecord, CleanSummary, @@ -320,6 +322,8 @@ DND_TIMER = DnDTimer.from_dict( "enabled": 1, } ) +DND_TIMER.start_time = datetime.datetime(year=2023, month=6, day=1, hour=22) +DND_TIMER.end_time = datetime.datetime(year=2023, month=6, day=2, hour=7) STATUS = S7Status.from_dict( { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5cb9b109368 --- /dev/null +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'base_url': 'https://usiot.roborock.com', + 'user_data': dict({ + 'avatarurl': 'https://files.roborock.com/iottest/default_avatar.png', + 'country': 'US', + 'countrycode': '1', + 'nickname': 'user_nickname', + 'region': 'us', + 'rriot': dict({ + 'h': 'abc123', + 'k': 'abc123', + 'r': dict({ + 'a': 'https://api-us.roborock.com', + 'l': 'https://wood-us.roborock.com', + 'm': 'ssl://mqtt-us-2.roborock.com:8883', + 'r': 'US', + }), + 's': 'abc123', + 'u': 'abc123', + }), + 'rruid': '**REDACTED**', + 'token': '**REDACTED**', + 'tokentype': '', + 'tuyaDeviceState': 2, + 'uid': '**REDACTED**', + }), + 'username': '**REDACTED**', + }), + 'coordinators': dict({ + '**REDACTED-0**': dict({ + 'api': dict({ + }), + 'roborock_device_info': dict({ + 'device': dict({ + 'activeTime': 1672364449, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 102, + '124': 203, + '125': 94, + '126': 90, + '127': 87, + '128': 0, + '133': 1, + }), + 'duid': '**REDACTED**', + 'extra': '{"RRPhotoPrivacyVersion": "1"}', + 'featureSet': '2234201184108543', + 'fv': '02.56.02', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock S7 MaxV', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 'abc123', + 'pv': '1.0', + 'roomId': 2362003, + 'share': False, + 'silentOtaSwitch': True, + 'sn': 'abc123', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'network_info': dict({ + 'bssid': '**REDACTED**', + 'ip': '123.232.12.1', + 'mac': '**REDACTED**', + 'rssi': 90, + 'ssid': 'wifi', + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'code': 'a27', + 'id': 'abc123', + 'model': 'roborock.vacuum.a27', + 'name': 'Roborock S7 MaxV', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': '101', + 'mode': 'rw', + 'name': 'rpc_request', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': '102', + 'mode': 'rw', + 'name': 'rpc_response', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': '120', + 'mode': 'ro', + 'name': '错误代码', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': '121', + 'mode': 'ro', + 'name': '设备状态', + 'type': 'ENUM', + }), + dict({ + 'code': 'battery', + 'id': '122', + 'mode': 'ro', + 'name': '设备电量', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': '123', + 'mode': 'rw', + 'name': '清扫模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': '124', + 'mode': 'rw', + 'name': '拖地模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'main_brush_life', + 'id': '125', + 'mode': 'rw', + 'name': '主刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'side_brush_life', + 'id': '126', + 'mode': 'rw', + 'name': '边刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'filter_life', + 'id': '127', + 'mode': 'rw', + 'name': '滤网寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'additional_props', + 'id': '128', + 'mode': 'ro', + 'name': '额外状态', + 'type': 'RAW', + }), + dict({ + 'code': 'task_complete', + 'id': '130', + 'mode': 'ro', + 'name': '完成事件', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_low_power', + 'id': '131', + 'mode': 'ro', + 'name': '电量不足任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_in_motion', + 'id': '132', + 'mode': 'ro', + 'name': '运动中任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'charge_status', + 'id': '133', + 'mode': 'ro', + 'name': '充电状态', + 'type': 'RAW', + }), + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + ]), + }), + 'props': dict({ + 'cleanSummary': dict({ + 'cleanArea': 1159182500, + 'cleanCount': 31, + 'cleanTime': 74382, + 'dustCollectionCount': 25, + 'records': list([ + 1672543330, + 1672458041, + ]), + 'squareMeterCleanArea': 1159.2, + }), + 'consumable': dict({ + 'cleaningBrushWorkTimes': 65, + 'dustCollectionWorkTimes': 25, + 'filterElementWorkTime': 0, + 'filterTimeLeft': 465618, + 'filterWorkTime': 74382, + 'mainBrushTimeLeft': 1005618, + 'mainBrushWorkTime': 74382, + 'sensorDirtyTime': 74382, + 'sensorTimeLeft': 33618, + 'sideBrushTimeLeft': 645618, + 'sideBrushWorkTime': 74382, + 'strainerWorkTimes': 65, + }), + 'dndTimer': dict({ + 'enabled': 1, + 'endHour': 7, + 'endMinute': 0, + 'endTime': '2023-06-02T07:00:00', + 'startHour': 22, + 'startMinute': 0, + 'startTime': '2023-06-01T22:00:00', + }), + 'lastCleanRecord': dict({ + 'area': 20965000, + 'avoidCount': 19, + 'begin': 1672543330, + 'cleanType': 3, + 'complete': 1, + 'duration': 1176, + 'dustCollectionStatus': 1, + 'end': 1672544638, + 'error': 0, + 'finishReason': 56, + 'mapFlag': 0, + 'squareMeterArea': 21.0, + 'startType': 2, + 'washCount': 2, + }), + 'status': dict({ + 'adbumperStatus': list([ + 0, + 0, + 0, + ]), + 'autoDustCollection': 1, + 'avoidCount': 19, + 'backType': -1, + 'battery': 100, + 'cameraStatus': 3457, + 'chargeStatus': 1, + 'cleanArea': 20965000, + 'cleanTime': 1176, + 'collisionAvoidStatus': 1, + 'debugMode': 0, + 'dndEnabled': 0, + 'dockErrorStatus': 0, + 'dockType': 3, + 'dustCollectionStatus': 0, + 'errorCode': 0, + 'fanPower': 102, + 'homeSecEnablePassword': 0, + 'homeSecStatus': 0, + 'inCleaning': 0, + 'inFreshState': 1, + 'inReturning': 0, + 'isExploring': 0, + 'isLocating': 0, + 'labStatus': 1, + 'lockStatus': 0, + 'mapPresent': 1, + 'mapStatus': 3, + 'mopForbiddenEnable': 1, + 'mopMode': 300, + 'msgSeq': 458, + 'msgVer': 2, + 'squareMeterCleanArea': 21.0, + 'state': 8, + 'switchMapMode': 0, + 'unsaveMapFlag': 0, + 'unsaveMapReason': 0, + 'washPhase': 0, + 'washReady': 0, + 'waterBoxCarriageStatus': 1, + 'waterBoxMode': 203, + 'waterBoxStatus': 1, + 'waterShortageStatus': 0, + }), + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/roborock/test_diagnostics.py b/tests/components/roborock/test_diagnostics.py new file mode 100644 index 00000000000..a10cbcf057e --- /dev/null +++ b/tests/components/roborock/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Tests for the diagnostics data provided by the Roborock integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bypass_api_fixture, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, setup_entry) + + assert isinstance(result, dict) + assert result == snapshot From 0a67f9640211f61dc69a6e77db8c095744653c86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Jun 2023 03:18:57 -0500 Subject: [PATCH 127/857] Bump ruuvitag-ble to 0.1.2 (#94144) --- homeassistant/components/ruuvitag_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ruuvitag_ble/manifest.json b/homeassistant/components/ruuvitag_ble/manifest.json index 6cabecb7912..fa8ec80423c 100644 --- a/homeassistant/components/ruuvitag_ble/manifest.json +++ b/homeassistant/components/ruuvitag_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble", "iot_class": "local_push", - "requirements": ["ruuvitag-ble==0.1.1"] + "requirements": ["ruuvitag-ble==0.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28a3a0d12ef..0e632e21ee5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ russound-rio==0.1.8 russound==0.1.9 # homeassistant.components.ruuvitag_ble -ruuvitag-ble==0.1.1 +ruuvitag-ble==0.1.2 # homeassistant.components.yamaha rxv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f18ea53cab..35cf2a14d65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1676,7 +1676,7 @@ rpi-bad-power==0.1.0 rtsp-to-webrtc==0.5.1 # homeassistant.components.ruuvitag_ble -ruuvitag-ble==0.1.1 +ruuvitag-ble==0.1.2 # homeassistant.components.yamaha rxv==0.7.0 From 33044bc153156313c803423309ae1b5794897a38 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Jun 2023 11:14:51 +0200 Subject: [PATCH 128/857] Fix migration of Google Assistant cloud settings (#94148) --- homeassistant/components/cloud/google_config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 8592a4ffbe3..1b7375946f7 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -108,7 +108,12 @@ def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: if domain in SUPPORTED_DOMAINS: return True - device_class = get_device_class(hass, entity_id) + try: + device_class = get_device_class(hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False + if ( domain == "binary_sensor" and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES From 7f4d62c80fa94df4576381be8b018b49ddc718e7 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 7 Jun 2023 05:15:03 -0400 Subject: [PATCH 129/857] Bump unifiprotect to 4.10.1 (#94141) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e16180b03bc..78e2ee3012c 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.10.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.1", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 0e632e21ee5..c4ec5da796a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.0 +pyunifiprotect==4.10.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35cf2a14d65..271d8a72689 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.0 +pyunifiprotect==4.10.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 68baf84144da9befae6cd2d73dadcbef5514f000 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jun 2023 12:57:37 +0200 Subject: [PATCH 130/857] Update frontend to 20230607.0 (#94150) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 47bdf60b8e7..af8898f28e2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230606.0"] + "requirements": ["home-assistant-frontend==20230607.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fad590e34d4..d183395ebfc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230606.0 +home-assistant-frontend==20230607.0 home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c4ec5da796a..ab2ece0d195 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230606.0 +home-assistant-frontend==20230607.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 271d8a72689..a896c6554a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -767,7 +767,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230606.0 +home-assistant-frontend==20230607.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 From 4802e7f93ab4d63aeaf0aa45938439bd259a59bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Jun 2023 13:16:23 +0200 Subject: [PATCH 131/857] Add debug logs to cloud migration (#94151) --- homeassistant/components/cloud/alexa_config.py | 10 ++++++++++ homeassistant/components/cloud/google_config.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 052fddabb54..8c1300f6228 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -221,6 +221,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) if self._prefs.alexa_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.alexa_settings_version < 3 @@ -233,6 +238,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ): self._migrate_alexa_entity_settings_v1() + _LOGGER.info( + "Finished migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 1b7375946f7..0a49c0b6ed6 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -213,6 +213,11 @@ class CloudGoogleConfig(AbstractConfig): async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) if self._prefs.google_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.google_settings_version < 3 @@ -225,6 +230,11 @@ class CloudGoogleConfig(AbstractConfig): ): self._migrate_google_entity_settings_v1() + _LOGGER.info( + "Finished migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) From df138d91e69bcac8e6e2860bbb136e94a5efabe0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Jun 2023 13:46:17 +0200 Subject: [PATCH 132/857] Disable google assistant local control of climate entities (#94153) --- homeassistant/components/google_assistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index bf511f8eaeb..918cec046fb 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -186,7 +186,7 @@ STORE_GOOGLE_LOCAL_WEBHOOK_ID = "local_webhook_id" SOURCE_CLOUD = "cloud" SOURCE_LOCAL = "local" -NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK} +NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK, TYPE_THERMOSTAT} FAN_SPEEDS = { "5/5": ["High", "Max", "Fast", "5"], From 05b5b30df30ddb3c576ca2df7290583483a6feb2 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Wed, 7 Jun 2023 12:11:39 -0400 Subject: [PATCH 133/857] Update python-qbittorrent to 0.4.3 (#94072) --- homeassistant/components/qbittorrent/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index c56bb8102b8..e2c1526e4f8 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["python-qbittorrent==0.4.2"] + "requirements": ["python-qbittorrent==0.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab2ece0d195..abea740f196 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2134,7 +2134,7 @@ python-otbr-api==2.1.0 python-picnic-api==1.1.0 # homeassistant.components.qbittorrent -python-qbittorrent==0.4.2 +python-qbittorrent==0.4.3 # homeassistant.components.ripple python-ripple-api==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a896c6554a6..a39e17729e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1560,7 +1560,7 @@ python-otbr-api==2.1.0 python-picnic-api==1.1.0 # homeassistant.components.qbittorrent -python-qbittorrent==0.4.2 +python-qbittorrent==0.4.3 # homeassistant.components.roborock python-roborock==0.23.4 From d431a692e5ee11391e093a5ca1095da9f14cd4e4 Mon Sep 17 00:00:00 2001 From: Justin Vanderhooft Date: Wed, 7 Jun 2023 17:17:01 +0100 Subject: [PATCH 134/857] Bump melnor-bluetooth to fix a timezone issue (#94159) --- homeassistant/components/melnor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 185899a9656..45dce207f7e 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.24"] + "requirements": ["melnor-bluetooth==0.0.25"] } diff --git a/requirements_all.txt b/requirements_all.txt index abea740f196..07a4823291f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1179,7 +1179,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.24 +melnor-bluetooth==0.0.25 # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a39e17729e1..53b3213dfde 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -899,7 +899,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.24 +melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france meteofrance-api==1.2.0 From 6af1beb6bf3e70a552dcda6ff7b800181692ce55 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 7 Jun 2023 18:36:39 +0200 Subject: [PATCH 135/857] Change Ezviz detection sensitivity to update per entity (#93995) * Split detection sensitivity updates to entity instead of coordinator. * Detection Sensitivity entity individual poll. * Api return None instead of "unkown" when unkown. * Only add entity if camera supports * Cleanup detection type * Commit suggestions. --------- Co-authored-by: Chris Talkington --- homeassistant/components/ezviz/entity.py | 29 +++++ homeassistant/components/ezviz/manifest.json | 2 +- homeassistant/components/ezviz/number.py | 118 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 113 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 1c966c7f82e..ccf273a970b 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -38,3 +38,32 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): def data(self) -> dict[str, Any]: """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + + +class EzvizBaseEntity(Entity): + """Generic entity for EZVIZ individual poll entities.""" + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + ) -> None: + """Initialize the entity.""" + self._serial = serial + self.coordinator = coordinator + self._camera_name = self.data["name"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + connections={ + (CONNECTION_NETWORK_MAC, self.data["mac_address"]), + }, + manufacturer=MANUFACTURER, + model=self.data["device_sub_category"], + name=self.data["name"], + sw_version=self.data["version"], + ) + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data for this entity.""" + return self.coordinator.data[self._serial] diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 6697fbce71d..e9f11f4cd39 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.0.15"] + "requirements": ["pyezviz==0.2.0.17"] } diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 849bf2c400b..074685c69f9 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -1,8 +1,18 @@ """Support for EZVIZ number controls.""" from __future__ import annotations -from pyezviz.constants import DeviceCatagories -from pyezviz.exceptions import HTTPError, PyEzvizError +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pyezviz.constants import SupportExt +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,17 +23,37 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_COORDINATOR, DOMAIN from .coordinator import EzvizDataUpdateCoordinator -from .entity import EzvizEntity +from .entity import EzvizBaseEntity -PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(seconds=3600) +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) -NUMBER_TYPES = NumberEntityDescription( + +@dataclass +class EzvizNumberEntityDescriptionMixin: + """Mixin values for EZVIZ Number entities.""" + + supported_ext: str + supported_ext_value: list + + +@dataclass +class EzvizNumberEntityDescription( + NumberEntityDescription, EzvizNumberEntityDescriptionMixin +): + """Describe a EZVIZ Number.""" + + +NUMBER_TYPE = EzvizNumberEntityDescription( key="detection_sensibility", name="Detection sensitivity", icon="mdi:eye", entity_category=EntityCategory.CONFIG, native_min_value=0, native_step=1, + supported_ext=str(SupportExt.SupportSensibilityAdjust.value), + supported_ext_value=["1", "3"], ) @@ -36,15 +66,18 @@ async def async_setup_entry( ] async_add_entities( - EzvizSensor(coordinator, camera, sensor, NUMBER_TYPES) - for camera in coordinator.data - for sensor, value in coordinator.data[camera].items() - if sensor in NUMBER_TYPES.key - if value + [ + EzvizSensor(coordinator, camera, value, entry.entry_id) + for camera in coordinator.data + for capibility, value in coordinator.data[camera]["supportExt"].items() + if capibility == NUMBER_TYPE.supported_ext + if value in NUMBER_TYPE.supported_ext_value + ], + update_before_add=True, ) -class EzvizSensor(EzvizEntity, NumberEntity): +class EzvizSensor(EzvizBaseEntity, NumberEntity): """Representation of a EZVIZ number entity.""" _attr_has_entity_name = True @@ -53,46 +86,57 @@ class EzvizSensor(EzvizEntity, NumberEntity): self, coordinator: EzvizDataUpdateCoordinator, serial: str, - sensor: str, - description: NumberEntityDescription, + value: str, + config_entry_id: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, serial) - self._sensor_name = sensor - self.battery_cam_type = bool( - self.data["device_category"] - == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value - ) - self._attr_unique_id = f"{serial}_{sensor}" - self._attr_native_max_value = 100 if self.battery_cam_type else 6 - self.entity_description = description + self.sensitivity_type = 3 if value == "3" else 0 + self._attr_native_max_value = 100 if value == "3" else 6 + self._attr_unique_id = f"{serial}_{NUMBER_TYPE.key}" + self.entity_description = NUMBER_TYPE + self.config_entry_id = config_entry_id + self.sensor_value: int | None = None @property def native_value(self) -> float | None: """Return the state of the entity.""" - try: - return float(self.data[self._sensor_name]) - except ValueError: - return None + if self.sensor_value is not None: + return float(self.sensor_value) + return None def set_native_value(self, value: float) -> None: """Set camera detection sensitivity.""" level = int(value) try: - if self.battery_cam_type: - self.coordinator.ezviz_client.detection_sensibility( - self._serial, - level, - 3, - ) - else: - self.coordinator.ezviz_client.detection_sensibility( - self._serial, - level, - 0, - ) + self.coordinator.ezviz_client.detection_sensibility( + self._serial, + level, + self.sensitivity_type, + ) except (HTTPError, PyEzvizError) as err: raise HomeAssistantError( f"Cannot set detection sensitivity level on {self.name}" ) from err + + self.sensor_value = level + + def update(self) -> None: + """Fetch data from EZVIZ.""" + _LOGGER.debug("Updating %s", self.name) + try: + self.sensor_value = self.coordinator.ezviz_client.get_detection_sensibility( + self._serial, + str(self.sensitivity_type), + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode): + _LOGGER.debug("Failed to login to EZVIZ API") + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry_id) + ) + return + + except (InvalidURL, HTTPError, PyEzvizError) as error: + raise HomeAssistantError(f"Invalid response from API: {error}") from error diff --git a/requirements_all.txt b/requirements_all.txt index 07a4823291f..fc3f3d3ac01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1664,7 +1664,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.15 +pyezviz==0.2.0.17 # homeassistant.components.fibaro pyfibaro==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53b3213dfde..b67921742e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1222,7 +1222,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.15 +pyezviz==0.2.0.17 # homeassistant.components.fibaro pyfibaro==0.7.1 From 1fa2fb4639e3fa7caa00bec35603ece2de5a8abe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Jun 2023 18:42:40 +0200 Subject: [PATCH 136/857] Fix OTBR reset (#94157) --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/otbr/util.py | 5 +++ .../components/otbr/websocket_api.py | 6 ++++ homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/otbr/test_websocket_api.py | 33 +++++++++++++++++-- 7 files changed, 46 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index f04e15a549c..94659df8547 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.1.0"] + "requirements": ["python-otbr-api==2.2.0"] } diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 5caebba5eb5..2d6217ea585 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -95,6 +95,11 @@ class OTBRData: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) + @_handle_otbr_error + async def delete_active_dataset(self) -> None: + """Delete the active operational dataset.""" + return await self.api.delete_active_dataset() + @_handle_otbr_error async def set_active_dataset_tlvs(self, dataset: bytes) -> None: """Set current active operational dataset in TLVS format.""" diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 0dcce288348..06bbca3a4ab 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -81,6 +81,12 @@ async def websocket_create_network( connection.send_error(msg["id"], "set_enabled_failed", str(exc)) return + try: + await data.delete_active_dataset() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "delete_active_dataset_failed", str(exc)) + return + try: await data.create_active_dataset( python_otbr_api.ActiveDataSet( diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 9a6a64481cd..0ce54496539 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.1.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.2.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fc3f3d3ac01..e6a7d89dca3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2128,7 +2128,7 @@ python-opensky==0.0.8 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.1.0 +python-otbr-api==2.2.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b67921742e9..c1374e80c7e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1554,7 +1554,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.1.0 +python-otbr-api==2.2.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 1feebe9c02c..65bec9e8408 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -84,6 +84,8 @@ async def test_create_network( with patch( "python_otbr_api.OTBR.create_active_dataset" ) as create_dataset_mock, patch( + "python_otbr_api.OTBR.delete_active_dataset" + ) as delete_dataset_mock, patch( "python_otbr_api.OTBR.set_enabled" ) as set_enabled_mock, patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 @@ -99,6 +101,7 @@ async def test_create_network( create_dataset_mock.assert_called_once_with( python_otbr_api.models.ActiveDataSet(channel=15, network_name="home-assistant") ) + delete_dataset_mock.assert_called_once_with() assert len(set_enabled_mock.mock_calls) == 2 assert set_enabled_mock.mock_calls[0][1][0] is False assert set_enabled_mock.mock_calls[1][1][0] is True @@ -151,7 +154,7 @@ async def test_create_network_fails_2( ), patch( "python_otbr_api.OTBR.create_active_dataset", side_effect=python_otbr_api.OTBRError, - ): + ), patch("python_otbr_api.OTBR.delete_active_dataset"): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -171,6 +174,8 @@ async def test_create_network_fails_3( side_effect=[None, python_otbr_api.OTBRError], ), patch( "python_otbr_api.OTBR.create_active_dataset", + ), patch( + "python_otbr_api.OTBR.delete_active_dataset" ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -191,6 +196,8 @@ async def test_create_network_fails_4( ), patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, + ), patch( + "python_otbr_api.OTBR.delete_active_dataset" ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -208,7 +215,9 @@ async def test_create_network_fails_5( """Test create network.""" with patch("python_otbr_api.OTBR.set_enabled"), patch( "python_otbr_api.OTBR.create_active_dataset" - ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None): + ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None), patch( + "python_otbr_api.OTBR.delete_active_dataset" + ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -216,6 +225,26 @@ async def test_create_network_fails_5( assert msg["error"]["code"] == "get_active_dataset_tlvs_empty" +async def test_create_network_fails_6( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + with patch("python_otbr_api.OTBR.set_enabled"), patch( + "python_otbr_api.OTBR.create_active_dataset" + ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None), patch( + "python_otbr_api.OTBR.delete_active_dataset", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "delete_active_dataset_failed" + + async def test_set_network( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 7d8e4123142798950fda06c08ebdba5063d7e620 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 7 Jun 2023 19:53:45 +0200 Subject: [PATCH 137/857] Fix Abode unit of measurement (#94168) Change unit of measurement to HA const --- homeassistant/components/abode/sensor.py | 3 ++- tests/components/abode/test_sensor.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index 87a9f8e9a27..546d57ab3e7 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import LIGHT_LUX from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -71,7 +72,7 @@ class AbodeSensor(AbodeDevice, SensorEntity): elif description.key == CONST.HUMI_STATUS_KEY: self._attr_native_unit_of_measurement = device.humidity_unit elif description.key == CONST.LUX_STATUS_KEY: - self._attr_native_unit_of_measurement = device.lux_unit + self._attr_native_unit_of_measurement = LIGHT_LUX @property def native_value(self) -> float | None: diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py index 5d074de214f..67892dfafb4 100644 --- a/tests/components/abode/test_sensor.py +++ b/tests/components/abode/test_sensor.py @@ -39,7 +39,7 @@ async def test_attributes(hass: HomeAssistant) -> None: state = hass.states.get("sensor.environment_sensor_lux") assert state.state == "1.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "lux" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "lx" state = hass.states.get("sensor.environment_sensor_temperature") # Abodepy device JSON reports 19.5, but Home Assistant shows 19.4 From 8e9eb400bfb831c4238bb40f3fcced5001515870 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 7 Jun 2023 20:34:59 +0200 Subject: [PATCH 138/857] Refactor async_set_temperature for mqtt climate (#94166) --- homeassistant/components/mqtt/climate.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 275c024667f..d7ab709469c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -604,15 +604,8 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): return changed @abstractmethod - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set hvac mode.""" - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" - operation_mode: HVACMode | None - if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: - await self.async_set_hvac_mode(operation_mode) - changed = await self._set_climate_attribute( kwargs.get(ATTR_TEMPERATURE), CONF_TEMP_COMMAND_TOPIC, @@ -967,6 +960,13 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self.prepare_subscribe_topics(topics) + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + operation_mode: HVACMode | None + if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(operation_mode) + await super().async_set_temperature(**kwargs) + async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" From 88bfd9480058fe019d89b322296008594a78c74f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Jun 2023 18:36:22 -0500 Subject: [PATCH 139/857] Add support for ESPHome raw bluetooth advertisements (#94138) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/__init__.py | 8 ++-- .../components/esphome/bluetooth/__init__.py | 19 +++++++--- .../components/esphome/bluetooth/client.py | 37 ++++++++++--------- .../components/esphome/bluetooth/scanner.py | 22 ++++++++++- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 2 +- 8 files changed, 60 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 297ce9b7882..41b1b780a1a 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -82,13 +82,13 @@ DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" @callback def _async_check_firmware_version( - hass: HomeAssistant, device_info: EsphomeDeviceInfo + hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion ) -> None: """Create or delete an the ble_firmware_outdated issue.""" # ESPHome device_info.mac_address is the unique_id issue = f"ble_firmware_outdated-{device_info.mac_address}" if ( - not device_info.bluetooth_proxy_version + not device_info.bluetooth_proxy_feature_flags_compat(api_version) # If the device has a project name its up to that project # to tell them about the firmware version update so we don't notify here or (device_info.project_name and device_info.project_name not in PROJECT_URLS) @@ -360,7 +360,7 @@ async def async_setup_entry( # noqa: C901 if entry_data.device_info.name: reconnect_logic.name = entry_data.device_info.name - if device_info.bluetooth_proxy_version: + if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): entry_data.disconnect_callbacks.append( await async_connect_scanner(hass, entry, cli, entry_data) ) @@ -391,7 +391,7 @@ async def async_setup_entry( # noqa: C901 # Re-connection logic will trigger after this await cli.disconnect() else: - _async_check_firmware_version(hass, device_info) + _async_check_firmware_version(hass, device_info, entry_data.api_version) _async_check_using_api_password(hass, device_info, bool(password)) async def on_disconnect() -> None: diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index e62b54655c8..aea65f9358e 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -5,7 +5,7 @@ from collections.abc import Callable from functools import partial import logging -from aioesphomeapi import APIClient +from aioesphomeapi import APIClient, BluetoothProxyFeature from homeassistant.components.bluetooth import ( HaBluetoothConnector, @@ -59,13 +59,15 @@ async def async_connect_scanner( source = str(entry.unique_id) new_info_callback = async_get_advertisement_callback(hass) assert entry_data.device_info is not None - version = entry_data.device_info.bluetooth_proxy_version - connectable = version >= 2 + feature_flags = entry_data.device_info.bluetooth_proxy_feature_flags_compat( + entry_data.api_version + ) + connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS) _LOGGER.debug( - "%s [%s]: Connecting scanner version=%s, connectable=%s", + "%s [%s]: Connecting scanner feature_flags=%s, connectable=%s", entry.title, source, - version, + feature_flags, connectable, ) connector = HaBluetoothConnector( @@ -89,7 +91,12 @@ async def async_connect_scanner( async_register_scanner(hass, scanner, connectable), scanner.async_setup(), ] - await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) + if feature_flags & BluetoothProxyFeature.RAW_ADVERTISEMENTS: + await cli.subscribe_bluetooth_le_raw_advertisements( + scanner.async_on_raw_advertisements + ) + else: + await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) @hass_callback def _async_unload() -> None: diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 914021b467e..708d79e0eec 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, BLEConnectionError, + BluetoothProxyFeature, ) from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError from aioesphomeapi.core import BluetoothGATTAPIError @@ -42,10 +43,6 @@ CCCD_UUID = "00002902-0000-1000-8000-00805f9b34fb" CCCD_NOTIFY_BYTES = b"\x01\x00" CCCD_INDICATE_BYTES = b"\x02\x00" -MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 -MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 -MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE = 5 - DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -158,7 +155,10 @@ class ESPHomeClient(BaseBleakClient): self._disconnected_event: asyncio.Event | None = None device_info = self.entry_data.device_info assert device_info is not None - self._connection_version = device_info.bluetooth_proxy_version + self._device_info = device_info + self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( + self.entry_data.api_version + ) self._address_type = address_or_ble_device.details["address_type"] self._source_name = f"{config_entry.title} [{self._source}]" @@ -247,7 +247,7 @@ class ESPHomeClient(BaseBleakClient): self._mtu = domain_data.get_gatt_mtu_cache(self._address_as_int) has_cache = bool( dangerous_use_bleak_cache - and self._connection_version >= MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE + and self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING and domain_data.get_gatt_services_cache(self._address_as_int) and self._mtu ) @@ -319,7 +319,7 @@ class ESPHomeClient(BaseBleakClient): _on_bluetooth_connection_state, timeout=timeout, has_cache=has_cache, - version=self._connection_version, + feature_flags=self._feature_flags, address_type=self._address_type, ) ) @@ -397,9 +397,10 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + if not self._feature_flags & BluetoothProxyFeature.PAIRING: raise NotImplementedError( - "Pairing is not available in ESPHome with version {self._connection_version}." + "Pairing is not available in this version ESPHome; " + f"Upgrade the ESPHome version on the {self._device_info.name} device." ) response = await self._client.bluetooth_device_pair(self._address_as_int) if response.paired: @@ -413,9 +414,10 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + if not self._feature_flags & BluetoothProxyFeature.PAIRING: raise NotImplementedError( - "Unpairing is not available in ESPHome with version {self._connection_version}." + "Unpairing is not available in this version ESPHome; " + f"Upgrade the ESPHome version on the {self._device_info.name} device." ) response = await self._client.bluetooth_device_unpair(self._address_as_int) if response.success: @@ -441,7 +443,7 @@ class ESPHomeClient(BaseBleakClient): # because the esp has already wiped the services list to # save memory. if ( - self._connection_version >= MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE + self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING or dangerous_use_bleak_cache ) and (cached_services := domain_data.get_gatt_services_cache(address_as_int)): _LOGGER.debug( @@ -524,12 +526,11 @@ class ESPHomeClient(BaseBleakClient): """Clear the GATT cache.""" self.domain_data.clear_gatt_services_cache(self._address_as_int) self.domain_data.clear_gatt_mtu_cache(self._address_as_int) - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE: + if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING: _LOGGER.warning( - "On device cache clear is not available with ESPHome Bluetooth version %s, " - "version %s is needed; Only memory cache will be cleared", - self._connection_version, - MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE, + "On device cache clear is not available with this ESPHome version; " + "Upgrade the ESPHome version on the device %s; Only memory cache will be cleared", + self._device_info.name, ) return True response = await self._client.bluetooth_device_clear_cache(self._address_as_int) @@ -673,7 +674,7 @@ class ESPHomeClient(BaseBleakClient): lambda handle, data: callback(data), ) - if self._connection_version < MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE: + if not self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING: return # For connection v3 we are responsible for enabling notifications diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 6151ed30429..85ab991df4e 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -1,8 +1,8 @@ """Bluetooth scanner for esphome.""" from __future__ import annotations -from aioesphomeapi import BluetoothLEAdvertisement -from bluetooth_data_tools import int_to_bluetooth_address +from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement +from bluetooth_data_tools import int_to_bluetooth_address, parse_advertisement_data from homeassistant.components.bluetooth import BaseHaRemoteScanner from homeassistant.core import callback @@ -25,3 +25,21 @@ class ESPHomeScanner(BaseHaRemoteScanner): None, {"address_type": adv.address_type}, ) + + @callback + def async_on_raw_advertisements( + self, advertisements: list[BluetoothLERawAdvertisement] + ) -> None: + """Call the registered callback.""" + for adv in advertisements: + parsed = parse_advertisement_data((adv.data,)) + self._async_on_advertisement( + int_to_bluetooth_address(adv.address), + adv.rssi, + parsed.local_name, + parsed.service_uuids, + parsed.service_data, + parsed.manufacturer_data, + None, + {"address_type": adv.address_type}, + ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c6e430d7845..fa18c14aa46 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.9.0", + "aioesphomeapi==14.0.0", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index e6a7d89dca3..68eb41caacb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.9.0 +aioesphomeapi==14.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1374e80c7e..82dabe8af1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.9.0 +aioesphomeapi==14.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 23f140587c7..3f8df691573 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -63,7 +63,7 @@ def mock_device_info() -> DeviceInfo: return DeviceInfo( uses_password=False, name="test", - bluetooth_proxy_version=0, + legacy_bluetooth_proxy_version=0, mac_address="11:22:33:44:55:aa", esphome_version="1.0.0", ) From a6a2b8d29f7e0ac1b0a03117167cf47df2952b5c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 8 Jun 2023 02:46:01 +0200 Subject: [PATCH 140/857] Update pydantic to 1.10.9 (#94178) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 077a74c1b3a..f53a16b009d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.2.2 mock-open==1.4.0 mypy==1.3.0 pre-commit==3.1.0 -pydantic==1.10.8 +pydantic==1.10.9 pylint==2.17.4 pylint-per-file-ignores==1.1.0 pipdeptree==2.7.0 From 85a12c37ee809720ff95854906592177900cd53e Mon Sep 17 00:00:00 2001 From: hookedonunix <102025039+hookedonunix@users.noreply.github.com> Date: Thu, 8 Jun 2023 10:46:35 +1000 Subject: [PATCH 141/857] Sync Climate min/max temp with Google Assistant (#94143) * Sync climate min/max temp to Google Assistant * Improving coverage on TemperatureSettingTrait --- .../components/google_assistant/trait.py | 23 ++++- .../components/google_assistant/test_trait.py | 91 +++++++++++++++++-- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 6f7dbd2c3b0..e44f1597a9b 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -922,9 +922,28 @@ class TemperatureSettingTrait(_Trait): def sync_attributes(self): """Return temperature point and modes attributes for a sync request.""" response = {} - response["thermostatTemperatureUnit"] = _google_temp_unit( - self.hass.config.units.temperature_unit + attrs = self.state.attributes + unit = self.hass.config.units.temperature_unit + response["thermostatTemperatureUnit"] = _google_temp_unit(unit) + + min_temp = round( + TemperatureConverter.convert( + float(attrs[climate.ATTR_MIN_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) ) + max_temp = round( + TemperatureConverter.convert( + float(attrs[climate.ATTR_MAX_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + response["thermostatTemperatureRange"] = { + "minThresholdCelsius": min_temp, + "maxThresholdCelsius": max_temp, + } modes = self.climate_google_modes diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 33eac82a6ba..97dc4af7c36 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -854,14 +854,18 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: climate.HVACMode.HEAT, climate.HVACMode.HEAT_COOL, ], - climate.ATTR_MIN_TEMP: None, - climate.ATTR_MAX_TEMP: None, + climate.ATTR_MIN_TEMP: 45, + climate.ATTR_MAX_TEMP: 95, }, ), BASIC_CONFIG, ) assert trt.sync_attributes() == { "availableThermostatModes": ["off", "cool", "heat", "heatcool", "on"], + "thermostatTemperatureRange": { + "minThresholdCelsius": 7, + "maxThresholdCelsius": 35, + }, "thermostatTemperatureUnit": "F", } assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) @@ -893,14 +897,18 @@ async def test_temperature_setting_climate_no_modes(hass: HomeAssistant) -> None climate.HVACMode.AUTO, { climate.ATTR_HVAC_MODES: [], - climate.ATTR_MIN_TEMP: None, - climate.ATTR_MAX_TEMP: None, + climate.ATTR_MIN_TEMP: climate.DEFAULT_MIN_TEMP, + climate.ATTR_MAX_TEMP: climate.DEFAULT_MAX_TEMP, }, ), BASIC_CONFIG, ) assert trt.sync_attributes() == { "availableThermostatModes": ["heat"], + "thermostatTemperatureRange": { + "minThresholdCelsius": climate.DEFAULT_MIN_TEMP, + "maxThresholdCelsius": climate.DEFAULT_MAX_TEMP, + }, "thermostatTemperatureUnit": "C", } @@ -937,6 +945,10 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: ) assert trt.sync_attributes() == { "availableThermostatModes": ["off", "cool", "heat", "auto", "on"], + "thermostatTemperatureRange": { + "minThresholdCelsius": 10, + "maxThresholdCelsius": 27, + }, "thermostatTemperatureUnit": "F", } assert trt.query_attributes() == { @@ -978,12 +990,40 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: with pytest.raises(helpers.SmartHomeError) as err: await trt.execute( - trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, BASIC_DATA, - {"thermostatTemperatureSetpoint": -100}, + { + "thermostatTemperatureSetpointHigh": 26, + "thermostatTemperatureSetpointLow": -100, + }, {}, ) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + BASIC_DATA, + { + "thermostatTemperatureSetpointHigh": 100, + "thermostatTemperatureSetpointLow": 18, + }, + {}, + ) + assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE + + calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + BASIC_DATA, + {"thermostatTemperatureSetpoint": 23.9}, + {}, + ) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: "climate.bla", + climate.ATTR_TEMPERATURE: 75, + } hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS @@ -1000,9 +1040,11 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None "climate.bla", climate.HVACMode.COOL, { + ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE, climate.ATTR_HVAC_MODES: [STATE_OFF, climate.HVACMode.COOL], climate.ATTR_MIN_TEMP: 10, climate.ATTR_MAX_TEMP: 30, + climate.ATTR_PRESET_MODE: climate.PRESET_ECO, ATTR_TEMPERATURE: 18, climate.ATTR_CURRENT_TEMPERATURE: 20, }, @@ -1011,10 +1053,14 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None ) assert trt.sync_attributes() == { "availableThermostatModes": ["off", "cool", "on"], + "thermostatTemperatureRange": { + "minThresholdCelsius": 10, + "maxThresholdCelsius": 30, + }, "thermostatTemperatureUnit": "C", } assert trt.query_attributes() == { - "thermostatMode": "cool", + "thermostatMode": "eco", "thermostatTemperatureAmbient": 20, "thermostatTemperatureSetpoint": 18, } @@ -1022,7 +1068,6 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) - with pytest.raises(helpers.SmartHomeError): await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, @@ -1040,6 +1085,32 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None assert len(calls) == 1 assert calls[0].data == {ATTR_ENTITY_ID: "climate.bla", ATTR_TEMPERATURE: 19} + calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_SET_PRESET_MODE) + await trt.execute( + trait.COMMAND_THERMOSTAT_SET_MODE, + BASIC_DATA, + {"thermostatMode": "eco"}, + {}, + ) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: "climate.bla", + climate.ATTR_PRESET_MODE: "eco", + } + + calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + BASIC_DATA, + { + "thermostatTemperatureSetpointHigh": 15, + "thermostatTemperatureSetpointLow": 22, + }, + {}, + ) + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "climate.bla", ATTR_TEMPERATURE: 18.5} + async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> None: """Test TemperatureSetting trait support for climate domain. @@ -1068,6 +1139,10 @@ async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> ) assert trt.sync_attributes() == { "availableThermostatModes": ["off", "heatcool", "on"], + "thermostatTemperatureRange": { + "minThresholdCelsius": 10, + "maxThresholdCelsius": 30, + }, "thermostatTemperatureUnit": "C", } assert trt.query_attributes() == { From 99ee4e8a427c3b267415d56096973fbf2fe07d72 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 8 Jun 2023 09:39:06 +0200 Subject: [PATCH 142/857] Set httpx log level to warning (#94217) Set log level of httpx to warning --- homeassistant/bootstrap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7e5aa853f12..6a667884962 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -391,6 +391,7 @@ def async_enable_logging( logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("aiohttp.access").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) sys.excepthook = lambda *args: logging.getLogger(None).exception( "Uncaught exception", exc_info=args # type: ignore[arg-type] From 52ba58c782b7d49e350d3358b313bc70ea279980 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 8 Jun 2023 00:41:45 -0700 Subject: [PATCH 143/857] Require pydantic 1.10.8 or higher (#94208) * Requied pydantic 1.10.9 or higher * Simplify constraint to 2.0 * Drop constraint by one patch release to 1.10.8 or higher * Add package constraints to gen requirements script --- homeassistant/package_constraints.txt | 5 ++--- script/gen_requirements_all.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d183395ebfc..0d46744d2ad 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -128,9 +128,8 @@ authlib<1.0 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Breaking change in version -# https://github.com/samuelcolvin/pydantic/issues/4092 -pydantic!=1.9.1 +# Require to avoid issues with decorators (#93904). v2 has breaking changes. +pydantic>=1.10.8,<2.0 # Breaks asyncio # https://github.com/pubnub/python/issues/130 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ca39d78c4c6..e71cdcf7bc1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -132,9 +132,8 @@ authlib<1.0 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Breaking change in version -# https://github.com/samuelcolvin/pydantic/issues/4092 -pydantic!=1.9.1 +# Require to avoid issues with decorators (#93904). v2 has breaking changes. +pydantic>=1.10.8,<2.0 # Breaks asyncio # https://github.com/pubnub/python/issues/130 From f06ab9167866345633c55f4beb68e109d89c2736 Mon Sep 17 00:00:00 2001 From: James Connor Date: Thu, 8 Jun 2023 08:43:30 +0100 Subject: [PATCH 144/857] Fix ambiclimate for Python 3.11 (#94203) Fix ambiclimate python 3.11 break --- homeassistant/components/ambiclimate/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 2bb2b441430..516ed319d01 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -98,7 +98,7 @@ async def async_setup_entry( tasks = [] for heater in data_connection.get_devices(): - tasks.append(heater.update_device_info()) + tasks.append(asyncio.create_task(heater.update_device_info())) await asyncio.wait(tasks) devs = [] From 08a85e35e2aeeb7eb027d672292f79af6c2dc920 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Jun 2023 10:58:13 +0200 Subject: [PATCH 145/857] Bump docker/login-action from 2.1.0 to 2.2.0 (#94221) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 421579951d4..a6997b58139 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -198,13 +198,13 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to DockerHub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -276,13 +276,13 @@ jobs: fi - name: Login to DockerHub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -350,14 +350,14 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'homeassistant' - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 7a195b5291d00fedd6d92d56023baad8f768fd67 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 8 Jun 2023 11:11:12 +0200 Subject: [PATCH 146/857] Fix imap crash on email without subject (#94230) --- homeassistant/components/imap/coordinator.py | 2 +- tests/components/imap/const.py | 20 ++++++++++ tests/components/imap/test_init.py | 41 +++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index d41aaf8c497..bf7f173e647 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -120,7 +120,7 @@ class ImapMessage: @property def subject(self) -> str: """Decode the message subject.""" - decoded_header = decode_header(self.email_message["Subject"]) + decoded_header = decode_header(self.email_message["Subject"] or "") subject_header = make_header(decoded_header) return str(subject_header) diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 15b56547894..5dcce782a41 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -24,7 +24,12 @@ TEST_MESSAGE_HEADERS2 = ( b"Subject: Test subject\r\n" ) +TEST_MESSAGE_HEADERS3 = b"" + TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 +TEST_MESSAGE_NO_SUBJECT_TO_FROM = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS3 +) TEST_MESSAGE_ALT = TEST_MESSAGE_HEADERS1 + DATE_HEADER2 + TEST_MESSAGE_HEADERS2 TEST_INVALID_DATE1 = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID1 + TEST_MESSAGE_HEADERS2 @@ -204,4 +209,19 @@ TEST_FETCH_RESPONSE_MULTIPART = ( ], ) + +TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_NO_SUBJECT_TO_FROM + TEST_CONTENT_TEXT_PLAIN)).encode( + "utf-8" + ) + + b"}", + bytearray(TEST_MESSAGE_NO_SUBJECT_TO_FROM + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + RESPONSE_BAD = ("BAD", []) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 712f159b4cb..2b7514cd3ea 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,6 +1,6 @@ """Test the imap entry initialization.""" import asyncio -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -22,6 +22,7 @@ from .const import ( TEST_FETCH_RESPONSE_INVALID_DATE2, TEST_FETCH_RESPONSE_INVALID_DATE3, TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, TEST_FETCH_RESPONSE_TEXT_PLAIN, @@ -153,6 +154,44 @@ async def test_receiving_message_successfully( ) +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize("imap_fetch", [TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM]) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_receiving_message_no_subject_to_from( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully without subject, to and from in body.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "" + assert data["subject"] == "" + assert data["date"] == datetime( + 2023, 3, 24, 13, 52, tzinfo=timezone(timedelta(seconds=3600)) + ) + assert data["text"] == "Test body\r\n\r\n" + assert data["headers"]["Return-Path"] == ("",) + assert data["headers"]["Delivered-To"] == ("notify@example.com",) + + @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize( ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] From 00ecf51091fe24baba618f5bc03bf66018aa780f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 8 Jun 2023 11:13:24 +0200 Subject: [PATCH 147/857] Bump python-opensky to 0.0.9 (#94224) --- homeassistant/components/opensky/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 460453968b6..cda86006bbd 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@joostlek"], "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.0.8"] + "requirements": ["python-opensky==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68eb41caacb..da695a848fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2124,7 +2124,7 @@ python-nest==4.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.0.8 +python-opensky==0.0.9 # homeassistant.components.otbr # homeassistant.components.thread From 05e822b7a3123a3b237aa76f4ffdd930536e0a24 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 8 Jun 2023 11:29:54 +0200 Subject: [PATCH 148/857] Solve wrong return code from modbus. (#94234) --- homeassistant/components/flexit/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index ac8f4b4da8c..838d2c934f9 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -192,7 +192,7 @@ class Flexit(ClimateEntity): result = float( await self._async_read_int16_from_register(register_type, register) ) - if result == -1: + if not result: return -1 return result / 10.0 @@ -200,6 +200,6 @@ class Flexit(ClimateEntity): result = await self._hub.async_pymodbus_call( self._slave, register, value, CALL_TYPE_WRITE_REGISTER ) - if result == -1: + if not result: return False return True From 5a90d1804163955ec0fa57e637bc47fde6231f08 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Thu, 8 Jun 2023 11:33:35 +0200 Subject: [PATCH 149/857] Bump pyoverkiz to 1.8.0 (#94176) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 2d81b7bab07..b03b60cd753 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.7.9"], + "requirements": ["pyoverkiz==1.8.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index da695a848fa..8429f60e19d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1894,7 +1894,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.9 +pyoverkiz==1.8.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82dabe8af1a..b880c24c0d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1401,7 +1401,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.9 +pyoverkiz==1.8.0 # homeassistant.components.openweathermap pyowm==3.2.0 From 081bc470db08326d0d8e9dde8b6dbd999f95e023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 8 Jun 2023 11:34:56 +0200 Subject: [PATCH 150/857] Update aioairzone-cloud to v0.1.8 (#94223) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index b2899a7c80c..e64a5d9a7e2 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.1.7"] + "requirements": ["aioairzone-cloud==0.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8429f60e19d..31ae8a1dc12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,7 +191,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.7 +aioairzone-cloud==0.1.8 # homeassistant.components.airzone aioairzone==0.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b880c24c0d3..e842ea0057a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,7 +169,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.7 +aioairzone-cloud==0.1.8 # homeassistant.components.airzone aioairzone==0.6.3 From 77badafea153ca1ca38ded9ae63faabb5800628d Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 8 Jun 2023 14:10:12 +0100 Subject: [PATCH 151/857] Bump aiohomekit to 2.6.5 (fixes python 3.11 regression) (#94245) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 89261df8751..19167e762e9 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.4"], + "requirements": ["aiohomekit==2.6.5"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 31ae8a1dc12..55225b9bfb5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -252,7 +252,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.4 +aiohomekit==2.6.5 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e842ea0057a..1b7103467ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.4 +aiohomekit==2.6.5 # homeassistant.components.emulated_hue # homeassistant.components.http From 425c9648989d9d79c997dc6ef7a55b7f1305d00a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 8 Jun 2023 15:55:09 +0200 Subject: [PATCH 152/857] Retrieve friends in an async manner in Lastfm (#94255) --- homeassistant/components/lastfm/config_flow.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index f7d7a9fd314..3f00443147d 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -128,9 +128,12 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): main_user, _ = get_lastfm_user( self.data[CONF_API_KEY], self.data[CONF_MAIN_USER] ) + friends_response = await self.hass.async_add_executor_job( + main_user.get_friends + ) friends = [ SelectOptionDict(value=friend.name, label=friend.get_name(True)) - for friend in main_user.get_friends() + for friend in friends_response ] except WSError: friends = [] @@ -197,9 +200,12 @@ class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): self.options[CONF_API_KEY], self.options[CONF_MAIN_USER], ) + friends_response = await self.hass.async_add_executor_job( + main_user.get_friends + ) friends = [ SelectOptionDict(value=friend.name, label=friend.get_name(True)) - for friend in main_user.get_friends() + for friend in friends_response ] except WSError: friends = [] From d2edfca2a28062c41e29cdf86de0766d7c061878 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Jun 2023 09:56:49 -0400 Subject: [PATCH 153/857] Rename Local Media to My Media (#94201) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- homeassistant/components/media_source/local_source.py | 2 +- tests/components/media_source/test_init.py | 2 +- tests/components/roku/test_media_player.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index c29794ae8d7..89437a6b2e0 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -38,7 +38,7 @@ def async_setup(hass: HomeAssistant) -> None: class LocalSource(MediaSource): """Provide local directories as media sources.""" - name: str = "Local Media" + name: str = "My media" def __init__(self, hass: HomeAssistant) -> None: """Initialize local source.""" diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index ec374e6a6e1..4e512608abf 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -95,7 +95,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: media = await media_source.async_browse_media(hass, const.URI_SCHEME) assert isinstance(media, media_source.models.BrowseMediaSource) assert len(media.children) == 1 - assert media.children[0].title == "Local Media" + assert media.children[0].title == "My media" async def test_async_resolve_media(hass: HomeAssistant) -> None: diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 1363cf7e286..5d4568ce7ac 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -864,7 +864,7 @@ async def test_media_browse_local_source( assert msg["result"]["children"][0]["title"] == "Apps" assert msg["result"]["children"][0]["media_content_type"] == MediaType.APPS - assert msg["result"]["children"][1]["title"] == "Local Media" + assert msg["result"]["children"][1]["title"] == "My media" assert msg["result"]["children"][1]["media_class"] == MediaClass.DIRECTORY assert msg["result"]["children"][1]["media_content_type"] is None assert ( @@ -892,7 +892,7 @@ async def test_media_browse_local_source( assert msg["success"] assert msg["result"] - assert msg["result"]["title"] == "Local Media" + assert msg["result"]["title"] == "My media" assert msg["result"]["media_class"] == MediaClass.DIRECTORY assert msg["result"]["media_content_type"] is None assert len(msg["result"]["children"]) == 2 From 48607d05863da785e2272ff70ba0b705fe8ff5ef Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 8 Jun 2023 15:57:49 +0200 Subject: [PATCH 154/857] Bump pymodbus to 3.3.1 (#94162) --- homeassistant/components/modbus/binary_sensor.py | 5 ++++- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/modbus/test_init.py | 1 - 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 4f416874f9d..43f43585775 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -115,7 +115,10 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): self._result = result.bits else: self._result = result.registers - self._attr_is_on = bool(self._result[0] & 1) + if len(self._result) >= 1: + self._attr_is_on = bool(self._result[0] & 1) + else: + self._attr_available = False self.async_write_ha_state() if self._coordinator: diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index bb64a264248..c2e6b9ef467 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.1.3"] + "requirements": ["pymodbus==3.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 55225b9bfb5..2588f88fe0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1829,7 +1829,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.1.3 +pymodbus==3.3.1 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b7103467ec..310a50ec9c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1348,7 +1348,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.1.3 +pymodbus==3.3.1 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 7a069234045..2daf722bb05 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -705,7 +705,6 @@ async def test_pymodbus_connect_fail( ExceptionMessage = "test connect exception" mock_pymodbus.connect.side_effect = ModbusException(ExceptionMessage) assert await async_setup_component(hass, DOMAIN, config) is True - assert ExceptionMessage in caplog.text async def test_delay( From 2f2504b53446ddb352e47212352ecdaf28b1faa6 Mon Sep 17 00:00:00 2001 From: Kostas Chatzikokolakis Date: Thu, 8 Jun 2023 16:59:16 +0300 Subject: [PATCH 155/857] Bump pulsectl to 23.5.2 (#94227) --- homeassistant/components/pulseaudio_loopback/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pulseaudio_loopback/manifest.json b/homeassistant/components/pulseaudio_loopback/manifest.json index f04538c01bb..a67dc614c50 100644 --- a/homeassistant/components/pulseaudio_loopback/manifest.json +++ b/homeassistant/components/pulseaudio_loopback/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/pulseaudio_loopback", "iot_class": "local_polling", - "requirements": ["pulsectl==20.2.4"] + "requirements": ["pulsectl==23.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2588f88fe0f..2bd15721343 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1459,7 +1459,7 @@ psutil-home-assistant==0.0.1 psutil==5.9.5 # homeassistant.components.pulseaudio_loopback -pulsectl==20.2.4 +pulsectl==23.5.2 # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 From 758eb5e510bca4ed2e50af0356e04ff19ed3239d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 8 Jun 2023 15:59:56 +0200 Subject: [PATCH 156/857] Update frontend to 20230608.0 (#94256) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index af8898f28e2..00753021b4c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230607.0"] + "requirements": ["home-assistant-frontend==20230608.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0d46744d2ad..f30433d0a79 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230607.0 +home-assistant-frontend==20230608.0 home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2bd15721343..270cf25609c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230607.0 +home-assistant-frontend==20230608.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 310a50ec9c3..772e1fadc90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -767,7 +767,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230607.0 +home-assistant-frontend==20230608.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 From 20449d1f018a665874bafc399e6169220384c916 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 8 Jun 2023 16:08:18 +0200 Subject: [PATCH 157/857] Catch exception when user has no lastfm friends (#94235) --- homeassistant/components/lastfm/config_flow.py | 6 +++--- tests/components/lastfm/__init__.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 3f00443147d..54406a6e03b 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from pylast import LastFMNetwork, User, WSError +from pylast import LastFMNetwork, PyLastError, User, WSError import voluptuous as vol from homeassistant.config_entries import ( @@ -135,7 +135,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): SelectOptionDict(value=friend.name, label=friend.get_name(True)) for friend in friends_response ] - except WSError: + except PyLastError: friends = [] return self.async_show_form( step_id="friends", @@ -207,7 +207,7 @@ class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): SelectOptionDict(value=friend.name, label=friend.get_name(True)) for friend in friends_response ] - except WSError: + except PyLastError: friends = [] else: friends = [] diff --git a/tests/components/lastfm/__init__.py b/tests/components/lastfm/__init__.py index 568983f400d..7ee8665e28a 100644 --- a/tests/components/lastfm/__init__.py +++ b/tests/components/lastfm/__init__.py @@ -1,7 +1,7 @@ """The tests for lastfm.""" from unittest.mock import patch -from pylast import Track, WSError +from pylast import PyLastError, Track from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS from homeassistant.const import CONF_API_KEY @@ -65,7 +65,7 @@ class MockUser: def get_friends(self): """Get mock friends.""" if self._has_friends is False: - raise WSError("network", "status", "Page not found") + raise PyLastError("network", "status", "Page not found") return [MockUser(None, None, True, USERNAME_2)] From c6977316573c8cbcec8bc386287fefcac6624ee0 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Thu, 8 Jun 2023 11:08:52 -0400 Subject: [PATCH 158/857] Bump unifiprotect to 4.10.2 (#94263) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 78e2ee3012c..a414c03a0d4 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.10.1", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.2", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 270cf25609c..1d788cc4ae9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.1 +pyunifiprotect==4.10.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 772e1fadc90..f81b4b4a7fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.1 +pyunifiprotect==4.10.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 18cbc9b4c3c2ed944491aa2ff97030b8da686fb7 Mon Sep 17 00:00:00 2001 From: hookedonunix <102025039+hookedonunix@users.noreply.github.com> Date: Fri, 9 Jun 2023 01:38:30 +1000 Subject: [PATCH 159/857] Add Water Heater platform to MQTT integration (#93644) * Add Water Heater entity to MQTT * Adding tests for Water Heater * Remove duplicate line and unneeded var declaration * Remove target temp range and away mode * Move common Temperature Control conf to const * Remove unnecessary platform schema and temp check * Add common value template key test * Use MqttTemperatureControl in Water Heater * Move operation mode out of MqttTemperatureControl * Remove unecessary attribute declaration * Remove default min/max temp and auto init attr * Fix mqtt water heater initial temp conversion * Make async_set_temperature common * Fix init temp comment * Merge value_template_keys into get_with_templates * Remove unnecessary operation_mode overriding * Add async_set_temperature to water heater * Fix docstring comments --- homeassistant/components/mqtt/climate.py | 32 +- .../components/mqtt/config_integration.py | 5 + homeassistant/components/mqtt/const.py | 18 + homeassistant/components/mqtt/discovery.py | 1 + homeassistant/components/mqtt/water_heater.py | 318 +++++ tests/components/mqtt/test_climate.py | 3 + tests/components/mqtt/test_water_heater.py | 1109 +++++++++++++++++ 7 files changed, 1470 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/mqtt/water_heater.py create mode 100644 tests/components/mqtt/test_water_heater.py diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index d7ab709469c..ba70836cac9 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -52,9 +52,24 @@ from homeassistant.util.unit_conversion import TemperatureConverter from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, CONF_ENCODING, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, + CONF_PRECISION, CONF_QOS, CONF_RETAIN, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) @@ -86,8 +101,6 @@ CONF_AWAY_MODE_STATE_TOPIC = "away_mode_state_topic" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" -CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" -CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" CONF_FAN_MODE_LIST = "fan_modes" @@ -107,11 +120,6 @@ CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" -CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" -CONF_MODE_COMMAND_TOPIC = "mode_command_topic" -CONF_MODE_LIST = "modes" -CONF_MODE_STATE_TEMPLATE = "mode_state_template" -CONF_MODE_STATE_TOPIC = "mode_state_topic" # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE @@ -120,7 +128,6 @@ CONF_MODE_STATE_TOPIC = "mode_state_topic" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_STATE_TEMPLATE = "power_state_template" CONF_POWER_STATE_TOPIC = "power_state_topic" -CONF_PRECISION = "precision" CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" @@ -133,8 +140,6 @@ CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" -CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" -CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" @@ -143,11 +148,6 @@ CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template" CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic" CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template" CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic" -CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" -CONF_TEMP_STATE_TOPIC = "temperature_state_topic" -CONF_TEMP_INITIAL = "initial" -CONF_TEMP_MAX = "max_temp" -CONF_TEMP_MIN = "min_temp" CONF_TEMP_STEP = "temp_step" DEFAULT_INITIAL_TEMPERATURE = 21.0 @@ -475,7 +475,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): ) -> None: """Add a subscription.""" qos: int = self._config[CONF_QOS] - if self._topic[topic] is not None: + if topic in self._topic and self._topic[topic] is not None: topics[topic] = { "topic": self._topic[topic], "msg_callback": msg_callback, diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 469f52e1488..fdc32a601e0 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -35,6 +35,7 @@ from . import ( text as text_platform, update as update_platform, vacuum as vacuum_platform, + water_heater as water_heater_platform, ) from .const import ( CONF_BIRTH_MESSAGE, @@ -132,6 +133,10 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [vacuum_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.WATER_HEATER.value: vol.All( + cv.ensure_list, + [water_heater_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] + ), } ) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c91c54a79a4..a8d7812965c 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -29,6 +29,22 @@ CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" +CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" +CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" +CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" +CONF_MODE_COMMAND_TOPIC = "mode_command_topic" +CONF_MODE_LIST = "modes" +CONF_MODE_STATE_TEMPLATE = "mode_state_template" +CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_PRECISION = "precision" +CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" +CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" +CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" +CONF_TEMP_STATE_TOPIC = "temperature_state_topic" +CONF_TEMP_INITIAL = "initial" +CONF_TEMP_MAX = "max_temp" +CONF_TEMP_MIN = "min_temp" + CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" @@ -106,6 +122,7 @@ PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] RELOADABLE_PLATFORMS = [ @@ -129,4 +146,5 @@ RELOADABLE_PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0c0032ec8eb..0411a1f679c 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -66,6 +66,7 @@ SUPPORTED_COMPONENTS = [ "text", "update", "vacuum", + "water_heater", ] MQTT_DISCOVERY_UPDATED = "mqtt_discovery_updated_{}" diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py new file mode 100644 index 00000000000..0f622d55b84 --- /dev/null +++ b/homeassistant/components/mqtt/water_heater.py @@ -0,0 +1,318 @@ +"""Support for MQTT water heater devices.""" +from __future__ import annotations + +import functools +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import water_heater +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + DEFAULT_MIN_TEMP, + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + CONF_OPTIMISTIC, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_TEMPERATURE_UNIT, + CONF_VALUE_TEMPLATE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + STATE_OFF, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.unit_conversion import TemperatureConverter + +from .climate import MqttTemperatureControlEntity +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA +from .const import ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, + CONF_PRECISION, + CONF_RETAIN, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + DEFAULT_OPTIMISTIC, +) +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, async_setup_entry_helper +from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Water Heater" + +MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED = frozenset( + { + water_heater.ATTR_CURRENT_TEMPERATURE, + water_heater.ATTR_MAX_TEMP, + water_heater.ATTR_MIN_TEMP, + water_heater.ATTR_TEMPERATURE, + water_heater.ATTR_OPERATION_LIST, + water_heater.ATTR_OPERATION_MODE, + } +) + +VALUE_TEMPLATE_KEYS = ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_MODE_STATE_TEMPLATE, + CONF_TEMP_STATE_TEMPLATE, +) + +COMMAND_TEMPLATE_KEYS = { + CONF_MODE_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TEMPLATE, +} + + +TOPIC_KEYS = ( + CONF_CURRENT_TEMP_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_STATE_TOPIC, +) + + +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_TEMP_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional( + CONF_MODE_LIST, + default=[ + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + STATE_OFF, + ], + ): cv.ensure_list, + vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + ), + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_TEMP_INITIAL): cv.positive_int, + vol.Optional(CONF_TEMP_MIN): vol.Coerce(float), + vol.Optional(CONF_TEMP_MAX): vol.Coerce(float), + vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_TEMP_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_TEMP_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMP_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All( + _PLATFORM_SCHEMA_BASE, +) + +_DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) + +DISCOVERY_SCHEMA = vol.All( + _DISCOVERY_SCHEMA_BASE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT water heater device through YAML and through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, water_heater.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up the MQTT water heater devices.""" + async_add_entities([MqttWaterHeater(hass, config, config_entry, discovery_data)]) + + +class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): + """Representation of an MQTT water heater device.""" + + _entity_id_format = water_heater.ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the water heater device.""" + MqttTemperatureControlEntity.__init__( + self, hass, config, config_entry, discovery_data + ) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._attr_operation_list = config[CONF_MODE_LIST] + self._attr_temperature_unit = config.get( + CONF_TEMPERATURE_UNIT, self.hass.config.units.temperature_unit + ) + if (min_temp := config.get(CONF_TEMP_MIN)) is not None: + self._attr_min_temp = min_temp + if (max_temp := config.get(CONF_TEMP_MAX)) is not None: + self._attr_max_temp = max_temp + if (precision := config.get(CONF_PRECISION)) is not None: + self._attr_precision = precision + + self._topic = {key: config.get(key) for key in TOPIC_KEYS} + + self._optimistic = config[CONF_OPTIMISTIC] + + # Set init temp, if it is missing convert the default to the temperature units + init_temp: float = config.get( + CONF_TEMP_INITIAL, + TemperatureConverter.convert( + DEFAULT_MIN_TEMP, + UnitOfTemperature.FAHRENHEIT, + self.temperature_unit, + ), + ) + if self._topic[CONF_TEMP_STATE_TOPIC] is None or self._optimistic: + self._attr_target_temperature = init_temp + if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: + self._attr_current_operation = STATE_OFF + + value_templates: dict[str, Template | None] = {} + for key in VALUE_TEMPLATE_KEYS: + value_templates[key] = None + if CONF_VALUE_TEMPLATE in config: + value_templates = { + key: config.get(CONF_VALUE_TEMPLATE) for key in VALUE_TEMPLATE_KEYS + } + for key in VALUE_TEMPLATE_KEYS & config.keys(): + value_templates[key] = config[key] + self._value_templates = { + key: MqttValueTemplate( + template, + entity=self, + ).async_render_with_possible_json_value + for key, template in value_templates.items() + } + + self._command_templates = {} + for key in COMMAND_TEMPLATE_KEYS: + self._command_templates[key] = MqttCommandTemplate( + config.get(key), entity=self + ).async_render + + support = WaterHeaterEntityFeature(0) + if (self._topic[CONF_TEMP_STATE_TOPIC] is not None) or ( + self._topic[CONF_TEMP_COMMAND_TOPIC] is not None + ): + support |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + + if (self._topic[CONF_MODE_STATE_TOPIC] is not None) or ( + self._topic[CONF_MODE_COMMAND_TOPIC] is not None + ): + support |= WaterHeaterEntityFeature.OPERATION_MODE + + self._attr_supported_features = support + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics: dict[str, dict[str, Any]] = {} + + @callback + def handle_mode_received( + msg: ReceiveMessage, template_name: str, attr: str, mode_list: str + ) -> None: + """Handle receiving listed mode via MQTT.""" + payload = self.render_template(msg, template_name) + + if payload not in self._config[mode_list]: + _LOGGER.error("Invalid %s mode: %s", mode_list, payload) + else: + setattr(self, attr, payload) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_current_mode_received(msg: ReceiveMessage) -> None: + """Handle receiving operation mode via MQTT.""" + handle_mode_received( + msg, + CONF_MODE_STATE_TEMPLATE, + "_attr_current_operation", + CONF_MODE_LIST, + ) + + self.add_subscription( + topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + ) + + self.prepare_subscribe_topics(topics) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + operation_mode: str | None + if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None: + await self.async_set_operation_mode(operation_mode) + await super().async_set_temperature(**kwargs) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + payload = self._command_templates[CONF_MODE_COMMAND_TEMPLATE](operation_mode) + await self._publish(CONF_MODE_COMMAND_TOPIC, payload) + + if self._optimistic or self._topic[CONF_MODE_STATE_TOPIC] is None: + self._attr_current_operation = operation_mode + self.async_write_ha_state() diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 452a9b862ff..ce27a479308 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1333,6 +1333,9 @@ async def test_get_target_temperature_low_high_with_templates( # By default, just unquote the JSON-strings "value_template": "{{ value_json }}", "action_template": "{{ value_json }}", + "current_humidity_template": "{{ value_json }}", + "current_temperature_template": "{{ value_json }}", + "temperature_state_template": "{{ value_json }}", # Rendering to a bool for aux heat "aux_state_template": "{{ value == 'switchmeon' }}", # Rendering preset_mode diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py new file mode 100644 index 00000000000..942a2ec87d4 --- /dev/null +++ b/tests/components/mqtt/test_water_heater.py @@ -0,0 +1,1109 @@ +"""The tests for the mqtt water heater component.""" +import copy +import json +from typing import Any +from unittest.mock import call, patch + +import pytest +import voluptuous as vol + +from homeassistant.components import mqtt, water_heater +from homeassistant.components.mqtt.water_heater import ( + MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED, +) +from homeassistant.components.water_heater import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_conversion import TemperatureConverter + +from .test_common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.components.water_heater import common +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +ENTITY_WATER_HEATER = "water_heater.test" + + +_DEFAULT_MIN_TEMP_CELSIUS = round( + TemperatureConverter.convert( + DEFAULT_MIN_TEMP, + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.CELSIUS, + ), + 1, +) +_DEFAULT_MAX_TEMP_CELSIUS = round( + TemperatureConverter.convert( + DEFAULT_MAX_TEMP, + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.CELSIUS, + ), + 1, +) + + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "temperature_command_topic": "temperature-topic", + } + } +} + + +@pytest.fixture(autouse=True) +def water_heater_platform_only(): + """Only setup the water heater platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.WATER_HEATER]): + yield + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_params( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the initial parameters.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.state == "off" + # default water heater min/max temp in celsius + assert state.attributes.get("min_temp") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.attributes.get("max_temp") == _DEFAULT_MAX_TEMP_CELSIUS + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_supported_features( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the supported_features.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + support = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + assert state.attributes.get("supported_features") == support + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_get_operation_modes( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that the operation list returns the correct modes.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert [ + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + STATE_OFF, + ] == state.attributes.get("operation_list") + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_set_operation_mode_bad_attr_and_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting operation mode without required attribute.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + with pytest.raises(vol.Invalid) as excinfo: + await common.async_set_operation_mode(hass, None, ENTITY_WATER_HEATER) + assert "string value is None for dictionary value @ data['operation_mode']" in str( + excinfo.value + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_set_operation( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting of new operation mode.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + await common.async_set_operation_mode(hass, "eco", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + mqtt_mock.async_publish.assert_called_once_with("mode-topic", "eco", 0, False) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"mode_state_topic": "mode-state"},), + ) + ], +) +async def test_set_operation_pessimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting operation mode in pessimistic mode.""" + await hass.async_block_till_done() + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "unknown" + + await common.async_set_operation_mode(hass, "eco", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "unknown" + + async_fire_mqtt_message(hass, "mode-state", "eco") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + + async_fire_mqtt_message(hass, "mode-state", "bogus mode") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "mode_state_topic": "mode-state", + "optimistic": True, + }, + ), + ) + ], +) +async def test_set_operation_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting operation mode in optimistic mode.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + + await common.async_set_operation_mode(hass, "electric", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "electric" + + async_fire_mqtt_message(hass, "mode-state", "performance") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "performance" + + async_fire_mqtt_message(hass, "mode-state", "bogus mode") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "performance" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_set_target_temperature( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting the target temperature.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + await common.async_set_operation_mode(hass, "performance", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "performance" + mqtt_mock.async_publish.assert_called_once_with( + "mode-topic", "performance", 0, False + ) + mqtt_mock.async_publish.reset_mock() + await common.async_set_temperature( + hass, temperature=50, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 50 + mqtt_mock.async_publish.assert_called_once_with( + "temperature-topic", "50.0", 0, False + ) + + # also test directly supplying the operation mode to set_temperature + mqtt_mock.async_publish.reset_mock() + await common.async_set_temperature( + hass, temperature=47, operation_mode="eco", entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + assert state.attributes.get("temperature") == 47 + mqtt_mock.async_publish.assert_has_calls( + [ + call("mode-topic", "eco", 0, False), + call("temperature-topic", "47.0", 0, False), + ] + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state"},), + ) + ], +) +async def test_set_target_temperature_pessimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting the target temperature.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") is None + await common.async_set_operation_mode(hass, "performance", ENTITY_WATER_HEATER) + await common.async_set_temperature( + hass, temperature=60, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") is None + + async_fire_mqtt_message(hass, "temperature-state", "1701") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 1701 + + async_fire_mqtt_message(hass, "temperature-state", "not a number") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 1701 + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state", "optimistic": True},), + ) + ], +) +async def test_set_target_temperature_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting the target temperature optimistic.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + await common.async_set_operation_mode(hass, "performance", ENTITY_WATER_HEATER) + await common.async_set_temperature( + hass, temperature=55, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 55 + + async_fire_mqtt_message(hass, "temperature-state", "49") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 49 + + async_fire_mqtt_message(hass, "temperature-state", "not a number") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 49 + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"current_temperature_topic": "current_temperature"},), + ) + ], +) +async def test_receive_mqtt_temperature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test getting the current temperature via MQTT.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "current_temperature", "53") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == 53 + + async_fire_mqtt_message(hass, "current_temperature", "") + state = hass.states.get(ENTITY_WATER_HEATER) + assert ( + "Invalid empty payload for attribute _attr_current_temperature, ignoring update" + in caplog.text + ) + assert state.attributes.get("current_temperature") == 53 + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, water_heater.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "temperature_command_topic": "temperature-topic", + # By default, just unquote the JSON-strings + "value_template": "{{ value_json }}", + "mode_state_template": "{{ value_json.attribute }}", + "temperature_state_template": "{{ value_json }}", + "current_temperature_template": "{{ value_json}}", + "mode_state_topic": "mode-state", + "temperature_state_topic": "temperature-state", + "current_temperature_topic": "current-temperature", + } + } + } + ], +) +async def test_get_with_templates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test getting various attributes with templates.""" + await mqtt_mock_entry() + + # Operation Mode + state = hass.states.get(ENTITY_WATER_HEATER) + async_fire_mqtt_message(hass, "mode-state", '{"attribute": "eco"}') + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + + # Temperature - with valid value + assert state.attributes.get("temperature") is None + async_fire_mqtt_message(hass, "temperature-state", '"1031"') + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 1031 + + # Temperature - with invalid value + async_fire_mqtt_message(hass, "temperature-state", '"-INVALID-"') + state = hass.states.get(ENTITY_WATER_HEATER) + # make sure, the invalid value gets logged... + assert "Could not parse temperature_state_template from -INVALID-" in caplog.text + # ... but the actual value stays unchanged. + assert state.attributes.get("temperature") == 1031 + + # Temperature - with JSON null value + async_fire_mqtt_message(hass, "temperature-state", "null") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") is None + + # Current temperature + async_fire_mqtt_message(hass, "current-temperature", '"74656"') + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == 74656 + # Test resetting the current temperature using a JSON null value + async_fire_mqtt_message(hass, "current-temperature", "null") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") is None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "temperature_command_topic": "temperature-topic", + # Create simple templates + "mode_command_template": "mode: {{ value }}", + "temperature_command_template": "temp: {{ value }}", + } + } + } + ], +) +async def test_set_and_templates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setting various attributes with templates.""" + mqtt_mock = await mqtt_mock_entry() + + # Mode + await common.async_set_operation_mode(hass, "heat_pump", ENTITY_WATER_HEATER) + mqtt_mock.async_publish.assert_called_once_with( + "mode-topic", "mode: heat_pump", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "heat_pump" + + # Temperature + await common.async_set_temperature( + hass, temperature=107, entity_id=ENTITY_WATER_HEATER + ) + mqtt_mock.async_publish.assert_called_once_with( + "temperature-topic", "temp: 107.0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 107 + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"min_temp": 70},))], +) +async def test_min_temp_custom( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a custom min temp.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + min_temp = state.attributes.get("min_temp") + + assert isinstance(min_temp, float) + assert state.attributes.get("min_temp") == 70 + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"max_temp": 220},))], +) +async def test_max_temp_custom( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a custom max temp.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + max_temp = state.attributes.get("max_temp") + + assert isinstance(max_temp, float) + assert max_temp == 220 + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ) + ], +) +async def test_temperature_unit( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting temperature unit converts temperature values.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.attributes.get("min_temp") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.attributes.get("max_temp") == _DEFAULT_MAX_TEMP_CELSIUS + + async_fire_mqtt_message(hass, "current_temperature", "127") + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == 52.8 + + +@pytest.mark.parametrize( + ("hass_config", "temperature_unit", "initial", "min_temp", "max_temp", "current"), + [ + ( + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.CELSIUS, + _DEFAULT_MIN_TEMP_CELSIUS, + _DEFAULT_MIN_TEMP_CELSIUS, + _DEFAULT_MAX_TEMP_CELSIUS, + 48.9, + ), + ( + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.KELVIN, + 316, + 316, + 333, + 322, + ), + ( + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.FAHRENHEIT, + DEFAULT_MIN_TEMP, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + 120, + ), + ], +) +async def test_alt_temperature_unit( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + temperature_unit: UnitOfTemperature, + initial: float, + min_temp: float, + max_temp: float, + current: float, +) -> None: + """Test deriving the systems temperature unit.""" + with patch.object(hass.config.units, "temperature_unit", temperature_unit): + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min_temp + assert state.attributes.get("max_temp") == max_temp + + async_fire_mqtt_message(hass, "current_temperature", "120") + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == current + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + DEFAULT_CONFIG, + MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + water_heater.DOMAIN: [ + { + "name": "Test 1", + "mode_state_topic": "test_topic1/state", + "mode_command_topic": "test_topic1/command", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "mode_state_topic": "test_topic2/state", + "mode_command_topic": "test_topic2/command", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one water heater per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, water_heater.DOMAIN) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("current_temperature_topic", "22.1", ATTR_CURRENT_TEMPERATURE, 22.1), + ("mode_state_topic", "eco", ATTR_OPERATION_MODE, None), + ("temperature_state_topic", "19.9", ATTR_TEMPERATURE, 19.9), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][water_heater.DOMAIN]) + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + ) + + +async def test_discovery_removal_water_heater( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered water heater.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][water_heater.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry, caplog, water_heater.DOMAIN, data + ) + + +async def test_discovery_update_water_heater( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered water heater.""" + config1 = {"name": "Beer"} + config2 = {"name": "Milk"} + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, water_heater.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_water_heater( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered water heater.""" + data1 = '{ "name": "Beer" }' + with patch( + "homeassistant.components.mqtt.water_heater.MqttWaterHeater.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer", "mode_command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "mode_command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, water_heater.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT water heater device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT water heater device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + config = { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_state_topic": "test-topic", + "availability_topic": "avty-topic", + } + } + } + await help_test_entity_id_update_subscriptions( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + config, + ["test-topic", "avty-topic"], + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + config = { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "command-topic", + "mode_state_topic": "test-topic", + } + } + } + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + config, + water_heater.SERVICE_SET_OPERATION_MODE, + command_topic="command-topic", + command_payload="eco", + state_topic="test-topic", + service_parameters={"operation_mode": "eco"}, + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_precision_default( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting precision to tenths works as intended.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_set_temperature( + hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 23.7 + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"precision": 0.5},))], +) +async def test_precision_halves( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting precision to halves works as intended.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_set_temperature( + hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 23.5 + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"precision": 1.0},))], +) +async def test_precision_whole( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting precision to whole works as intended.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_set_temperature( + hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 24.0 + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + water_heater.SERVICE_SET_OPERATION_MODE, + "mode_command_topic", + {"operation_mode": "electric"}, + "electric", + "mode_command_template", + ), + ( + water_heater.SERVICE_SET_TEMPERATURE, + "temperature_command_topic", + {"temperature": "20.1"}, + 20.1, + "temperature_command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = water_heater.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG) + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = water_heater.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = water_heater.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = water_heater.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) From 76535d3f7d1f971dc9c5ba2919b0dc7af639ca6c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Jun 2023 12:00:34 -0400 Subject: [PATCH 160/857] Fix default value when logger used (#94269) --- homeassistant/components/logger/__init__.py | 5 +---- homeassistant/components/logger/helpers.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index fe29447aeba..b1086d7f780 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -13,7 +13,6 @@ from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import ( ATTR_LEVEL, - DEFAULT_LOGSEVERITY, DOMAIN, LOGGER_DEFAULT, LOGGER_FILTERS, @@ -39,9 +38,7 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { - vol.Optional( - LOGGER_DEFAULT, default=DEFAULT_LOGSEVERITY - ): _VALID_LOG_LEVEL, + vol.Optional(LOGGER_DEFAULT): _VALID_LOG_LEVEL, vol.Optional(LOGGER_LOGS): vol.Schema({cv.string: _VALID_LOG_LEVEL}), vol.Optional(LOGGER_FILTERS): vol.Schema({cv.string: [cv.is_regex]}), } diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 0f1751c1b2e..dcd4348a561 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -119,7 +119,7 @@ class LoggerSettings: self._yaml_config = yaml_config self._default_level = logging.INFO - if DOMAIN in yaml_config: + if DOMAIN in yaml_config and LOGGER_DEFAULT in yaml_config[DOMAIN]: self._default_level = yaml_config[DOMAIN][LOGGER_DEFAULT] self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store( hass, STORAGE_VERSION, STORAGE_KEY From 6db1fbf480d17f21f052b699c4beaea97878a518 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 8 Jun 2023 18:22:34 +0200 Subject: [PATCH 161/857] Fix repair issue about no yaml for config entries (#94271) --- homeassistant/helpers/config_validation.py | 2 +- homeassistant/setup.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 01fa4c19561..27e4bc2c41f 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1132,7 +1132,7 @@ def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: domain, "config_entry_only", "config_entry_only", - {"add_integration": f"/_my_redirect/config_flow_start?domain={domain}"}, + {"add_integration": f"/config/integrations/dashboard/add?domain={domain}"}, ) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index b6db8c0ebb3..2adc5fe1024 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -240,12 +240,15 @@ async def _async_setup_component( async_create_issue( hass, HOMEASSISTANT_DOMAIN, - f"config_entry_only{domain}", + f"config_entry_only_{domain}", is_fixable=False, severity=IssueSeverity.ERROR, issue_domain=domain, translation_key="config_entry_only", - translation_placeholders={"domain": domain}, + translation_placeholders={ + "domain": domain, + "add_integration": f"/config/integrations/dashboard/add?domain={domain}", + }, ) start = timer() From c8756ba5bb7bb4c27b1d3acc2b2fe769b6fea54d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Jun 2023 18:27:04 +0200 Subject: [PATCH 162/857] Use stable USB device path in USB discovery (#94266) --- .../components/insteon/config_flow.py | 7 +--- homeassistant/components/usb/__init__.py | 33 ++++++++------- tests/components/usb/test_init.py | 42 +++++++++++++++++++ 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index f153bc1aa34..f5bafd935a0 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -168,12 +168,9 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, discovery_info.device - ) - self._device_path = dev_path + self._device_path = discovery_info.device self._device_name = usb.human_readable_device_name( - dev_path, + discovery_info.device, discovery_info.serial_number, discovery_info.manufacturer, discovery_info.description, diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index dcb4009145f..b2358a4b0bd 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -300,8 +300,7 @@ class USBDiscovery: return _async_remove_callback - @hass_callback - def _async_process_discovered_usb_device(self, device: USBDevice) -> None: + async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" _LOGGER.debug("Discovered USB Device: %s", device) device_tuple = dataclasses.astuple(device) @@ -313,14 +312,7 @@ class USBDiscovery: if not matched: return - service_info = UsbServiceInfo( - device=device.device, - vid=device.vid, - pid=device.pid, - serial_number=device.serial_number, - manufacturer=device.manufacturer, - description=device.description, - ) + service_info: UsbServiceInfo | None = None sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) most_matched_fields = len(sorted_by_most_targeted[0]) @@ -331,6 +323,18 @@ class USBDiscovery: if len(matcher) < most_matched_fields: break + if service_info is None: + service_info = UsbServiceInfo( + device=await self.hass.async_add_executor_job( + get_serial_by_id, device.device + ), + vid=device.vid, + pid=device.pid, + serial_number=device.serial_number, + manufacturer=device.manufacturer, + description=device.description, + ) + discovery_flow.async_create_flow( self.hass, matcher["domain"], @@ -338,17 +342,18 @@ class USBDiscovery: service_info, ) - @hass_callback - def _async_process_ports(self, ports: list[ListPortInfo]) -> None: + async def _async_process_ports(self, ports: list[ListPortInfo]) -> None: """Process each discovered port.""" for port in ports: if port.vid is None and port.pid is None: continue - self._async_process_discovered_usb_device(usb_device_from_port(port)) + await self._async_process_discovered_usb_device(usb_device_from_port(port)) async def _async_scan_serial(self) -> None: """Scan serial ports.""" - self._async_process_ports(await self.hass.async_add_executor_job(comports)) + await self._async_process_ports( + await self.hass.async_add_executor_job(comports) + ) if self.initial_scan_done: return diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 0d4625fe7fb..e7c878b6f40 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1021,3 +1021,45 @@ async def test_cancel_initial_scan_callback( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(mock_callback.mock_calls) == 0 + + +async def test_resolve_serial_by_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the discovery data resolves to serial/by-id.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch( + "homeassistant.components.usb.get_serial_by_id", + return_value="/dev/serial/by-id/bla", + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" From fceef42b086524eed1912e6a4aaea0ea1401bc05 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Thu, 8 Jun 2023 12:36:42 -0400 Subject: [PATCH 163/857] Fix Insteon startup for users with X10 devices (#94277) --- homeassistant/components/insteon/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 2ef9913ab8c..f9c22ef62a5 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -140,6 +140,9 @@ def add_insteon_events(hass: HomeAssistant, device: Device) -> None: _LOGGER.debug("Firing event %s with %s", event, schema) hass.bus.async_fire(event, schema) + if str(device.address).startswith("X10"): + return + for name_or_group, event in device.events.items(): if isinstance(name_or_group, int): for _, event in device.events[name_or_group].items(): @@ -166,8 +169,9 @@ def register_new_device_callback(hass): await device.async_status() platforms = get_device_platforms(device) for platform in platforms: + groups = get_device_platform_groups(device, platform) signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" - dispatcher_send(hass, signal, {"address": device.address}) + dispatcher_send(hass, signal, {"address": device.address, "groups": groups}) add_insteon_events(hass, device) devices.subscribe(async_new_insteon_device, force_strong_ref=True) From 35ad40421bd35714f9db3a2ac1c27f44ab6729d0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Jun 2023 19:21:08 +0200 Subject: [PATCH 164/857] Drop call to usb.get_serial_by_id from zha config flow (#94278) --- homeassistant/components/zha/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 91bc2ac42a2..ba50839ee44 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -552,10 +552,9 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN vid = discovery_info.vid pid = discovery_info.pid serial_number = discovery_info.serial_number - device = discovery_info.device manufacturer = discovery_info.manufacturer description = discovery_info.description - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + dev_path = discovery_info.device await self._set_unique_id_or_update_path( unique_id=f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}", From 0dbdfb7e706b2cd5630c44856b11de378205f8fd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Jun 2023 19:21:19 +0200 Subject: [PATCH 165/857] Drop call to usb.get_serial_by_id from velbus config flow (#94276) --- homeassistant/components/velbus/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index e0394e4787c..5c35303f859 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -74,9 +74,7 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" ) - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, discovery_info.device - ) + dev_path = discovery_info.device # check if this device is not already configured self._async_abort_entries_match({CONF_PORT: dev_path}) # check if we can make a valid velbus connection From bdc82fa50ac86cf48194c37ce4f9b0304a58861f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Jun 2023 19:21:31 +0200 Subject: [PATCH 166/857] Drop call to usb.get_serial_by_id from modem_callerid config flow (#94275) --- homeassistant/components/modem_callerid/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 537fe81da11..fac20073fe9 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -32,9 +32,7 @@ class PhoneModemFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: """Handle USB Discovery.""" - device = discovery_info.device - - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + dev_path = discovery_info.device unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" if ( await self.validate_device_errors(dev_path=dev_path, unique_id=unique_id) From f7938c940c10b7a522d8c2af715381209a5d53cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 8 Jun 2023 22:53:43 +0300 Subject: [PATCH 167/857] Upgrade ruff to 0.0.272 (#94290) --- .pre-commit-config.yaml | 2 +- homeassistant/components/esphome/bluetooth/client.py | 2 +- pyproject.toml | 5 ++--- requirements_test_pre_commit.txt | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e8fef97697..461543ba141 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.262 + rev: v0.0.272 hooks: - id: ruff args: diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 708d79e0eec..d452ab8764a 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -233,7 +233,7 @@ class ESPHomeClient(BaseBleakClient): ) -> bool: """Connect to a specified Peripheral. - Keyword Args: + **kwargs: timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. diff --git a/pyproject.toml b/pyproject.toml index 7e8318c6e69..032bc6e14a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,6 +273,8 @@ ignore = [ "D407", # Section name underlining "E501", # line too long "E731", # do not assign a lambda expression, use a def + "UP006", # keep type annotation style as is + "UP007", # keep type annotation style as is # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] @@ -289,9 +291,6 @@ voluptuous = "vol" [tool.ruff.flake8-pytest-style] fixture-parentheses = false -[tool.ruff.pyupgrade] -keep-runtime-typing = true - [tool.ruff.per-file-ignores] # Allow for main entry & scripts to write to stdout diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 9954e97da97..abe388df553 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -4,5 +4,5 @@ bandit==1.7.4 black==23.3.0 codespell==2.2.2 isort==5.12.0 -ruff==0.0.262 +ruff==0.0.272 yamllint==1.28.0 From ca936d0b384632b1104bca4ee252791e1a470fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 8 Jun 2023 23:46:04 +0300 Subject: [PATCH 168/857] Replace bandit with ruff (#93200) --- .github/workflows/ci.yaml | 13 ------------ .pre-commit-config.yaml | 9 -------- .../components/command_line/notify.py | 2 +- .../components/command_line/utils.py | 4 ++-- homeassistant/components/gtfs/sensor.py | 2 +- homeassistant/components/influxdb/sensor.py | 2 +- .../components/recorder/migration.py | 6 +++--- .../components/recorder/statistics.py | 6 +++--- homeassistant/components/recorder/util.py | 6 ++++-- homeassistant/components/yi/camera.py | 2 +- pyproject.toml | 17 +++++++++++++++ requirements_test_pre_commit.txt | 1 - tests/bandit.yaml | 21 ------------------- tests/components/command_line/test_cover.py | 2 +- tests/components/command_line/test_sensor.py | 2 +- tests/util/test_process.py | 2 +- 16 files changed, 36 insertions(+), 61 deletions(-) delete mode 100644 tests/bandit.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cb24a6a9d45..8a99287b869 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -468,19 +468,6 @@ jobs: with: args: hadolint Dockerfile.dev - - name: Run bandit (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure - - name: Run bandit (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual bandit --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - base: name: Prepare dependencies runs-on: ubuntu-22.04 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 461543ba141..3fac4229f8c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,15 +22,6 @@ repos: - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/|homeassistant/generated/ - - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=tests/bandit.yaml - files: ^(homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 2922b8caae3..2f4f20045d7 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -72,7 +72,7 @@ class CommandLineNotificationService(BaseNotificationService): universal_newlines=True, stdin=subprocess.PIPE, close_fds=False, # required for posix_spawn - shell=True, # nosec # shell by design + shell=True, # noqa: S602 # shell by design ) as proc: try: proc.communicate(input=message, timeout=self._timeout) diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 2d42732190e..66faa3a0bf8 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -19,7 +19,7 @@ def call_shell_with_timeout( _LOGGER.debug("Running command: %s", command) subprocess.check_output( command, - shell=True, # nosec # shell by design + shell=True, # noqa: S602 # shell by design timeout=timeout, close_fds=False, # required for posix_spawn ) @@ -45,7 +45,7 @@ def check_output_or_log(command: str, timeout: int) -> str | None: try: return_value = subprocess.check_output( command, - shell=True, # nosec # shell by design + shell=True, # noqa: S602 # shell by design timeout=timeout, close_fds=False, # required for posix_spawn ) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 9fac4d01926..b395c73ab3e 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -341,7 +341,7 @@ def get_next_departure( {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """ + """ # noqa: S608 result = schedule.engine.connect().execute( text(sql_query), { diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index 67aaae225a8..b4f643e876f 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -339,7 +339,7 @@ class InfluxQLSensorData: return self.query = ( - f"select {self.group}({self.field}) as {INFLUX_CONF_VALUE} from" + f"select {self.group}({self.field}) as {INFLUX_CONF_VALUE} from" # noqa: S608 f" {self.measurement} where {where_clause}" ) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index b8436da97d5..33d8c7b5e67 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1303,7 +1303,7 @@ def _migrate_statistics_columns_to_timestamp( with session_scope(session=session_maker()) as session: session.connection().execute( text( - f"UPDATE {table} set start_ts=strftime('%s',start) + " + f"UPDATE {table} set start_ts=strftime('%s',start) + " # noqa: S608 "cast(substr(start,-7) AS FLOAT), " f"created_ts=strftime('%s',created) + " "cast(substr(created,-7) AS FLOAT), " @@ -1321,7 +1321,7 @@ def _migrate_statistics_columns_to_timestamp( with session_scope(session=session_maker()) as session: result = session.connection().execute( text( - f"UPDATE {table} set start_ts=" + f"UPDATE {table} set start_ts=" # noqa: S608 "IF(start is NULL or UNIX_TIMESTAMP(start) is NULL,0," "UNIX_TIMESTAMP(start) " "), " @@ -1343,7 +1343,7 @@ def _migrate_statistics_columns_to_timestamp( with session_scope(session=session_maker()) as session: result = session.connection().execute( text( - f"UPDATE {table} set start_ts=" # nosec + f"UPDATE {table} set start_ts=" # noqa: S608 "(case when start is NULL then 0 else EXTRACT(EPOCH FROM start::timestamptz) end), " "created_ts=EXTRACT(EPOCH FROM created::timestamptz), " "last_reset_ts=EXTRACT(EPOCH FROM last_reset::timestamptz) " diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ee9662a2157..9bbf35bb40a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2400,7 +2400,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: session.connection().execute( text( - f"update {table} set start = NULL, created = NULL, last_reset = NULL;" + f"update {table} set start = NULL, created = NULL, last_reset = NULL;" # noqa: S608 ) ) elif engine.dialect.name == SupportedDialect.MYSQL: @@ -2410,7 +2410,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" + f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" # noqa: S608 ) ) .rowcount @@ -2425,7 +2425,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # nosec + f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # noqa: S608 f"where id in (select id from {table} where start is not NULL LIMIT 100000)" ) ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 1c50fd0a77c..d963901f17b 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -278,9 +278,11 @@ def basic_sanity_check(cursor: SQLiteCursor) -> bool: for table in TABLES_TO_CHECK: if table in (TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES): - cursor.execute(f"SELECT * FROM {table};") # nosec # not injection + cursor.execute(f"SELECT * FROM {table};") # noqa: S608 # not injection else: - cursor.execute(f"SELECT * FROM {table} LIMIT 1;") # nosec # not injection + cursor.execute( + f"SELECT * FROM {table} LIMIT 1;" # noqa: S608 # not injection + ) return True diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index 0537c268aa4..632260a899c 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_BRAND = "YI Home Camera" DEFAULT_PASSWORD = "" -DEFAULT_PATH = "/tmp/sd/record" # nosec +DEFAULT_PATH = "/tmp/sd/record" # noqa: S108 DEFAULT_PORT = 21 DEFAULT_USERNAME = "root" DEFAULT_ARGUMENTS = "-pred 1" diff --git a/pyproject.toml b/pyproject.toml index 032bc6e14a2..6fbd810626b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -251,6 +251,23 @@ select = [ "ICN001", # import concentions; {name} should be imported as {asname} "PGH004", # Use specific rule codes when using noqa "PLC0414", # Useless import alias. Import alias does not rename original package. + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S320", # suspicious-xmle-tree-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass "SIM117", # Merge with-statements that use the same scope "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index abe388df553..cee1c684d8a 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -bandit==1.7.4 black==23.3.0 codespell==2.2.2 isort==5.12.0 diff --git a/tests/bandit.yaml b/tests/bandit.yaml deleted file mode 100644 index 568f77d622a..00000000000 --- a/tests/bandit.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# https://bandit.readthedocs.io/en/latest/config.html - -tests: - - B103 - - B108 - - B306 - - B307 - - B313 - - B314 - - B315 - - B316 - - B317 - - B318 - - B319 - - B320 - - B325 - - B601 - - B602 - - B604 - - B608 - - B609 diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index d977c202b04..d621d98c744 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -145,7 +145,7 @@ async def test_poll_when_cover_has_command_state( await hass.async_block_till_done() check_output.assert_called_once_with( "echo state", - shell=True, # nosec # shell by design + shell=True, # noqa: S604 # shell by design timeout=15, close_fds=False, ) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 87360d0e251..244a1b992ce 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -167,7 +167,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: assert len(check_output.mock_calls) == 1 check_output.assert_called_with( 'echo "sensor_value" "3 4"', - shell=True, # nosec # shell by design + shell=True, # noqa: S604 # shell by design timeout=15, close_fds=False, ) diff --git a/tests/util/test_process.py b/tests/util/test_process.py index 243e9f53fca..ae28f5d82fc 100644 --- a/tests/util/test_process.py +++ b/tests/util/test_process.py @@ -12,7 +12,7 @@ async def test_kill_process() -> None: """Test killing a process.""" sleeper = subprocess.Popen( "sleep 1000", - shell=True, # nosec # shell by design + shell=True, # noqa: S602 # shell by design stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) From b3a001996da9717c08b5c0fc71d1e220cf8fbce1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 8 Jun 2023 22:55:16 +0200 Subject: [PATCH 169/857] Improve coverage for LastFM (#93661) * Improve coverage for LastFM * Improve tests * Improve tests --- .coveragerc | 1 - tests/components/lastfm/__init__.py | 69 +++++---- tests/components/lastfm/conftest.py | 67 +++++++++ tests/components/lastfm/test_config_flow.py | 155 ++++++++++---------- tests/components/lastfm/test_init.py | 28 ++-- tests/components/lastfm/test_sensor.py | 105 +++++++++++-- 6 files changed, 291 insertions(+), 134 deletions(-) create mode 100644 tests/components/lastfm/conftest.py diff --git a/.coveragerc b/.coveragerc index 5167decbf8a..c717c1624c3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -618,7 +618,6 @@ omit = homeassistant/components/kwb/sensor.py homeassistant/components/lacrosse/sensor.py homeassistant/components/lannouncer/notify.py - homeassistant/components/lastfm/sensor.py homeassistant/components/launch_library/__init__.py homeassistant/components/launch_library/sensor.py homeassistant/components/lcn/climate.py diff --git a/tests/components/lastfm/__init__.py b/tests/components/lastfm/__init__.py index 7ee8665e28a..dde914d51cc 100644 --- a/tests/components/lastfm/__init__.py +++ b/tests/components/lastfm/__init__.py @@ -22,64 +22,83 @@ CONF_FRIENDS_DATA = {CONF_USERS: [USERNAME_2]} class MockNetwork: """Mock _Network object for pylast.""" - def __init__(self, username: str): + def __init__(self, username: str) -> None: """Initialize the mock.""" self.username = username +class MockTopTrack: + """Mock TopTrack object for pylast.""" + + def __init__(self, item: Track) -> None: + """Initialize the mock.""" + self.item = item + + +class MockLastTrack: + """Mock LastTrack object for pylast.""" + + def __init__(self, track: Track) -> None: + """Initialize the mock.""" + self.track = track + + class MockUser: """Mock User object for pylast.""" - def __init__(self, now_playing_result, error, has_friends, username): + def __init__( + self, + username: str = USERNAME_1, + now_playing_result: Track | None = None, + thrown_error: Exception | None = None, + friends: list = [], + recent_tracks: list[Track] = [], + top_tracks: list[Track] = [], + ) -> None: """Initialize the mock.""" self._now_playing_result = now_playing_result - self._thrown_error = error - self._has_friends = has_friends + self._thrown_error = thrown_error + self._friends = friends + self._recent_tracks = recent_tracks + self._top_tracks = top_tracks self.name = username def get_name(self, capitalized: bool) -> str: """Get name of the user.""" return self.name - def get_playcount(self): + def get_playcount(self) -> int: """Get mock play count.""" if self._thrown_error: raise self._thrown_error - return 1 + return len(self._recent_tracks) - def get_image(self): + def get_image(self) -> str: """Get mock image.""" + return "" - def get_recent_tracks(self, limit): + def get_recent_tracks(self, limit: int) -> list[MockLastTrack]: """Get mock recent tracks.""" - return [] + return [MockLastTrack(track) for track in self._recent_tracks] - def get_top_tracks(self, limit): + def get_top_tracks(self, limit: int) -> list[MockTopTrack]: """Get mock top tracks.""" - return [] + return [MockTopTrack(track) for track in self._recent_tracks] - def get_now_playing(self): + def get_now_playing(self) -> Track: """Get mock now playing.""" return self._now_playing_result - def get_friends(self): + def get_friends(self) -> list[any]: """Get mock friends.""" - if self._has_friends is False: + if len(self._friends) == 0: raise PyLastError("network", "status", "Page not found") - return [MockUser(None, None, True, USERNAME_2)] + return self._friends -def patch_fetch_user( - now_playing: Track | None = None, - thrown_error: Exception | None = None, - has_friends: bool = True, - username: str = USERNAME_1, -) -> MockUser: +def patch_user(user: MockUser) -> MockUser: """Patch interface.""" - return patch( - "pylast.User", - return_value=MockUser(now_playing, thrown_error, has_friends, username), - ) + return patch("pylast.User", return_value=user) def patch_setup_entry() -> bool: diff --git a/tests/components/lastfm/conftest.py b/tests/components/lastfm/conftest.py new file mode 100644 index 00000000000..119d4796f57 --- /dev/null +++ b/tests/components/lastfm/conftest.py @@ -0,0 +1,67 @@ +"""Configure tests for the LastFM integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from pylast import Track +import pytest + +from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.lastfm import ( + API_KEY, + USERNAME_1, + USERNAME_2, + MockNetwork, + MockUser, +) + +ComponentSetup = Callable[[MockConfigEntry, MockUser], Awaitable[None]] + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Create LastFM entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1, USERNAME_2], + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, +) -> Callable[[MockConfigEntry, MockUser], Awaitable[None]]: + """Fixture for setting up the component.""" + + async def func(mock_config_entry: MockConfigEntry, mock_user: MockUser) -> None: + mock_config_entry.add_to_hass(hass) + with patch("pylast.User", return_value=mock_user): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func + + +@pytest.fixture(name="default_user") +def mock_default_user() -> MockUser: + """Return default mock user.""" + return MockUser( + now_playing_result=Track("artist", "title", MockNetwork("lastfm")), + top_tracks=[Track("artist", "title", MockNetwork("lastfm"))], + recent_tracks=[Track("artist", "title", MockNetwork("lastfm"))], + ) + + +@pytest.fixture(name="first_time_user") +def mock_first_time_user() -> MockUser: + """Return first time mock user.""" + return MockUser(now_playing_result=None, top_tracks=[], recent_tracks=[]) diff --git a/tests/components/lastfm/test_config_flow.py b/tests/components/lastfm/test_config_flow.py index 02168449398..ce28638c3f3 100644 --- a/tests/components/lastfm/test_config_flow.py +++ b/tests/components/lastfm/test_config_flow.py @@ -1,4 +1,6 @@ """Test Lastfm config flow.""" +from unittest.mock import patch + from pylast import WSError import pytest @@ -21,16 +23,17 @@ from . import ( CONF_USER_DATA, USERNAME_1, USERNAME_2, - patch_fetch_user, + MockUser, patch_setup_entry, ) +from .conftest import ComponentSetup from tests.common import MockConfigEntry -async def test_full_user_flow(hass: HomeAssistant) -> None: +async def test_full_user_flow(hass: HomeAssistant, default_user: MockUser) -> None: """Test the full user configuration flow.""" - with patch_fetch_user(), patch_setup_entry(): + with patch("pylast.User", return_value=default_user), patch_setup_entry(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -68,9 +71,11 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: (WSError("network", "status", "Something strange"), "unknown"), ], ) -async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: +async def test_flow_fails( + hass: HomeAssistant, error: Exception, message: str, default_user: MockUser +) -> None: """Test user initialized flow with invalid username.""" - with patch_fetch_user(thrown_error=error): + with patch("pylast.User", return_value=MockUser(thrown_error=error)): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONF_USER_DATA ) @@ -78,7 +83,7 @@ async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) - assert result["step_id"] == "user" assert result["errors"]["base"] == message - with patch_fetch_user(), patch_setup_entry(): + with patch("pylast.User", return_value=default_user), patch_setup_entry(): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONF_USER_DATA, @@ -95,9 +100,11 @@ async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) - assert result["options"] == CONF_DATA -async def test_flow_friends_invalid_username(hass: HomeAssistant) -> None: +async def test_flow_friends_invalid_username( + hass: HomeAssistant, default_user: MockUser +) -> None: """Test user initialized flow with invalid username.""" - with patch_fetch_user(), patch_setup_entry(): + with patch("pylast.User", return_value=default_user), patch_setup_entry(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -109,7 +116,12 @@ async def test_flow_friends_invalid_username(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "friends" - with patch_fetch_user(thrown_error=WSError("network", "status", "User not found")): + with patch( + "pylast.User", + return_value=MockUser( + thrown_error=WSError("network", "status", "User not found") + ), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONF_FRIENDS_DATA ) @@ -117,7 +129,7 @@ async def test_flow_friends_invalid_username(hass: HomeAssistant) -> None: assert result["step_id"] == "friends" assert result["errors"]["base"] == "invalid_account" - with patch_fetch_user(), patch_setup_entry(): + with patch("pylast.User", return_value=default_user), patch_setup_entry(): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONF_FRIENDS_DATA ) @@ -126,9 +138,11 @@ async def test_flow_friends_invalid_username(hass: HomeAssistant) -> None: assert result["options"] == CONF_DATA -async def test_flow_friends_no_friends(hass: HomeAssistant) -> None: +async def test_flow_friends_no_friends( + hass: HomeAssistant, default_user: MockUser +) -> None: """Test options is empty when user has no friends.""" - with patch_fetch_user(has_friends=False), patch_setup_entry(): + with patch("pylast.User", return_value=default_user), patch_setup_entry(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -142,9 +156,9 @@ async def test_flow_friends_no_friends(hass: HomeAssistant) -> None: assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 -async def test_import_flow_success(hass: HomeAssistant) -> None: +async def test_import_flow_success(hass: HomeAssistant, default_user: MockUser) -> None: """Test import flow.""" - with patch_fetch_user(): + with patch("pylast.User", return_value=default_user): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -160,16 +174,16 @@ async def test_import_flow_success(hass: HomeAssistant) -> None: } -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: +async def test_import_flow_already_exist( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + default_user: MockUser, +) -> None: """Test import of yaml already exist.""" + await setup_integration(config_entry, default_user) - MockConfigEntry( - domain=DOMAIN, - data={}, - options={CONF_API_KEY: API_KEY, CONF_USERS: ["test"]}, - ).add_to_hass(hass) - - with patch_fetch_user(): + with patch("pylast.User", return_value=default_user): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -181,20 +195,16 @@ async def test_import_flow_already_exist(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + default_user: MockUser, +) -> None: """Test updating options.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_API_KEY: API_KEY, - CONF_MAIN_USER: USERNAME_1, - CONF_USERS: [USERNAME_1, USERNAME_2], - }, - ) - entry.add_to_hass(hass) - with patch_fetch_user(): - await hass.config_entries.async_setup(entry.entry_id) + await setup_integration(config_entry, default_user) + with patch("pylast.User", return_value=default_user): + entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() @@ -215,27 +225,28 @@ async def test_options_flow(hass: HomeAssistant) -> None: } -async def test_options_flow_incorrect_username(hass: HomeAssistant) -> None: +async def test_options_flow_incorrect_username( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + default_user: MockUser, +) -> None: """Test updating options doesn't work with incorrect username.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_API_KEY: API_KEY, - CONF_MAIN_USER: USERNAME_1, - CONF_USERS: [USERNAME_1], - }, - ) - entry.add_to_hass(hass) - with patch_fetch_user(): - await hass.config_entries.async_setup(entry.entry_id) + await setup_integration(config_entry, default_user) + with patch("pylast.User", return_value=default_user): + entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "init" - with patch_fetch_user(thrown_error=WSError("network", "status", "User not found")): + with patch( + "pylast.User", + return_value=MockUser( + thrown_error=WSError("network", "status", "User not found") + ), + ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_USERS: [USERNAME_1]}, @@ -246,7 +257,7 @@ async def test_options_flow_incorrect_username(hass: HomeAssistant) -> None: assert result["step_id"] == "init" assert result["errors"]["base"] == "invalid_account" - with patch_fetch_user(): + with patch("pylast.User", return_value=default_user): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_USERS: [USERNAME_1]}, @@ -261,20 +272,16 @@ async def test_options_flow_incorrect_username(hass: HomeAssistant) -> None: } -async def test_options_flow_from_import(hass: HomeAssistant) -> None: +async def test_options_flow_from_import( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + default_user: MockUser, +) -> None: """Test updating options gained from import.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_API_KEY: API_KEY, - CONF_MAIN_USER: None, - CONF_USERS: [USERNAME_1], - }, - ) - entry.add_to_hass(hass) - with patch_fetch_user(): - await hass.config_entries.async_setup(entry.entry_id) + await setup_integration(config_entry, default_user) + with patch("pylast.User", return_value=default_user): + entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() @@ -283,20 +290,16 @@ async def test_options_flow_from_import(hass: HomeAssistant) -> None: assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 -async def test_options_flow_without_friends(hass: HomeAssistant) -> None: +async def test_options_flow_without_friends( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + default_user: MockUser, +) -> None: """Test updating options for someone without friends.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_API_KEY: API_KEY, - CONF_MAIN_USER: USERNAME_1, - CONF_USERS: [USERNAME_1], - }, - ) - entry.add_to_hass(hass) - with patch_fetch_user(has_friends=False): - await hass.config_entries.async_setup(entry.entry_id) + await setup_integration(config_entry, default_user) + with patch("pylast.User", return_value=default_user): + entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lastfm/test_init.py b/tests/components/lastfm/test_init.py index 832494f28de..8f731385e6f 100644 --- a/tests/components/lastfm/test_init.py +++ b/tests/components/lastfm/test_init.py @@ -1,30 +1,24 @@ """Test LastFM component setup process.""" from __future__ import annotations -from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS, DOMAIN -from homeassistant.const import CONF_API_KEY +from homeassistant.components.lastfm.const import DOMAIN from homeassistant.core import HomeAssistant -from . import USERNAME_1, USERNAME_2, patch_fetch_user +from . import MockUser +from .conftest import ComponentSetup from tests.common import MockConfigEntry -async def test_load_unload_entry(hass: HomeAssistant) -> None: +async def test_load_unload_entry( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + default_user: MockUser, +) -> None: """Test load and unload entry.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_API_KEY: "12345678", - CONF_MAIN_USER: [USERNAME_1], - CONF_USERS: [USERNAME_1, USERNAME_2], - }, - ) - entry.add_to_hass(hass) - with patch_fetch_user(): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_integration(config_entry, default_user) + entry = hass.config_entries.async_entries(DOMAIN)[0] state = hass.states.get("sensor.testaccount1") assert state diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index 06e8e812ca7..e46cf99ffdc 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -1,22 +1,92 @@ """Tests for the lastfm sensor.""" +from unittest.mock import patch -from pylast import Track +from pylast import WSError -from homeassistant.components.lastfm.const import DOMAIN, STATE_NOT_SCROBBLING +from homeassistant.components.lastfm.const import ( + ATTR_LAST_PLAYED, + ATTR_PLAY_COUNT, + ATTR_TOP_PLAYED, + CONF_USERS, + DOMAIN, + STATE_NOT_SCROBBLING, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component -from . import CONF_DATA, MockNetwork, patch_fetch_user +from . import API_KEY, USERNAME_1, MockUser +from .conftest import ComponentSetup from tests.common import MockConfigEntry +LEGACY_CONFIG = { + Platform.SENSOR: [ + {CONF_PLATFORM: DOMAIN, CONF_API_KEY: API_KEY, CONF_USERS: [USERNAME_1]} + ] +} -async def test_update_not_playing(hass: HomeAssistant) -> None: - """Test update when no playing song.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=CONF_DATA) - entry.add_to_hass(hass) - with patch_fetch_user(None): - await hass.config_entries.async_setup(entry.entry_id) + +async def test_legacy_migration(hass: HomeAssistant) -> None: + """Test migration from yaml to config flow.""" + with patch("pylast.User", return_value=None): + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_user_unavailable( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test update when user can't be fetched.""" + await setup_integration( + config_entry, + MockUser(thrown_error=WSError("network", "status", "User not found")), + ) + + entity_id = "sensor.testaccount1" + + state = hass.states.get(entity_id) + + assert state.state == "unavailable" + + +async def test_first_time_user( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + first_time_user: MockUser, +) -> None: + """Test first time user.""" + await setup_integration(config_entry, first_time_user) + + entity_id = "sensor.testaccount1" + + state = hass.states.get(entity_id) + + assert state.state == STATE_NOT_SCROBBLING + assert state.attributes[ATTR_LAST_PLAYED] is None + assert state.attributes[ATTR_TOP_PLAYED] is None + assert state.attributes[ATTR_PLAY_COUNT] == 0 + + +async def test_update_not_playing( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + first_time_user: MockUser, +) -> None: + """Test update when no playing song.""" + await setup_integration(config_entry, first_time_user) + entity_id = "sensor.testaccount1" state = hass.states.get(entity_id) @@ -24,15 +94,20 @@ async def test_update_not_playing(hass: HomeAssistant) -> None: assert state.state == STATE_NOT_SCROBBLING -async def test_update_playing(hass: HomeAssistant) -> None: +async def test_update_playing( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + default_user: MockUser, +) -> None: """Test update when playing a song.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=CONF_DATA) - entry.add_to_hass(hass) - with patch_fetch_user(Track("artist", "title", MockNetwork("test"))): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_integration(config_entry, default_user) + entity_id = "sensor.testaccount1" state = hass.states.get(entity_id) assert state.state == "artist - title" + assert state.attributes[ATTR_LAST_PLAYED] == "artist - title" + assert state.attributes[ATTR_TOP_PLAYED] == "artist - title" + assert state.attributes[ATTR_PLAY_COUNT] == 1 From 23d15850da224717eb0867d04bf52885926792cc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Jun 2023 23:04:12 +0200 Subject: [PATCH 170/857] Use addon name as title in otbr hassio flow (#87081) * Use addon name as title in otbr hassio flow * Address review comments --- homeassistant/components/otbr/config_flow.py | 38 +++++- tests/components/otbr/test_config_flow.py | 129 +++++++++++++++++-- 2 files changed, 154 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index c8ab8246c8b..67c8412102d 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from contextlib import suppress import logging from typing import cast @@ -12,11 +13,18 @@ from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol import yarl -from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.components.hassio import ( + HassioAPIError, + HassioServiceInfo, + async_get_addon_info, +) +from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.components.thread import async_get_preferred_dataset from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN @@ -25,6 +33,32 @@ from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) +def _is_yellow(hass: HomeAssistant) -> bool: + """Return True if Home Assistant is running on a Home Assistant Yellow.""" + try: + yellow_hardware.async_info(hass) + except HomeAssistantError: + return False + return True + + +async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str: + """Return config entry title.""" + device: str | None = None + + with suppress(HassioAPIError): + addon_info = await async_get_addon_info(hass, discovery_info.slug) + device = addon_info.get("options", {}).get("device") + + if _is_yellow(hass) and device == "/dev/TTYAMA1": + return "Home Assistant Yellow" + + if device and "SkyConnect" in device: + return "Home Assistant SkyConnect" + + return discovery_info.name + + class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Open Thread Border Router.""" @@ -124,6 +158,6 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(discovery_info.uuid) return self.async_create_entry( - title="Open Thread Border Router", + title=await _title(self.hass, discovery_info), data=config_entry_data, ) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index cfb47a28bcf..b6cb0df78cd 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -25,6 +25,23 @@ HASSIO_DATA = hassio.HassioServiceInfo( ) +@pytest.fixture(name="addon_info") +def addon_info_fixture(): + """Mock Supervisor add-on info.""" + with patch( + "homeassistant.components.otbr.config_flow.async_get_addon_info", + ) as addon_info: + addon_info.return_value = { + "available": True, + "hostname": None, + "options": {}, + "state": None, + "update_available": False, + "version": None, + } + yield addon_info + + async def test_user_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: @@ -176,7 +193,7 @@ async def test_user_flow_connect_error(hass: HomeAssistant, error) -> None: async def test_hassio_discovery_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info ) -> None: """Test the hassio discovery flow.""" url = "http://core-silabs-multiprotocol:8081" @@ -195,7 +212,7 @@ async def test_hassio_discovery_flow( } assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Open Thread Border Router" + assert result["title"] == "Silicon Labs Multiprotocol" assert result["data"] == expected_data assert result["options"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -203,12 +220,101 @@ async def test_hassio_discovery_flow( config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] assert config_entry.data == expected_data assert config_entry.options == {} - assert config_entry.title == "Open Thread Border Router" + assert config_entry.title == "Silicon Labs Multiprotocol" + assert config_entry.unique_id == HASSIO_DATA.uuid + + +async def test_hassio_discovery_flow_yellow( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info +) -> None: + """Test the hassio discovery flow.""" + url = "http://core-silabs-multiprotocol:8081" + aioclient_mock.get(f"{url}/node/dataset/active", text="aa") + + addon_info.return_value = { + "available": True, + "hostname": None, + "options": {"device": "/dev/TTYAMA1"}, + "state": None, + "update_available": False, + "version": None, + } + + with patch( + "homeassistant.components.otbr.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.otbr.config_flow.yellow_hardware.async_info" + ): + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant Yellow" + assert result["data"] == expected_data + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + assert config_entry.options == {} + assert config_entry.title == "Home Assistant Yellow" + assert config_entry.unique_id == HASSIO_DATA.uuid + + +async def test_hassio_discovery_flow_sky_connect( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info +) -> None: + """Test the hassio discovery flow.""" + url = "http://core-silabs-multiprotocol:8081" + aioclient_mock.get(f"{url}/node/dataset/active", text="aa") + + addon_info.return_value = { + "available": True, + "hostname": None, + "options": { + "device": ( + "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" + "9e2adbd75b8beb119fe564a0f320645d-if00-port0" + ) + }, + "state": None, + "update_available": False, + "version": None, + } + + with patch( + "homeassistant.components.otbr.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant SkyConnect" + assert result["data"] == expected_data + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + assert config_entry.options == {} + assert config_entry.title == "Home Assistant SkyConnect" assert config_entry.unique_id == HASSIO_DATA.uuid async def test_hassio_discovery_flow_router_not_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -247,7 +353,7 @@ async def test_hassio_discovery_flow_router_not_setup( } assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Open Thread Border Router" + assert result["title"] == "Silicon Labs Multiprotocol" assert result["data"] == expected_data assert result["options"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -255,12 +361,12 @@ async def test_hassio_discovery_flow_router_not_setup( config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] assert config_entry.data == expected_data assert config_entry.options == {} - assert config_entry.title == "Open Thread Border Router" + assert config_entry.title == "Silicon Labs Multiprotocol" assert config_entry.unique_id == HASSIO_DATA.uuid async def test_hassio_discovery_flow_router_not_setup_has_preferred( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -296,7 +402,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( } assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Open Thread Border Router" + assert result["title"] == "Silicon Labs Multiprotocol" assert result["data"] == expected_data assert result["options"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -304,7 +410,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] assert config_entry.data == expected_data assert config_entry.options == {} - assert config_entry.title == "Open Thread Border Router" + assert config_entry.title == "Silicon Labs Multiprotocol" assert config_entry.unique_id == HASSIO_DATA.uuid @@ -312,6 +418,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, multiprotocol_addon_manager_mock, + addon_info, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -353,7 +460,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( } assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Open Thread Border Router" + assert result["title"] == "Silicon Labs Multiprotocol" assert result["data"] == expected_data assert result["options"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -361,7 +468,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] assert config_entry.data == expected_data assert config_entry.options == {} - assert config_entry.title == "Open Thread Border Router" + assert config_entry.title == "Silicon Labs Multiprotocol" assert config_entry.unique_id == HASSIO_DATA.uuid From ed3d38bb17007042c776e3cab3985bcf0dbaf200 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 8 Jun 2023 23:08:14 +0200 Subject: [PATCH 171/857] Request steam online friends from batches (#91167) * Request friends from batches * Add tests * Add tests * Fix feedback * Add libcall to verify request length * Improve tests --- .../components/steam_online/config_flow.py | 20 +++++++++-- tests/components/steam_online/__init__.py | 33 +++++++++++++++---- .../steam_online/test_config_flow.py | 10 ++++-- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 8356ad8bbc6..094db9ba207 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Steam integration.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from typing import Any import steam @@ -15,6 +15,9 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS +# To avoid too long request URIs, the amount of ids to request is limited +MAX_IDS_TO_REQUEST = 275 + def validate_input(user_input: dict[str, str]) -> dict[str, str | int]: """Handle common flow input validation.""" @@ -108,6 +111,11 @@ class SteamFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) +def _batch_ids(ids: list[str]) -> Iterator[list[str]]: + for i in range(0, len(ids), MAX_IDS_TO_REQUEST): + yield ids[i : i + MAX_IDS_TO_REQUEST] + + class SteamOptionsFlowHandler(config_entries.OptionsFlow): """Handle Steam client options.""" @@ -170,5 +178,11 @@ class SteamOptionsFlowHandler(config_entries.OptionsFlow): _users_str = [user["steamid"] for user in friends["friendslist"]["friends"]] except steam.api.HTTPError: return [] - names = interface.GetPlayerSummaries(steamids=_users_str) - return names["response"]["players"]["player"] + names = [] + for id_batch in _batch_ids(_users_str): + names.extend( + interface.GetPlayerSummaries(steamids=id_batch)["response"]["players"][ + "player" + ] + ) + return names diff --git a/tests/components/steam_online/__init__.py b/tests/components/steam_online/__init__.py index d41554e4d04..786c5d67782 100644 --- a/tests/components/steam_online/__init__.py +++ b/tests/components/steam_online/__init__.py @@ -1,5 +1,8 @@ """Tests for Steam integration.""" +import random +import string from unittest.mock import patch +import urllib.parse import steam @@ -11,8 +14,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry API_KEY = "abc123" -ACCOUNT_1 = "1234567890" -ACCOUNT_2 = "1234567891" +ACCOUNT_1 = "12345678901234567" +ACCOUNT_2 = "12345678912345678" ACCOUNT_NAME_1 = "testaccount1" ACCOUNT_NAME_2 = "testaccount2" @@ -30,6 +33,8 @@ CONF_OPTIONS_2 = { } } +MAX_LENGTH_STEAM_IDS = 30 + def create_entry(hass: HomeAssistant) -> MockConfigEntry: """Add config entry in Home Assistant.""" @@ -62,16 +67,32 @@ class MockedInterface(dict): def GetFriendList(self, steamid: str) -> dict: """Get friend list.""" - return {"friendslist": {"friends": [{"steamid": ACCOUNT_2}]}} + fake_friends = [{"steamid": ACCOUNT_2}] + for _i in range(0, 4): + fake_friends.append( + {"steamid": "".join(random.choices(string.digits, k=len(ACCOUNT_1)))} + ) + return {"friendslist": {"friends": fake_friends}} - def GetPlayerSummaries(self, steamids: str) -> dict: + def GetPlayerSummaries(self, steamids: str | list[str]) -> dict: """Get player summaries.""" + assert len(urllib.parse.quote(str(steamids))) <= MAX_LENGTH_STEAM_IDS return { "response": { "players": { "player": [ - {"steamid": ACCOUNT_1, "personaname": ACCOUNT_NAME_1}, - {"steamid": ACCOUNT_2, "personaname": ACCOUNT_NAME_2}, + { + "steamid": ACCOUNT_1, + "personaname": ACCOUNT_NAME_1, + "personastate": 1, + "avatarmedium": "", + }, + { + "steamid": ACCOUNT_2, + "personaname": ACCOUNT_NAME_2, + "personastate": 2, + "avatarmedium": "", + }, ] } } diff --git a/tests/components/steam_online/test_config_flow.py b/tests/components/steam_online/test_config_flow.py index a9d81a16fba..a62adb18776 100644 --- a/tests/components/steam_online/test_config_flow.py +++ b/tests/components/steam_online/test_config_flow.py @@ -138,7 +138,10 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: async def test_options_flow(hass: HomeAssistant) -> None: """Test updating options.""" entry = create_entry(hass) - with patch_interface(): + with patch_interface(), patch( + "homeassistant.components.steam_online.config_flow.MAX_IDS_TO_REQUEST", + return_value=2, + ): await hass.config_entries.async_setup(entry.entry_id) result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() @@ -159,7 +162,10 @@ async def test_options_flow(hass: HomeAssistant) -> None: async def test_options_flow_deselect(hass: HomeAssistant) -> None: """Test deselecting user.""" entry = create_entry(hass) - with patch_interface(): + with patch_interface(), patch( + "homeassistant.components.steam_online.config_flow.MAX_IDS_TO_REQUEST", + return_value=2, + ): await hass.config_entries.async_setup(entry.entry_id) result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() From c3936e6f14596ba9106979951f34ac418e3f583c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 8 Jun 2023 23:43:56 +0200 Subject: [PATCH 172/857] Replace isort with ruff (#94302) --- .github/workflows/ci.yaml | 41 ------------------- .pre-commit-config.yaml | 4 -- homeassistant/auth/providers/command_line.py | 2 +- homeassistant/auth/providers/homeassistant.py | 2 +- .../auth/providers/insecure_example.py | 2 +- .../auth/providers/legacy_api_password.py | 2 +- .../auth/providers/trusted_networks.py | 2 +- homeassistant/components/ads/sensor.py | 2 +- .../components/color_extractor/__init__.py | 2 +- .../components/generic/config_flow.py | 2 +- .../components/google_assistant/__init__.py | 2 +- .../components/huawei_lte/config_flow.py | 2 +- .../components/image_upload/__init__.py | 2 +- homeassistant/components/logger/__init__.py | 2 +- homeassistant/components/mqtt/subscription.py | 2 +- .../components/progettihwsw/__init__.py | 2 +- .../components/progettihwsw/binary_sensor.py | 2 +- .../components/progettihwsw/switch.py | 2 +- .../recorder/table_managers/event_data.py | 2 +- .../recorder/table_managers/event_types.py | 2 +- .../table_managers/state_attributes.py | 2 +- .../recorder/table_managers/states_meta.py | 2 +- .../remote_rpi_gpio/binary_sensor.py | 2 +- .../components/remote_rpi_gpio/switch.py | 2 +- homeassistant/components/rfxtrx/__init__.py | 2 +- .../components/rfxtrx/config_flow.py | 2 +- homeassistant/components/tellduslive/cover.py | 2 +- .../components/tensorflow/image_processing.py | 2 +- homeassistant/components/w800rf32/__init__.py | 2 +- .../components/w800rf32/binary_sensor.py | 2 +- .../zha/core/cluster_handlers/closures.py | 2 +- .../zha/core/cluster_handlers/general.py | 12 +++--- .../core/cluster_handlers/homeautomation.py | 2 +- .../zha/core/cluster_handlers/hvac.py | 2 +- .../zha/core/cluster_handlers/lighting.py | 2 +- .../zha/core/cluster_handlers/lightlink.py | 2 +- .../cluster_handlers/manufacturerspecific.py | 2 +- .../zha/core/cluster_handlers/measurement.py | 2 +- .../zha/core/cluster_handlers/protocol.py | 2 +- .../zha/core/cluster_handlers/security.py | 2 +- .../zha/core/cluster_handlers/smartenergy.py | 2 +- .../components/zha/core/discovery.py | 2 +- pylint/ruff.toml | 7 ++++ pyproject.toml | 22 ++++------ requirements_test_pre_commit.txt | 1 - script/ruff.toml | 7 ++++ .../tests/test_config_flow.py | 8 ++-- .../device_action/tests/test_device_action.py | 2 +- .../tests/test_device_condition.py | 2 +- .../tests/test_device_trigger.py | 2 +- tests/components/rfxtrx/test_device_action.py | 2 +- tests/components/vicare/conftest.py | 2 +- tests/components/vicare/test_config_flow.py | 2 +- tests/components/waze_travel_time/conftest.py | 2 +- .../waze_travel_time/test_sensor.py | 2 +- tests/ruff.toml | 16 ++++++++ 56 files changed, 95 insertions(+), 117 deletions(-) create mode 100644 pylint/ruff.toml create mode 100644 script/ruff.toml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8a99287b869..b0aa9c74ea1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -338,44 +338,6 @@ jobs: shopt -s globstar pre-commit run --hook-stage manual ruff --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - lint-isort: - name: Check isort - runs-on: ubuntu-22.04 - needs: - - info - - pre-commit - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - check-latest: true - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache/restore@v3.3.1 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache/restore@v3.3.1 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Run isort - run: | - . venv/bin/activate - pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure - lint-other: name: Check other linters runs-on: ubuntu-22.04 @@ -719,7 +681,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -844,7 +805,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -952,7 +912,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3fac4229f8c..d6cd3f43b10 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,10 +22,6 @@ repos: - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/|homeassistant/generated/ - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index f63d6d465f6..bfe8a2fdddb 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -13,8 +13,8 @@ from homeassistant.const import CONF_COMMAND from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow CONF_ARGS = "args" CONF_META = "meta" diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 050a0660a6b..6f621b93a6a 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -16,8 +16,8 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.storage import Store -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow STORAGE_VERSION = 1 STORAGE_KEY = "auth_provider.homeassistant" diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index 47626686f9d..f7f01e74c27 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -11,8 +11,8 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow USER_SCHEMA = vol.Schema( { diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 72ba3b1ecb3..0cadbf07589 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -15,8 +15,8 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 04db5fc287b..6962671cb2f 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -23,9 +23,9 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from .. import InvalidAuthError from ..models import Credentials, RefreshToken, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow IPAddress = IPv4Address | IPv6Address IPNetwork = IPv4Network | IPv6Network diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 76d73f75a8b..17aede2bd2b 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -10,6 +10,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from .. import ads from . import ( ADS_TYPEMAP, CONF_ADS_FACTOR, @@ -18,7 +19,6 @@ from . import ( STATE_KEY_STATE, AdsEntity, ) -from .. import ads DEFAULT_NAME = "ADS sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index aa2d9cfbfdb..d0a6b53964b 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -3,10 +3,10 @@ import asyncio import io import logging -from PIL import UnidentifiedImageError import aiohttp import async_timeout from colorthief import ColorThief +from PIL import UnidentifiedImageError import voluptuous as vol from homeassistant.components.light import ( diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 94a885a7c5d..d48c4619abd 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -9,10 +9,10 @@ import io import logging from typing import Any -import PIL from aiohttp import web from async_timeout import timeout from httpx import HTTPStatusError, RequestError, TimeoutException +import PIL import voluptuous as vol import yarl diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 3a0315a5931..37f3a2c3edc 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -28,10 +28,10 @@ from .const import ( # noqa: F401 DEFAULT_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSED_DOMAINS, DOMAIN, + EVENT_QUERY_RECEIVED, # noqa: F401 SERVICE_REQUEST_SYNC, SOURCE_CLOUD, ) -from .const import EVENT_QUERY_RECEIVED # noqa: F401 from .http import GoogleAssistantView, GoogleConfig from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index f6c3b69ddeb..6d7b0b9bb11 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -8,7 +8,6 @@ from urllib.parse import urlparse from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection -from huawei_lte_api.Session import GetResponseType from huawei_lte_api.exceptions import ( LoginErrorPasswordWrongException, LoginErrorUsernamePasswordOverrunException, @@ -16,6 +15,7 @@ from huawei_lte_api.exceptions import ( LoginErrorUsernameWrongException, ResponseErrorException, ) +from huawei_lte_api.Session import GetResponseType from requests.exceptions import Timeout from url_normalize import url_normalize import voluptuous as vol diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 766be89f0d4..569df9c65e4 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -8,9 +8,9 @@ import secrets import shutil from typing import Any -from PIL import Image, ImageOps, UnidentifiedImageError from aiohttp import hdrs, web from aiohttp.web_request import FileField +from PIL import Image, ImageOps, UnidentifiedImageError import voluptuous as vol from homeassistant.components.http.static import CACHE_HEADERS diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index b1086d7f780..cd2761510d3 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -14,6 +14,7 @@ from . import websocket_api from .const import ( ATTR_LEVEL, DOMAIN, + EVENT_LOGGING_CHANGED, # noqa: F401 LOGGER_DEFAULT, LOGGER_FILTERS, LOGGER_LOGS, @@ -21,7 +22,6 @@ from .const import ( SERVICE_SET_DEFAULT_LEVEL, SERVICE_SET_LEVEL, ) -from .const import EVENT_LOGGING_CHANGED # noqa: F401 from .helpers import ( LoggerDomainConfig, LoggerSettings, diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 7ccc31bd335..dda80bba84e 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -8,8 +8,8 @@ import attr from homeassistant.core import HomeAssistant -from . import debug_info from .. import mqtt +from . import debug_info from .const import DEFAULT_QOS from .models import MessageCallbackType diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index bce25c07b17..d1d27b78769 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -1,7 +1,7 @@ """Automation manager for boards manufactured by ProgettiHWSW Italy.""" -from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI from ProgettiHWSW.input import Input +from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI from ProgettiHWSW.relay import Relay from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index 058d76cdf05..b2019389fe3 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -2,8 +2,8 @@ from datetime import timedelta import logging -from ProgettiHWSW.input import Input import async_timeout +from ProgettiHWSW.input import Input from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 956848a6594..dc7f838bcbc 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -3,8 +3,8 @@ from datetime import timedelta import logging from typing import Any -from ProgettiHWSW.relay import Relay import async_timeout +from ProgettiHWSW.relay import Relay from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 4e08719e572..85266a37939 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -10,11 +10,11 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventData from ..queries import get_shared_event_datas from ..util import chunked, execute_stmt_lambda_element +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index d5541c547d5..fd03bdd14d2 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,12 +9,12 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids from ..tasks import RefreshEventTypesTask from ..util import chunked, execute_stmt_lambda_element +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 442277be96e..3ae67b932bf 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -11,11 +11,11 @@ from homeassistant.core import Event from homeassistant.helpers.entity import entity_sources from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StateAttributes from ..queries import get_shared_attributes from ..util import chunked, execute_stmt_lambda_element +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index bc4a8cfd2d9..b8f6204d318 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -8,11 +8,11 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids from ..util import chunked, execute_stmt_lambda_element +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 37994830c4d..bc0e694e8eb 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -11,6 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .. import remote_rpi_gpio from . import ( CONF_BOUNCETIME, CONF_INVERT_LOGIC, @@ -19,7 +20,6 @@ from . import ( DEFAULT_INVERT_LOGIC, DEFAULT_PULL_MODE, ) -from .. import remote_rpi_gpio CONF_PORTS = "ports" diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index 862efb0f89d..962cf6b4f3c 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -12,8 +12,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC from .. import remote_rpi_gpio +from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC CONF_PORTS = "ports" diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index de8a9fc6b8d..0edd6f82195 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -8,8 +8,8 @@ import copy import logging from typing import Any, NamedTuple, cast -import RFXtrx as rfxtrxmod import async_timeout +import RFXtrx as rfxtrxmod import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 2e054ce4724..8d55208cbb7 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -8,8 +8,8 @@ import itertools import os from typing import Any, TypedDict, cast -import RFXtrx as rfxtrxmod from async_timeout import timeout +import RFXtrx as rfxtrxmod import serial import serial.tools.list_ports import voluptuous as vol diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index 57da852a356..4934bf811af 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -8,8 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TelldusLiveClient from .. import tellduslive +from . import TelldusLiveClient from .entry import TelldusLiveEntity diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index 60e9dc54b80..a149ea92371 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -7,8 +7,8 @@ import os import sys import time -from PIL import Image, ImageDraw, UnidentifiedImageError import numpy as np +from PIL import Image, ImageDraw, UnidentifiedImageError import tensorflow as tf # pylint: disable=import-error import voluptuous as vol diff --git a/homeassistant/components/w800rf32/__init__.py b/homeassistant/components/w800rf32/__init__.py index 88aaa934a7b..29701ec82ab 100644 --- a/homeassistant/components/w800rf32/__init__.py +++ b/homeassistant/components/w800rf32/__init__.py @@ -1,8 +1,8 @@ """Support for w800rf32 devices.""" import logging -import W800rf32 as w800 import voluptuous as vol +import W800rf32 as w800 from homeassistant.const import ( CONF_DEVICE, diff --git a/homeassistant/components/w800rf32/binary_sensor.py b/homeassistant/components/w800rf32/binary_sensor.py index 04007469992..6d1ce5c61c0 100644 --- a/homeassistant/components/w800rf32/binary_sensor.py +++ b/homeassistant/components/w800rf32/binary_sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -import W800rf32 as w800 import voluptuous as vol +import W800rf32 as w800 from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index ab58405b974..0c1ca20ae96 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -3,9 +3,9 @@ from zigpy.zcl.clusters import closures from homeassistant.core import callback -from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index d4014bbf697..8f47eda6e71 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -14,12 +14,6 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback from homeassistant.helpers.event import async_call_later -from . import ( - AttrReportConfig, - ClientClusterHandler, - ClusterHandler, - parse_and_log_command, -) from .. import registries from ..const import ( REPORT_CONFIG_ASAP, @@ -33,6 +27,12 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) +from . import ( + AttrReportConfig, + ClientClusterHandler, + ClusterHandler, + parse_and_log_command, +) from .helpers import is_hue_motion_sensor if TYPE_CHECKING: diff --git a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py index 981ed08ba00..8ca014f453e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -5,7 +5,6 @@ import enum from zigpy.zcl.clusters import homeautomation -from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, @@ -13,6 +12,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) +from . import AttrReportConfig, ClusterHandler @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 94154564e8c..8fd28a1dba7 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -14,7 +14,6 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback -from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_MAX_INT, @@ -22,6 +21,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) +from . import AttrReportConfig, ClusterHandler AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 56f3c701aa1..993ecca29cd 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -5,9 +5,9 @@ from functools import cached_property from zigpy.zcl.clusters import lighting -from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import REPORT_CONFIG_DEFAULT +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Ballast.cluster_id) diff --git a/homeassistant/components/zha/core/cluster_handlers/lightlink.py b/homeassistant/components/zha/core/cluster_handlers/lightlink.py index 437a4b4ecf8..bac4d8c09a9 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lightlink.py +++ b/homeassistant/components/zha/core/cluster_handlers/lightlink.py @@ -5,8 +5,8 @@ import zigpy.exceptions from zigpy.zcl.clusters import lightlink from zigpy.zcl.foundation import GENERAL_COMMANDS, GeneralCommand -from . import ClusterHandler, ClusterHandlerStatus from .. import registries +from . import ClusterHandler, ClusterHandlerStatus @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(lightlink.LightLink.cluster_id) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index d20888e1f55..9bdf49aadc1 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -10,7 +10,6 @@ import zigpy.zcl from homeassistant.core import callback -from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import ( ATTR_ATTRIBUTE_ID, @@ -24,6 +23,7 @@ from ..const import ( SIGNAL_ATTR_UPDATED, UNKNOWN, ) +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler if TYPE_CHECKING: from ..endpoint import Endpoint diff --git a/homeassistant/components/zha/core/cluster_handlers/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py index 8b882a299f6..beeb6296e32 100644 --- a/homeassistant/components/zha/core/cluster_handlers/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING import zigpy.zcl from zigpy.zcl.clusters import measurement -from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_DEFAULT, @@ -14,6 +13,7 @@ from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) +from . import AttrReportConfig, ClusterHandler from .helpers import is_hue_motion_sensor if TYPE_CHECKING: diff --git a/homeassistant/components/zha/core/cluster_handlers/protocol.py b/homeassistant/components/zha/core/cluster_handlers/protocol.py index 6398a8875b6..1643fe031cd 100644 --- a/homeassistant/components/zha/core/cluster_handlers/protocol.py +++ b/homeassistant/components/zha/core/cluster_handlers/protocol.py @@ -1,8 +1,8 @@ """Protocol cluster handlers module for Zigbee Home Automation.""" from zigpy.zcl.clusters import protocol -from . import ClusterHandler from .. import registries +from . import ClusterHandler @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index 7e4951ad672..56b925671e3 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -15,7 +15,6 @@ from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone from homeassistant.core import callback -from . import ClusterHandler, ClusterHandlerStatus from .. import registries from ..const import ( SIGNAL_ATTR_UPDATED, @@ -25,6 +24,7 @@ from ..const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) +from . import ClusterHandler, ClusterHandlerStatus if TYPE_CHECKING: from ..endpoint import Endpoint diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 1cb647ea313..8fd38425dff 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING import zigpy.zcl from zigpy.zcl.clusters import smartenergy -from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_ASAP, @@ -16,6 +15,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) +from . import AttrReportConfig, ClusterHandler if TYPE_CHECKING: from ..endpoint import Endpoint diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index e8b6f5f8304..0ca1c136271 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -16,7 +16,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType -from . import const as zha_const, registries as zha_regs from .. import ( # noqa: F401 pylint: disable=unused-import, alarm_control_panel, binary_sensor, @@ -33,6 +32,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, siren, switch, ) +from . import const as zha_const, registries as zha_regs # importing cluster handlers updates registries from .cluster_handlers import ( # noqa: F401 pylint: disable=unused-import, diff --git a/pylint/ruff.toml b/pylint/ruff.toml new file mode 100644 index 00000000000..271881141a9 --- /dev/null +++ b/pylint/ruff.toml @@ -0,0 +1,7 @@ +# This extend our general Ruff rules specifically for tests +extend = "../pyproject.toml" + +[isort] +known-third-party = [ + "pylint", +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6fbd810626b..60c534b8bf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,20 +77,6 @@ include = ["homeassistant*"] [tool.black] extend-exclude = "/generated/" -[tool.isort] -# https://github.com/PyCQA/isort/wiki/isort-Settings -profile = "black" -# will group `import x` and `from x import` of the same module. -force_sort_within_sections = true -known_first_party = [ - "homeassistant", - "tests", -] -forced_separate = [ - "tests", -] -combine_as_imports = true - [tool.pylint.MAIN] py-version = "3.10" ignore = [ @@ -248,6 +234,7 @@ select = [ "D", # docstrings "E", # pycodestyle "F", # pyflakes/autoflake + "I", # isort "ICN001", # import concentions; {name} should be imported as {asname} "PGH004", # Use specific rule codes when using noqa "PLC0414", # Useless import alias. Import alias does not rename original package. @@ -308,6 +295,13 @@ voluptuous = "vol" [tool.ruff.flake8-pytest-style] fixture-parentheses = false +[tool.ruff.isort] +force-sort-within-sections = true +known-first-party = [ + "homeassistant", +] +combine-as-imports = true + [tool.ruff.per-file-ignores] # Allow for main entry & scripts to write to stdout diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index cee1c684d8a..eff26bcfe82 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,6 +2,5 @@ black==23.3.0 codespell==2.2.2 -isort==5.12.0 ruff==0.0.272 yamllint==1.28.0 diff --git a/script/ruff.toml b/script/ruff.toml new file mode 100644 index 00000000000..9d77bf60af9 --- /dev/null +++ b/script/ruff.toml @@ -0,0 +1,7 @@ +# This extend our general Ruff rules specifically for tests +extend = "../pyproject.toml" + +[isort] +forced-separate = [ + "tests", +] \ No newline at end of file diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py index bc087119c6e..d0000b800df 100644 --- a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py @@ -5,15 +5,15 @@ from unittest.mock import patch import pytest from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.NEW_DOMAIN.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component diff --git a/script/scaffold/templates/device_action/tests/test_device_action.py b/script/scaffold/templates/device_action/tests/test_device_action.py index ce3f907b47b..7807a1389f5 100644 --- a/script/scaffold/templates/device_action/tests/test_device_action.py +++ b/script/scaffold/templates/device_action/tests/test_device_action.py @@ -2,8 +2,8 @@ import pytest from homeassistant.components import automation -from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/script/scaffold/templates/device_condition/tests/test_device_condition.py b/script/scaffold/templates/device_condition/tests/test_device_condition.py index 59781e24fe6..61ee7459bbf 100644 --- a/script/scaffold/templates/device_condition/tests/test_device_condition.py +++ b/script/scaffold/templates/device_condition/tests/test_device_condition.py @@ -4,8 +4,8 @@ from __future__ import annotations import pytest from homeassistant.components import automation -from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py index 8cfaa6f64a9..4b151e60f0d 100644 --- a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py +++ b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py @@ -2,8 +2,8 @@ import pytest from homeassistant.components import automation -from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.NEW_DOMAIN import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py index c4613d67282..c2c50cbca8c 100644 --- a/tests/components/rfxtrx/test_device_action.py +++ b/tests/components/rfxtrx/test_device_action.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import Any, NamedTuple -import RFXtrx import pytest +import RFXtrx import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 1137abbc54e..5085ff6661d 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -4,8 +4,8 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, Mock, patch -from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig import pytest +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from homeassistant.components.vicare.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 72fb8d0d0b6..0774848ef11 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -1,8 +1,8 @@ """Test the ViCare config flow.""" from unittest.mock import AsyncMock, patch -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError import pytest +from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError from syrupy.assertion import SnapshotAssertion from homeassistant.components import dhcp diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index 4d964d9f08a..65c2616d1dc 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -1,8 +1,8 @@ """Fixtures for Waze Travel Time tests.""" from unittest.mock import patch -from WazeRouteCalculator import WRCError import pytest +from WazeRouteCalculator import WRCError @pytest.fixture(name="mock_wrc", autouse=True) diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index 5b23d8a369a..a3367a48d2a 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -1,6 +1,6 @@ """Test Waze Travel Time sensors.""" -from WazeRouteCalculator import WRCError import pytest +from WazeRouteCalculator import WRCError from homeassistant.components.waze_travel_time.const import ( CONF_AVOID_FERRIES, diff --git a/tests/ruff.toml b/tests/ruff.toml index dafc6fd6ad7..73246163d2f 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -10,4 +10,20 @@ extend-select = [ "PT015", # Assertion always fails, replace with pytest.fail() "PT021", # use yield instead of request.addfinalizer "PT022", # No teardown in fixture, replace useless yield with return +] + +[isort] +known-first-party = [ + "homeassistant", + "tests", + "script", +] +known-third-party = [ + "syrupy", + "pytest", + "voluptuous", + "pylint", +] +forced-separate = [ + "tests", ] \ No newline at end of file From dafc7a15b191fa4f4d4d7d16ca047b10bad5be39 Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 8 Jun 2023 21:18:42 -0400 Subject: [PATCH 173/857] Bump Python-Roborock to 23.6 for crash fix (#94281) * bump to 23.5 * update to 23.5 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/mock_data.py | 4 ---- .../roborock/snapshots/test_diagnostics.ambr | 10 ++++++++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 0cd437278cf..39e13412e90 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.23.4"] + "requirements": ["python-roborock==0.23.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d788cc4ae9..086a23f3452 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.23.4 +python-roborock==0.23.6 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f81b4b4a7fc..497b7d0303a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1563,7 +1563,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.23.4 +python-roborock==0.23.6 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 8155c10fdbd..15e69cee9d9 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1,8 +1,6 @@ """Mock data for Roborock tests.""" from __future__ import annotations -import datetime - from roborock.containers import ( CleanRecord, CleanSummary, @@ -322,8 +320,6 @@ DND_TIMER = DnDTimer.from_dict( "enabled": 1, } ) -DND_TIMER.start_time = datetime.datetime(year=2023, month=6, day=1, hour=22) -DND_TIMER.end_time = datetime.datetime(year=2023, month=6, day=2, hour=7) STATUS = S7Status.from_dict( { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 5cb9b109368..432bad167cd 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -225,10 +225,16 @@ 'enabled': 1, 'endHour': 7, 'endMinute': 0, - 'endTime': '2023-06-02T07:00:00', + 'endTime': dict({ + '__type': "", + 'isoformat': '07:00:00', + }), 'startHour': 22, 'startMinute': 0, - 'startTime': '2023-06-01T22:00:00', + 'startTime': dict({ + '__type': "", + 'isoformat': '22:00:00', + }), }), 'lastCleanRecord': dict({ 'area': 20965000, From 7e75790281819d00e0409f202ac2c1c3984f1846 Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Fri, 9 Jun 2023 09:24:53 +0200 Subject: [PATCH 174/857] Update URL in readme (#94282) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 084949dc44e..0dc98a379a3 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ If you run into issues while using Home Assistant or during development of a component, check the `Home Assistant help section `__ of our website for further help and information. .. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg - :target: https://discord.gg/c5DvZ4e + :target: https://www.home-assistant.io/join-chat/ .. |screenshot-states| image:: https://raw.githubusercontent.com/home-assistant/core/master/docs/screenshots.png :target: https://demo.home-assistant.io .. |screenshot-integrations| image:: https://raw.githubusercontent.com/home-assistant/core/dev/docs/screenshot-integrations.png From 288708474b9e82503bb07b8ecb69809a5fe355f3 Mon Sep 17 00:00:00 2001 From: Jonathan Keljo Date: Fri, 9 Jun 2023 00:39:14 -0700 Subject: [PATCH 175/857] Upgrade sisyphus-control to 3.1.3 (#94310) --- homeassistant/components/sisyphus/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sisyphus/manifest.json b/homeassistant/components/sisyphus/manifest.json index 1a8d9e2e16b..dbb40344d66 100644 --- a/homeassistant/components/sisyphus/manifest.json +++ b/homeassistant/components/sisyphus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sisyphus", "iot_class": "local_push", "loggers": ["sisyphus_control"], - "requirements": ["sisyphus-control==3.1.2"] + "requirements": ["sisyphus-control==3.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 086a23f3452..7a5b2f78902 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2379,7 +2379,7 @@ simplepush==2.1.1 simplisafe-python==2023.05.0 # homeassistant.components.sisyphus -sisyphus-control==3.1.2 +sisyphus-control==3.1.3 # homeassistant.components.slack slackclient==2.5.0 From 5e3b632b149b7d68d0e9c46f9ab934812cb3680b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 9 Jun 2023 10:04:01 +0200 Subject: [PATCH 176/857] Drop call to usb.get_serial_by_id from zwave_js config flow (#94279) --- homeassistant/components/zwave_js/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index e6411e3b879..071b562ceea 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -415,7 +415,6 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): vid = discovery_info.vid pid = discovery_info.pid serial_number = discovery_info.serial_number - device = discovery_info.device manufacturer = discovery_info.manufacturer description = discovery_info.description # Zooz uses this vid/pid, but so do 2652 sticks @@ -430,7 +429,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" ) self._abort_if_unique_id_configured() - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + dev_path = discovery_info.device self.usb_path = dev_path self._title = usb.human_readable_device_name( dev_path, From 41022fdce4740c7de45455ba582ae1c6ad0de3de Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 9 Jun 2023 10:39:52 +0200 Subject: [PATCH 177/857] Add comments about removing deprecated code from sky_connect config flow (#94262) --- homeassistant/components/homeassistant_sky_connect/__init__.py | 1 + .../components/homeassistant_sky_connect/config_flow.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 0f7ec704715..5f17069f5d5 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -32,6 +32,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: return usb_dev = entry.data["device"] + # The call to get_serial_by_id can be removed in HA Core 2024.1 dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) if not await multi_pan_addon_using_device(hass, dev_path): diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 7bc514d5615..5ac44f3f290 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -58,6 +58,7 @@ class HomeAssistantSkyConnectOptionsFlow(silabs_multiprotocol_addon.OptionsFlowH ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" usb_dev = self.config_entry.data["device"] + # The call to get_serial_by_id can be removed in HA Core 2024.1 dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) return silabs_multiprotocol_addon.SerialPortSettings( device=dev_path, From 4085c4f6d86fbbe47840341eb552d2e44018231e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 9 Jun 2023 10:56:04 +0200 Subject: [PATCH 178/857] Tweak typing of Entity.platform (#88321) * Tweak typing of Entity.platform * Fix mypy errors * Fix update test * Improve comments --- homeassistant/components/rflink/__init__.py | 1 - homeassistant/components/update/__init__.py | 3 --- homeassistant/helpers/entity.py | 10 ++++++---- tests/components/update/test_init.py | 13 +++++-------- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index b563275297f..8df2d7ec343 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -323,7 +323,6 @@ class RflinkDevice(Entity): Contains the common logic for Rflink entities. """ - platform = None _state: bool | None = None _available = True _attr_should_poll = False diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 3c8a30d5b4f..e0244034865 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -236,9 +236,6 @@ class UpdateEntity(RestoreEntity): Update entities return the brand icon based on the integration domain by default. """ - if self.platform is None: - return None - return ( f"https://brands.home-assistant.io/_/{self.platform.platform_name}/icon.png" ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index ef526f956cd..cdb20833a3d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -235,13 +235,15 @@ class Entity(ABC): # this class. These may be used to customize the behavior of the entity. entity_id: str = None # type: ignore[assignment] - # Owning hass instance. Will be set by EntityPlatform + # Owning hass instance. Set by EntityPlatform by calling add_to_platform_start # While not purely typed, it makes typehinting more useful for us # and removes the need for constant None checks or asserts. hass: HomeAssistant = None # type: ignore[assignment] - # Owning platform instance. Will be set by EntityPlatform - platform: EntityPlatform | None = None + # Owning platform instance. Set by EntityPlatform by calling add_to_platform_start + # While not purely typed, it makes typehinting more useful for us + # and removes the need for constant None checks or asserts. + platform: EntityPlatform = None # type: ignore[assignment] # Entity description instance for this Entity entity_description: EntityDescription @@ -840,7 +842,7 @@ class Entity(ABC): self._call_on_remove_callbacks() self.hass = None # type: ignore[assignment] - self.platform = None + self.platform = None # type: ignore[assignment] self.parallel_updates = None async def add_to_platform_finish(self) -> None: diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index d0546b6a2ef..a7780f54f70 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -49,6 +49,7 @@ async def test_update(hass: HomeAssistant) -> None: """Test getting data from the mocked update entity.""" update = MockUpdateEntity() update.hass = hass + update.platform = MockEntityPlatform(hass) update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.1" @@ -57,7 +58,10 @@ async def test_update(hass: HomeAssistant) -> None: update._attr_title = "Title" assert update.entity_category is EntityCategory.DIAGNOSTIC - assert update.entity_picture is None + assert ( + update.entity_picture + == "https://brands.home-assistant.io/_/test_platform/icon.png" + ) assert update.installed_version == "1.0.0" assert update.latest_version == "1.0.1" assert update.release_summary == "Summary" @@ -76,13 +80,6 @@ async def test_update(hass: HomeAssistant) -> None: ATTR_TITLE: "Title", } - # Test with platform - update.platform = MockEntityPlatform(hass) - assert ( - update.entity_picture - == "https://brands.home-assistant.io/_/test_platform/icon.png" - ) - # Test no update available update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.0" From e25fbecfdc1400910ec0ae85ec99006d59078263 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 9 Jun 2023 11:28:12 +0200 Subject: [PATCH 179/857] Add missing super() call to EnergyCostSensor.add_to_platform_abort (#94322) --- homeassistant/components/energy/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 7518b163f3c..ae92ee2de58 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -434,6 +434,7 @@ class EnergyCostSensor(SensorEntity): def add_to_platform_abort(self) -> None: """Abort adding an entity to a platform.""" self.add_finished.set() + super().add_to_platform_abort() async def async_will_remove_from_hass(self) -> None: """Handle removing from hass.""" From 5fb41777fdbc89edf4ed79a5500eea83f998e52c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 9 Jun 2023 12:12:20 +0200 Subject: [PATCH 180/857] Cleanup MQTT schema from previous removed options (#94110) * Cleanup removed validator schema option of #62680 * Cleanup removed climate options and abbreviations * Removed white_value options for mqtt light * Remove whaite value from mqtt json light --- .../components/mqtt/abbreviations.py | 12 ------ homeassistant/components/mqtt/climate.py | 38 ------------------- homeassistant/components/mqtt/cover.py | 2 - .../components/mqtt/light/schema_basic.py | 22 +---------- .../components/mqtt/light/schema_json.py | 6 --- .../components/mqtt/light/schema_template.py | 13 +------ 6 files changed, 4 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index de593385c1f..5e44f9409b8 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -12,9 +12,6 @@ ABBREVIATIONS = { "avty_mode": "availability_mode", "avty_t": "availability_topic", "avty_tpl": "availability_template", - "away_mode_cmd_t": "away_mode_command_topic", - "away_mode_stat_tpl": "away_mode_state_template", - "away_mode_stat_t": "away_mode_state_topic", "b_tpl": "blue_template", "bri_cmd_tpl": "brightness_command_template", "bri_cmd_t": "brightness_command_topic", @@ -80,10 +77,6 @@ ABBREVIATIONS = { "fan_mode_stat_t": "fan_mode_state_topic", "frc_upd": "force_update", "g_tpl": "green_template", - "hold_cmd_tpl": "hold_command_template", - "hold_cmd_t": "hold_command_topic", - "hold_stat_tpl": "hold_state_template", - "hold_stat_t": "hold_state_topic", "hs_cmd_t": "hs_command_topic", "hs_cmd_tpl": "hs_command_template", "hs_stat_t": "hs_state_topic", @@ -243,7 +236,6 @@ ABBREVIATIONS = { "tilt_clsd_val": "tilt_closed_value", "tilt_cmd_t": "tilt_command_topic", "tilt_cmd_tpl": "tilt_command_template", - "tilt_inv_stat": "tilt_invert_state", "tilt_max": "tilt_max", "tilt_min": "tilt_min", "tilt_opnd_val": "tilt_opened_value", @@ -257,10 +249,6 @@ ABBREVIATIONS = { "val_tpl": "value_template", "whit_cmd_t": "white_command_topic", "whit_scl": "white_scale", - "whit_val_cmd_t": "white_value_command_topic", - "whit_val_scl": "white_value_scale", - "whit_val_stat_t": "white_value_state_topic", - "whit_val_tpl": "white_value_template", "xy_cmd_t": "xy_command_topic", "xy_cmd_tpl": "xy_command_template", "xy_stat_t": "xy_state_topic", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index ba70836cac9..755df281736 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -93,11 +93,6 @@ CONF_ACTION_TOPIC = "action_topic" CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" -# AWAY and HOLD mode topics and templates are no longer supported, -# support was removed with release 2022.9 -CONF_AWAY_MODE_COMMAND_TOPIC = "away_mode_command_topic" -CONF_AWAY_MODE_STATE_TEMPLATE = "away_mode_state_template" -CONF_AWAY_MODE_STATE_TOPIC = "away_mode_state_topic" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" @@ -106,13 +101,6 @@ CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" CONF_FAN_MODE_LIST = "fan_modes" CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template" CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic" -# AWAY and HOLD mode topics and templates are no longer supported, -# support was removed with release 2022.9 -CONF_HOLD_COMMAND_TEMPLATE = "hold_command_template" -CONF_HOLD_COMMAND_TOPIC = "hold_command_topic" -CONF_HOLD_STATE_TEMPLATE = "hold_state_template" -CONF_HOLD_STATE_TOPIC = "hold_state_topic" -CONF_HOLD_LIST = "hold_modes" CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" @@ -133,8 +121,6 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" CONF_PRESET_MODES_LIST = "preset_modes" -# Support CONF_SEND_IF_OFF is removed with release 2022.9 -CONF_SEND_IF_OFF = "send_if_off" CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" @@ -362,18 +348,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - # Support CONF_SEND_IF_OFF is removed with release 2022.9 - cv.removed(CONF_SEND_IF_OFF), - # AWAY and HOLD mode topics and templates are no longer supported, - # support was removed with release 2022.9 - cv.removed(CONF_AWAY_MODE_COMMAND_TOPIC), - cv.removed(CONF_AWAY_MODE_STATE_TEMPLATE), - cv.removed(CONF_AWAY_MODE_STATE_TOPIC), - cv.removed(CONF_HOLD_COMMAND_TEMPLATE), - cv.removed(CONF_HOLD_COMMAND_TOPIC), - cv.removed(CONF_HOLD_STATE_TEMPLATE), - cv.removed(CONF_HOLD_STATE_TOPIC), - cv.removed(CONF_HOLD_LIST), # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # was already removed or never added support was deprecated with release 2023.2 @@ -391,18 +365,6 @@ _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA DISCOVERY_SCHEMA = vol.All( _DISCOVERY_SCHEMA_BASE, - # Support CONF_SEND_IF_OFF is removed with release 2022.9 - cv.removed(CONF_SEND_IF_OFF), - # AWAY and HOLD mode topics and templates are no longer supported, - # support was removed with release 2022.9 - cv.removed(CONF_AWAY_MODE_COMMAND_TOPIC), - cv.removed(CONF_AWAY_MODE_STATE_TEMPLATE), - cv.removed(CONF_AWAY_MODE_STATE_TOPIC), - cv.removed(CONF_HOLD_COMMAND_TEMPLATE), - cv.removed(CONF_HOLD_COMMAND_TOPIC), - cv.removed(CONF_HOLD_STATE_TEMPLATE), - cv.removed(CONF_HOLD_STATE_TOPIC), - cv.removed(CONF_HOLD_LIST), # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added # support was deprecated with release 2023.2 and will be removed with release 2023.8 diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index da2f1b4496d..0b435db0b7a 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -200,13 +200,11 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE, validate_options, ) DISCOVERY_SCHEMA = vol.All( - cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_options, ) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index b3659a67e61..7f2c2cf5e06 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -111,10 +111,6 @@ CONF_XY_STATE_TOPIC = "xy_state_topic" CONF_XY_VALUE_TEMPLATE = "xy_value_template" CONF_WHITE_COMMAND_TOPIC = "white_command_topic" CONF_WHITE_SCALE = "white_scale" -CONF_WHITE_VALUE_COMMAND_TOPIC = "white_value_command_topic" -CONF_WHITE_VALUE_SCALE = "white_value_scale" -CONF_WHITE_VALUE_STATE_TOPIC = "white_value_state_topic" -CONF_WHITE_VALUE_TEMPLATE = "white_value_template" CONF_ON_COMMAND_TYPE = "on_command_type" MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( @@ -167,7 +163,7 @@ VALUE_TEMPLATE_KEYS = [ CONF_XY_VALUE_TEMPLATE, ] -_PLATFORM_SCHEMA_BASE = ( +PLATFORM_SCHEMA_MODERN_BASIC = ( MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BRIGHTNESS_COMMAND_TEMPLATE): cv.template, @@ -228,21 +224,7 @@ _PLATFORM_SCHEMA_BASE = ( ) DISCOVERY_SCHEMA_BASIC = vol.All( - # CONF_WHITE_VALUE_* is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_COMMAND_TOPIC), - cv.removed(CONF_WHITE_VALUE_SCALE), - cv.removed(CONF_WHITE_VALUE_STATE_TOPIC), - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN_BASIC = vol.All( - # CONF_WHITE_VALUE_* is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_COMMAND_TOPIC), - cv.removed(CONF_WHITE_VALUE_SCALE), - cv.removed(CONF_WHITE_VALUE_STATE_TOPIC), - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE, + PLATFORM_SCHEMA_MODERN_BASIC.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index c40dae659b7..70992887ca7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -101,8 +101,6 @@ CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" -CONF_WHITE_VALUE = "white_value" - def valid_color_configuration(config: ConfigType) -> ConfigType: """Test color_mode is not combined with deprecated config.""" @@ -158,15 +156,11 @@ _PLATFORM_SCHEMA_BASE = ( ) DISCOVERY_SCHEMA_JSON = vol.All( - # CONF_WHITE_VALUE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_color_configuration, ) PLATFORM_SCHEMA_MODERN_JSON = vol.All( - # CONF_WHITE_VALUE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE), _PLATFORM_SCHEMA_BASE, valid_color_configuration, ) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index c2b4de289fd..063895d738c 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -75,7 +75,6 @@ CONF_GREEN_TEMPLATE = "green_template" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" CONF_RED_TEMPLATE = "red_template" -CONF_WHITE_VALUE_TEMPLATE = "white_value_template" COMMAND_TEMPLATES = (CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_OFF_TEMPLATE) VALUE_TEMPLATES = ( @@ -88,7 +87,7 @@ VALUE_TEMPLATES = ( CONF_STATE_TEMPLATE, ) -_PLATFORM_SCHEMA_BASE = ( +PLATFORM_SCHEMA_MODERN_TEMPLATE = ( MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BLUE_TEMPLATE): cv.template, @@ -111,15 +110,7 @@ _PLATFORM_SCHEMA_BASE = ( ) DISCOVERY_SCHEMA_TEMPLATE = vol.All( - # CONF_WHITE_VALUE_TEMPLATE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN_TEMPLATE = vol.All( - # CONF_WHITE_VALUE_TEMPLATE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE, + PLATFORM_SCHEMA_MODERN_TEMPLATE.extend({}, extra=vol.REMOVE_EXTRA), ) From c984604a6c485e196a0aaae084ccf42854d5b730 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 9 Jun 2023 12:30:26 +0200 Subject: [PATCH 181/857] Refactor some mqtt tests not the just use schema validation (#94330) Remove help_test_validate_platform_config --- .../mqtt/test_alarm_control_panel.py | 11 ++-- tests/components/mqtt/test_common.py | 27 ++------- tests/components/mqtt/test_fan.py | 5 +- tests/components/mqtt/test_humidifier.py | 5 +- tests/components/mqtt/test_init.py | 56 ++++++++++--------- tests/components/mqtt/test_light.py | 5 +- tests/components/mqtt/test_light_json.py | 5 +- tests/components/mqtt/test_sensor.py | 5 +- 8 files changed, 47 insertions(+), 72 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index a30a15d5098..ee32b7131c4 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -64,7 +64,6 @@ from .test_common import ( help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, - help_test_validate_platform_config, ) from tests.common import async_fire_mqtt_message @@ -131,7 +130,7 @@ def alarm_control_panel_platform_only(): @pytest.mark.parametrize( - ("config", "valid"), + ("hass_config", "valid"), [ ( { @@ -170,10 +169,14 @@ def alarm_control_panel_platform_only(): ], ) async def test_fail_setup_without_state_or_command_topic( - hass: HomeAssistant, config, valid + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, valid ) -> None: """Test for failing setup with no state or command topic.""" - assert help_test_validate_platform_config(hass, config) == valid + if valid: + await mqtt_mock_entry() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index f9df3450c8d..ce4e7909154 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -9,17 +9,14 @@ from typing import Any from unittest.mock import ANY, MagicMock, patch import pytest -import voluptuous as vol import yaml from homeassistant import config as module_hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info -from homeassistant.components.mqtt.config_integration import PLATFORM_CONFIG_SCHEMA_BASE from homeassistant.components.mqtt.const import MQTT_DISCONNECTED from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED from homeassistant.components.mqtt.models import PublishPayloadType -from homeassistant.config import async_log_exception from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -70,8 +67,6 @@ _MqttMessageType = list[tuple[str, str]] _AttributesType = list[tuple[str, Any]] _StateDataType = list[tuple[_MqttMessageType, str | None, _AttributesType | None]] -MQTT_YAML_SCHEMA = vol.Schema({mqtt.DOMAIN: PLATFORM_CONFIG_SCHEMA_BASE}) - def help_all_subscribe_calls(mqtt_client_mock: MqttMockPahoClient) -> list[Any]: """Test of a call.""" @@ -82,20 +77,6 @@ def help_all_subscribe_calls(mqtt_client_mock: MqttMockPahoClient) -> list[Any]: return all_calls -def help_test_validate_platform_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType | None: - """Test the schema validation.""" - try: - # validate the schema - MQTT_YAML_SCHEMA(config) - return True - except vol.Error as exc: - # log schema exceptions - async_log_exception(exc, mqtt.DOMAIN, config, hass) - return False - - async def help_setup_component( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator | None, @@ -428,10 +409,14 @@ async def help_test_default_availability_list_single( {"topic": "availability-topic1"}, ] config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - help_test_validate_platform_config(hass, config) + + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry.add_to_hass(hass) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await entry.async_setup(hass) assert ( - "Invalid config for [mqtt]: two or more values in the same group of exclusion 'availability'" + "two or more values in the same group of exclusion 'availability'" in caplog.text ) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index c274c18bec0..c4181a3f885 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -97,10 +97,7 @@ async def test_fail_setup_if_no_command_topic( """Test if command fails with command topic.""" with pytest.raises(AssertionError): await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: required key not provided @ data['mqtt']['fan'][0]['command_topic']" - in caplog.text - ) + assert "Invalid config for [mqtt]: required key not provided" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 90b2e6d5ba6..08050aec8a0 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -139,10 +139,7 @@ async def test_fail_setup_if_no_command_topic( """Test if command fails with command topic.""" with pytest.raises(AssertionError): await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: required key not provided @ data['mqtt']['humidifier'][0]['command_topic']. Got None" - in caplog.text - ) + assert "Invalid config for [mqtt]: required key not provided" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 1e3dbc6c323..eee1d006137 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -37,7 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .test_common import help_all_subscribe_calls, help_test_validate_platform_config +from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, @@ -2066,50 +2066,52 @@ async def test_handle_message_callback( assert callbacks[0].payload == "test-payload" -@patch("homeassistant.components.mqtt.PLATFORMS", []) -async def test_setup_manual_mqtt_with_platform_key( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test set up a manual MQTT item with a platform key.""" - config = { - mqtt.DOMAIN: { - "light": { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "light": { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + } } } - } - help_test_validate_platform_config(hass, config) + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", []) +async def test_setup_manual_mqtt_with_platform_key( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test set up a manual MQTT item with a platform key.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" in caplog.text ) +@pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {"light": {"name": "test"}}}]) +@pytest.mark.xfail(reason="Invalid config for [mqtt]: required key not provided") @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_with_invalid_config( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test set up a manual MQTT item with an invalid config.""" - config = {mqtt.DOMAIN: {"light": {"name": "test"}}} - help_test_validate_platform_config(hass, config) + with pytest.raises(AssertionError): + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']." " Got None. (See ?, line ?)" in caplog.text ) -@patch("homeassistant.components.mqtt.PLATFORMS", []) -async def test_setup_manual_mqtt_empty_platform( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test set up a manual MQTT platform without items.""" - config: ConfigType = {mqtt.DOMAIN: {"light": []}} - help_test_validate_platform_config(hass, config) - assert "voluptuous.error.MultipleInvalid" not in caplog.text - - @patch("homeassistant.components.mqtt.PLATFORMS", []) @pytest.mark.parametrize( ("mqtt_config_entry_data", "protocol"), diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 22330d65c2a..884ff3bd2fd 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -253,10 +253,7 @@ async def test_fail_setup_if_no_command_topic( """Test if command fails with command topic.""" with pytest.raises(AssertionError): await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." - in caplog.text - ) + assert "Invalid config for [mqtt]: required key not provided" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 6c653045aa2..5a7bedd91e6 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -198,10 +198,7 @@ async def test_fail_setup_if_no_command_topic( """Test if setup fails with no command topic.""" with pytest.raises(AssertionError): await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." - in caplog.text - ) + assert "Invalid config for [mqtt]: required key not provided" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 16697939f50..8f2aa754bac 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -692,10 +692,7 @@ async def test_default_availability_list_single( ) -> None: """Test availability list and availability_topic are mutually exclusive.""" await help_test_default_availability_list_single( - hass, - caplog, - sensor.DOMAIN, - DEFAULT_CONFIG, + hass, caplog, sensor.DOMAIN, DEFAULT_CONFIG ) From 239e2d9820197faaa11d20dcdc8d540099e035ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 9 Jun 2023 13:15:35 +0200 Subject: [PATCH 182/857] Migrate microsoft_face to EntityComponent (#94338) --- .../components/microsoft_face/__init__.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 82a9accac59..f57a9146858 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -73,12 +74,16 @@ SCHEMA_TRAIN_SERVICE = vol.Schema({vol.Required(ATTR_GROUP): cv.slugify}) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Microsoft Face.""" + component = EntityComponent[MicrosoftFaceGroupEntity]( + logging.getLogger(__name__), DOMAIN, hass + ) entities: dict[str, MicrosoftFaceGroupEntity] = {} face = MicrosoftFace( hass, config[DOMAIN].get(CONF_AZURE_REGION), config[DOMAIN].get(CONF_API_KEY), config[DOMAIN].get(CONF_TIMEOUT), + component, entities, ) @@ -99,9 +104,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await face.call_api("put", f"persongroups/{g_id}", {"name": name}) face.store[g_id] = {} + old_entity = entities.pop(g_id, None) + if old_entity: + await component.async_remove_entity(old_entity.entity_id) entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name) - entities[g_id].async_write_ha_state() + await component.async_add_entities([entities[g_id]]) except HomeAssistantError as err: _LOGGER.error("Can't create group '%s' with error: %s", g_id, err) @@ -118,7 +126,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: face.store.pop(g_id) entity = entities.pop(g_id) - hass.states.async_remove(entity.entity_id, service.context) + await component.async_remove_entity(entity.entity_id) except HomeAssistantError as err: _LOGGER.error("Can't delete group '%s' with error: %s", g_id, err) @@ -244,7 +252,7 @@ class MicrosoftFaceGroupEntity(Entity): class MicrosoftFace: """Microsoft Face api for Home Assistant.""" - def __init__(self, hass, server_loc, api_key, timeout, entities): + def __init__(self, hass, server_loc, api_key, timeout, component, entities): """Initialize Microsoft Face api.""" self.hass = hass self.websession = async_get_clientsession(hass) @@ -252,6 +260,7 @@ class MicrosoftFace: self._api_key = api_key self._server_url = f"https://{server_loc}.{FACE_API_URL}" self._store = {} + self._component: EntityComponent[MicrosoftFaceGroupEntity] = component self._entities = entities @property @@ -263,25 +272,30 @@ class MicrosoftFace: """Load all group/person data into local store.""" groups = await self.call_api("get", "persongroups") - tasks = [] + remove_tasks = [] + new_entities = [] for group in groups: g_id = group["personGroupId"] self._store[g_id] = {} + old_entity = self._entities.pop(g_id, None) + if old_entity: + remove_tasks.append( + self._component.async_remove_entity(old_entity.entity_id) + ) + self._entities[g_id] = MicrosoftFaceGroupEntity( self.hass, self, g_id, group["name"] ) + new_entities.append(self._entities[g_id]) persons = await self.call_api("get", f"persongroups/{g_id}/persons") for person in persons: self._store[g_id][person["name"]] = person["personId"] - tasks.append( - asyncio.create_task(self._entities[g_id].async_update_ha_state()) - ) - - if tasks: - await asyncio.wait(tasks) + if remove_tasks: + await asyncio.gather(remove_tasks) + await self._component.async_add_entities(new_entities) async def call_api(self, method, function, data=None, binary=False, params=None): """Make an api call.""" From 59f5b8f2d6d979055dc4f1b3f0d9b8790811f5ee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 9 Jun 2023 15:17:41 +0200 Subject: [PATCH 183/857] Remove unneeded checks for Entity.platform (#94321) * Remove unneeded checks for Entity.platform * Update tests * Prevent breaking integrations without an EntityComponent * Warn when entity has no platform --- .../components/device_tracker/config_entry.py | 1 - .../components/meteo_france/sensor.py | 6 +-- .../components/meteo_france/weather.py | 6 +-- homeassistant/components/mysensors/device.py | 1 - .../components/synology_dsm/camera.py | 1 - homeassistant/components/tts/media_source.py | 1 - homeassistant/components/zha/light.py | 2 +- homeassistant/helpers/entity.py | 49 ++++++++++++------ tests/components/arcam_fmj/conftest.py | 3 +- tests/components/number/test_init.py | 5 ++ tests/helpers/test_entity.py | 50 +++++++++++++++---- tests/helpers/test_restore_state.py | 14 ++++-- 12 files changed, 95 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index c4450ab60a7..286929c5345 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -373,7 +373,6 @@ class ScannerEntity(BaseTrackerEntity): # Entities without a unique ID don't have a device if ( not self.registry_entry - or not self.platform or not self.platform.config_entry or not self.mac_address or (device_entry := self.find_device_entry()) is None diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index c87aea05260..ca3284d957c 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -247,11 +247,7 @@ class MeteoFranceSensor(CoordinatorEntity[DataUpdateCoordinator[_DataT]], Sensor @property def device_info(self) -> DeviceInfo: """Return the device info.""" - assert ( - self.platform - and self.platform.config_entry - and self.platform.config_entry.unique_id - ) + assert self.platform.config_entry and self.platform.config_entry.unique_id return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index e1a530eef97..7709ba0a638 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -109,11 +109,7 @@ class MeteoFranceWeather( @property def device_info(self) -> DeviceInfo: """Return the device info.""" - assert ( - self.platform - and self.platform.config_entry - and self.platform.config_entry.unique_id - ) + assert self.platform.config_entry and self.platform.config_entry.unique_id return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index d7405dba187..42c5a40636e 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -227,7 +227,6 @@ class MySensorsEntity(MySensorsDevice, Entity): """Return entity specific state attributes.""" attr = self._extra_attributes - assert self.platform assert self.platform.config_entry attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index b85ef5f2e3a..425475dc0d0 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -128,7 +128,6 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C _LOGGER.debug("Update stream URL for camera %s", self.camera_data.name) self.stream.update_source(url) - assert self.platform assert self.platform.config_entry self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 9fc0d40dae0..d371d457dde 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -165,7 +165,6 @@ class TTSMediaSource(MediaSource): raise BrowseError("Unknown provider") if isinstance(engine_instance, TextToSpeechEntity): - assert engine_instance.platform is not None engine_domain = engine_instance.platform.domain else: engine_domain = engine diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 705176ceda4..9e71691aaa5 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -622,7 +622,7 @@ class BaseLight(LogMixin, light.LightEntity): ) if self._debounced_member_refresh is not None: self.debug("transition complete - refreshing group member states") - assert self.platform and self.platform.config_entry + assert self.platform.config_entry self.platform.config_entry.async_create_background_task( self.hass, self._debounced_member_refresh.async_call(), diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index cdb20833a3d..68f64f0c749 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -258,6 +258,9 @@ class Entity(ABC): # it should be using async_write_ha_state. _async_update_ha_state_reported = False + # If we reported this entity was added without its platform set + _no_platform_reported = False + # Protect for multiple updates _update_staged = False @@ -331,7 +334,6 @@ class Entity(ABC): if hasattr(self, "_attr_name"): return self._attr_name if self.translation_key is not None and self.has_entity_name: - assert self.platform name_translation_key = ( f"component.{self.platform.platform_name}.entity.{self.platform.domain}" f".{self.translation_key}.name" @@ -584,6 +586,22 @@ class Entity(ABC): if self.hass is None: raise RuntimeError(f"Attribute hass is None for {self}") + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + if self.platform is None and not self._no_platform_reported: # type: ignore[unreachable] + report_issue = self._suggest_report_issue() # type: ignore[unreachable] + _LOGGER.warning( + ( + "Entity %s (%s) does not have a platform, this may be caused by " + "adding it manually instead of with an EntityComponent helper" + ", please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + self._no_platform_reported = True + if self.entity_id is None: raise NoEntitySpecifiedError( f"No entity id specified for entity {self.name}" @@ -636,7 +654,6 @@ class Entity(ABC): if entry and entry.disabled_by: if not self._disabled_reported: self._disabled_reported = True - assert self.platform is not None _LOGGER.warning( ( "Entity %s is incorrectly being triggered for updates while it" @@ -861,6 +878,8 @@ class Entity(ABC): If the entity doesn't have a non disabled entry in the entity registry, or if force_remove=True, its state will be removed. """ + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 if self.platform and self._platform_state != EntityPlatformState.ADDED: raise HomeAssistantError( f"Entity {self.entity_id} async_remove called twice" @@ -908,19 +927,18 @@ class Entity(ABC): Not to be extended by integrations. """ - if self.platform: - info = { - "domain": self.platform.platform_name, - "custom_component": "custom_components" in type(self).__module__, - } + info = { + "domain": self.platform.platform_name, + "custom_component": "custom_components" in type(self).__module__, + } - if self.platform.config_entry: - info["source"] = SOURCE_CONFIG_ENTRY - info["config_entry"] = self.platform.config_entry.entry_id - else: - info["source"] = SOURCE_PLATFORM_CONFIG + if self.platform.config_entry: + info["source"] = SOURCE_CONFIG_ENTRY + info["config_entry"] = self.platform.config_entry.entry_id + else: + info["source"] = SOURCE_PLATFORM_CONFIG - self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info + self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests @@ -940,6 +958,8 @@ class Entity(ABC): Not to be extended by integrations. """ + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 if self.platform: self.hass.data[DATA_ENTITY_SOURCE].pop(self.entity_id) @@ -974,7 +994,6 @@ class Entity(ABC): await self.async_remove(force_remove=True) - assert self.platform is not None self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) @@ -1048,6 +1067,8 @@ class Entity(ABC): "create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 if self.platform: report_issue += ( f"+label%3A%22integration%3A+{self.platform.platform_name}%22" diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index 39b44d2ad9e..693cdc685c9 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.const import CONF_HOST, CONF_PORT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockEntityPlatform MOCK_HOST = "127.0.0.1" MOCK_PORT = 50000 @@ -75,6 +75,7 @@ def player_fixture(hass, state): player = ArcamFmj(MOCK_NAME, state, MOCK_UUID) player.entity_id = MOCK_ENTITY_ID player.hass = hass + player.platform = MockEntityPlatform(hass) player.async_write_ha_state = Mock() return player diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 6cd9a53b6f4..b039f3c7eb5 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -35,6 +35,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( + MockEntityPlatform, async_mock_restore_state_shutdown_restart, mock_restore_cache_with_extra_data, ) @@ -246,6 +247,7 @@ async def test_deprecation_warnings( """Test overriding the deprecated attributes is possible and warnings are logged.""" number = MockDefaultNumberEntityDeprecated() number.hass = hass + number.platform = MockEntityPlatform(hass) assert number.max_value == 100.0 assert number.min_value == 0.0 assert number.step == 1.0 @@ -254,6 +256,7 @@ async def test_deprecation_warnings( number_2 = MockNumberEntityDeprecated() number_2.hass = hass + number_2.platform = MockEntityPlatform(hass) assert number_2.max_value == 0.5 assert number_2.min_value == -0.5 assert number_2.step == 0.1 @@ -262,6 +265,7 @@ async def test_deprecation_warnings( number_3 = MockNumberEntityAttrDeprecated() number_3.hass = hass + number_3.platform = MockEntityPlatform(hass) assert number_3.max_value == 1000.0 assert number_3.min_value == -1000.0 assert number_3.step == 100.0 @@ -270,6 +274,7 @@ async def test_deprecation_warnings( number_4 = MockNumberEntityDescrDeprecated() number_4.hass = hass + number_4.platform = MockEntityPlatform(hass) assert number_4.max_value == 10.0 assert number_4.min_value == -10.0 assert number_4.step == 2.0 diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index e556d9d5451..3ea820e684c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -578,12 +578,14 @@ async def test_async_remove_no_platform(hass: HomeAssistant) -> None: async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: - """Test async_remove method when no platform set.""" + """Test async_remove runs on_remove callback.""" result = [] + platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" + await platform.async_add_entities([ent]) ent.async_on_remove(lambda: result.append(1)) await ent.async_remove() assert len(result) == 1 @@ -593,11 +595,12 @@ async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> No """Test in flight polling is ignored after removing.""" result = [] + platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" ent.async_on_remove(lambda: result.append(1)) - ent.async_write_ha_state() + await platform.async_add_entities([ent]) assert hass.states.get("test.test").state == STATE_UNKNOWN await ent.async_remove() assert len(result) == 1 @@ -798,18 +801,18 @@ async def test_setup_source(hass: HomeAssistant) -> None: async def test_removing_entity_unavailable(hass: HomeAssistant) -> None: """Test removing an entity that is still registered creates an unavailable state.""" - entry = er.RegistryEntry( + er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", disabled_by=None, ) + platform = MockEntityPlatform(hass, domain="hello") ent = entity.Entity() - ent.hass = hass ent.entity_id = "hello.world" - ent.registry_entry = entry - ent.async_write_ha_state() + ent._attr_unique_id = "test-unique-id" + await platform.async_add_entities([ent]) state = hass.states.get("hello.world") assert state is not None @@ -1112,19 +1115,48 @@ async def test_warn_using_async_update_ha_state( """Test we warn once when using async_update_ha_state without force_update.""" ent = entity.Entity() ent.hass = hass + ent.platform = MockEntityPlatform(hass) ent.entity_id = "hello.world" + error_message = "is using self.async_update_ha_state()" # When forcing, it should not trigger the warning caplog.clear() await ent.async_update_ha_state(force_refresh=True) - assert "is using self.async_update_ha_state()" not in caplog.text + assert error_message not in caplog.text # When not forcing, it should trigger the warning caplog.clear() await ent.async_update_ha_state() - assert "is using self.async_update_ha_state()" in caplog.text + assert error_message in caplog.text # When not forcing, it should not trigger the warning again caplog.clear() await ent.async_update_ha_state() - assert "is using self.async_update_ha_state()" not in caplog.text + assert error_message not in caplog.text + + +async def test_warn_no_platform( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we warn am entity does not have a platform.""" + ent = entity.Entity() + ent.hass = hass + ent.platform = MockEntityPlatform(hass) + ent.entity_id = "hello.world" + error_message = "does not have a platform" + + # No warning if the entity has a platform + caplog.clear() + ent.async_write_ha_state() + assert error_message not in caplog.text + + # Without a platform, it should trigger the warning + ent.platform = None + caplog.clear() + ent.async_write_ha_state() + assert error_message in caplog.text + + # Without a platform, it should not trigger the warning again + caplog.clear() + ent.async_write_ha_state() + assert error_message not in caplog.text diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index b5ce7afade0..56e931b4345 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -27,6 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from tests.common import ( + MockEntityPlatform, MockModule, MockPlatform, async_fire_time_changed, @@ -266,15 +267,16 @@ async def test_dump_data(hass: HomeAssistant) -> None: State("input_boolean.b5", "unavailable", {"restored": True}), ] + platform = MockEntityPlatform(hass, domain="input_boolean") entity = Entity() entity.hass = hass entity.entity_id = "input_boolean.b0" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) entity = RestoreEntity() entity.hass = hass entity.entity_id = "input_boolean.b1" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) data = async_get(hass) now = dt_util.utcnow() @@ -340,15 +342,16 @@ async def test_dump_error(hass: HomeAssistant) -> None: State("input_boolean.b2", "on"), ] + platform = MockEntityPlatform(hass, domain="input_boolean") entity = Entity() entity.hass = hass entity.entity_id = "input_boolean.b0" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) entity = RestoreEntity() entity.hass = hass entity.entity_id = "input_boolean.b1" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) data = async_get(hass) @@ -378,10 +381,11 @@ async def test_load_error(hass: HomeAssistant) -> None: async def test_state_saved_on_remove(hass: HomeAssistant) -> None: """Test that we save entity state on removal.""" + platform = MockEntityPlatform(hass, domain="input_boolean") entity = RestoreEntity() entity.hass = hass entity.entity_id = "input_boolean.b0" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) now = dt_util.utcnow() hass.states.async_set( From fc1eab1e7e68517b56bc6a691ad5ddb6a970a89e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Jun 2023 09:40:54 -0500 Subject: [PATCH 184/857] Bump sensirion-ble to 0.1.0 (#94352) --- homeassistant/components/sensirion_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensirion_ble/manifest.json b/homeassistant/components/sensirion_ble/manifest.json index 372f40ac9a8..38f66a88e8e 100644 --- a/homeassistant/components/sensirion_ble/manifest.json +++ b/homeassistant/components/sensirion_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensirion_ble", "iot_class": "local_push", - "requirements": ["sensirion-ble==0.0.1"] + "requirements": ["sensirion-ble==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a5b2f78902..33753515e41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ sense-energy==0.11.2 sense_energy==0.11.2 # homeassistant.components.sensirion_ble -sensirion-ble==0.0.1 +sensirion-ble==0.1.0 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 497b7d0303a..58144fb0b63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1703,7 +1703,7 @@ sense-energy==0.11.2 sense_energy==0.11.2 # homeassistant.components.sensirion_ble -sensirion-ble==0.0.1 +sensirion-ble==0.1.0 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 From 117ab4a0e5b319fee7133ad8b8278e1355f3938a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 9 Jun 2023 17:00:07 +0200 Subject: [PATCH 185/857] Bump reolink-aio to 0.6.0 (#94259) --- homeassistant/components/reolink/__init__.py | 3 --- homeassistant/components/reolink/host.py | 23 +++---------------- .../components/reolink/manifest.json | 2 +- homeassistant/components/reolink/update.py | 8 ++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 11 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index a5050d3c436..923df261d84 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -8,7 +8,6 @@ from datetime import timedelta import logging from typing import Literal -from aiohttp import ClientConnectorError import async_timeout from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError @@ -58,8 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await host.stop() raise ConfigEntryAuthFailed(err) from err except ( - ClientConnectorError, - asyncio.TimeoutError, ReolinkException, ReolinkError, ) as err: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index c57f7b1e77e..2e61c131490 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -222,11 +222,7 @@ class ReolinkHost: """Disconnect from the API, so the connection will be released.""" try: await self._api.unsubscribe() - except ( - aiohttp.ClientConnectorError, - asyncio.TimeoutError, - ReolinkError, - ) as err: + except ReolinkError as err: _LOGGER.error( "Reolink error while unsubscribing from host %s:%s: %s", self._api.host, @@ -236,11 +232,7 @@ class ReolinkHost: try: await self._api.logout() - except ( - aiohttp.ClientConnectorError, - asyncio.TimeoutError, - ReolinkError, - ) as err: + except ReolinkError as err: _LOGGER.error( "Reolink error while logging out for host %s:%s: %s", self._api.host, @@ -396,22 +388,13 @@ class ReolinkHost: try: await self._api.get_motion_state_all_ch() - except ( - aiohttp.ClientConnectorError, - ReolinkError, - ) as err: + except ReolinkError as err: _LOGGER.error( "Reolink error while polling motion state for host %s:%s: %s", self._api.host, self._api.port, str(err), ) - except asyncio.TimeoutError: - _LOGGER.error( - "Reolink timeout error while polling motion state for host %s:%s", - self._api.host, - self._api.port, - ) finally: # schedule next poll if not self._hass.is_stopping: diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 413c106b53e..46aee506f9c 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.16"] + "requirements": ["reolink-aio==0.6.0"] } diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index aeb44cb7740..54a42f9b1c7 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -30,8 +30,7 @@ async def async_setup_entry( ) -> None: """Set up update entities for Reolink component.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - if reolink_data.host.api.supported(None, "update"): - async_add_entities([ReolinkUpdateEntity(reolink_data)]) + async_add_entities([ReolinkUpdateEntity(reolink_data)]) class ReolinkUpdateEntity( @@ -64,7 +63,10 @@ class ReolinkUpdateEntity( if not self.coordinator.data: return self.installed_version - return self.coordinator.data + if isinstance(self.coordinator.data, str): + return self.coordinator.data + + return self.coordinator.data.version_string async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/requirements_all.txt b/requirements_all.txt index 33753515e41..ce7bce75f11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2265,7 +2265,7 @@ regenmaschine==2023.05.1 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.16 +reolink-aio==0.6.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58144fb0b63..c3faf412388 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1652,7 +1652,7 @@ regenmaschine==2023.05.1 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.16 +reolink-aio==0.6.0 # homeassistant.components.rflink rflink==0.0.65 From 26b78d2a7a08a0da8de772d86828de4424da9b9b Mon Sep 17 00:00:00 2001 From: Jafar Atili Date: Fri, 9 Jun 2023 21:54:11 +0300 Subject: [PATCH 186/857] fix: electrasmart - cast temperature to int in set_temperature (#94368) fix: cast temperature to int --- homeassistant/components/electrasmart/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 361f906133d..a9688939048 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -250,7 +250,7 @@ class ElectraClimateEntity(ClimateEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError("No target temperature provided") - self._electra_ac_device.set_temperature(temperature) + self._electra_ac_device.set_temperature(int(temperature)) await self._async_operate_electra_ac() def _update_device_attrs(self) -> None: From 3d678f5b99efdb34fb38a3bcb974889665889def Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Sat, 10 Jun 2023 09:21:33 +0200 Subject: [PATCH 187/857] Renson integration (#56374) * Implemented Renson integration * - renamed component to a better name - Made cleaner code by splitting up files into different one - Fixed issues regarding getting data from library - Added service.yaml file * Added Renson services * cleanup translations * added config_flow tests * changed config_flow, removed all services * use SensorEntityDescription + introduced new binarySensor * fixed config_flow test * renamed renson_endura_delta to renson * refactored sensors and implemented binary_sensor * Changed some sensors to non measurement and added entity_registery_enabled_default for config sensors * Enabled binary_sensor * changed import to new renamed module * Merge files into correct files + cleaned some code * Change use of EntityDescription * Update codeowners * Fixed lint issues * Fix sensor * Create test.yml * Update test.yml * add github action tests * Format json files * Remove deprecated code * Update homeassistant/components/renson/binary_sensor.py Co-authored-by: Paulus Schoutsen * Use Coordinqte in Sensor * Migrated binary sensor to use coordinate * Removed firmwareSensor * Add entity_catogory to binary_sensor * Add services * Revert "Add services" This reverts commit 028760d8d8454ce98cf14eed0c7927d228ccd5e6. * update requirements of Renson integration * Add services and fan * Fixed some issue + fixed PR comments * Cleanup code * Go back 2 years ago to the bare minimum for PR approval * Refactored code and added a lot of device classes to the entities * Fix some bugs * Add unique Id and some device class * Show the level value for CURRENT_LEVEL_FIELD instead of the raw data * Remove FILTER_PRESET_FIELD for now * Make the _attr_unique_id unique * Changed Renson tests * Moved Renson hass data into @dataclass * Changed test + added files to .coveragerc * Add device_class=SensorDeviceClass.Duration * Fix syntax --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- .coveragerc | 4 + CODEOWNERS | 2 + homeassistant/components/renson/__init__.py | 87 +++++ .../components/renson/config_flow.py | 70 ++++ homeassistant/components/renson/const.py | 3 + homeassistant/components/renson/entity.py | 47 +++ homeassistant/components/renson/manifest.json | 9 + homeassistant/components/renson/sensor.py | 317 ++++++++++++++++++ homeassistant/components/renson/strings.json | 15 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/renson/__init__.py | 1 + tests/components/renson/test_config_flow.py | 80 +++++ 15 files changed, 648 insertions(+) create mode 100644 homeassistant/components/renson/__init__.py create mode 100644 homeassistant/components/renson/config_flow.py create mode 100644 homeassistant/components/renson/const.py create mode 100644 homeassistant/components/renson/entity.py create mode 100644 homeassistant/components/renson/manifest.json create mode 100644 homeassistant/components/renson/sensor.py create mode 100644 homeassistant/components/renson/strings.json create mode 100644 tests/components/renson/__init__.py create mode 100644 tests/components/renson/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index c717c1624c3..44fe8c41b52 100644 --- a/.coveragerc +++ b/.coveragerc @@ -975,6 +975,10 @@ omit = homeassistant/components/rainmachine/switch.py homeassistant/components/rainmachine/update.py homeassistant/components/rainmachine/util.py + homeassistant/components/renson/__init__.py + homeassistant/components/renson/const.py + homeassistant/components/renson/entity.py + homeassistant/components/renson/sensor.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py diff --git a/CODEOWNERS b/CODEOWNERS index 63833d6c1fa..c1545d61429 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1005,6 +1005,8 @@ build.json @home-assistant/supervisor /tests/components/remote/ @home-assistant/core /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet +/homeassistant/components/renson/ @jimmyd-be +/tests/components/renson/ @jimmyd-be /homeassistant/components/reolink/ @starkillerOG /tests/components/reolink/ @starkillerOG /homeassistant/components/repairs/ @home-assistant/core diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py new file mode 100644 index 00000000000..2e2f4e8f253 --- /dev/null +++ b/homeassistant/components/renson/__init__.py @@ -0,0 +1,87 @@ +"""The Renson integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +import async_timeout +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [ + Platform.SENSOR, +] + + +@dataclass +class RensonData: + """Renson data class.""" + + api: RensonVentilation + coordinator: RensonCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Renson from a config entry.""" + + api = RensonVentilation(entry.data[CONF_HOST]) + coordinator = RensonCoordinator("Renson", hass, api) + + if not await hass.async_add_executor_job(api.connect): + raise ConfigEntryNotReady("Cannot connect to Renson device") + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RensonData( + api, + coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class RensonCoordinator(DataUpdateCoordinator): + """Data update coordinator for Renson.""" + + def __init__( + self, + name: str, + hass: HomeAssistant, + api: RensonVentilation, + update_interval=timedelta(seconds=30), + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=name, + # Polling interval. Will only be polled if there are subscribers. + update_interval=update_interval, + ) + self.api = api + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + async with async_timeout.timeout(30): + return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/config_flow.py b/homeassistant/components/renson/config_flow.py new file mode 100644 index 00000000000..9883772ce02 --- /dev/null +++ b/homeassistant/components/renson/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Renson integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from renson_endura_delta import renson +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Renson.""" + + VERSION = 1 + + async def validate_input( + self, hass: HomeAssistant, data: dict[str, Any] + ) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + api = renson.RensonVentilation(data[CONF_HOST]) + + if not await self.hass.async_add_executor_job(api.connect): + raise CannotConnect + + return {"title": "Renson"} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await self.validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py new file mode 100644 index 00000000000..840e1ce428a --- /dev/null +++ b/homeassistant/components/renson/const.py @@ -0,0 +1,3 @@ +"""Constants for the Renson integration.""" + +DOMAIN = "renson" diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py new file mode 100644 index 00000000000..9ba30b43aa7 --- /dev/null +++ b/homeassistant/components/renson/entity.py @@ -0,0 +1,47 @@ +"""Entity class for Renson ventilation unit.""" +from __future__ import annotations + +from renson_endura_delta.field_enum import ( + DEVICE_NAME_FIELD, + FIRMWARE_VERSION_FIELD, + HARDWARE_VERSION_FIELD, + MAC_ADDRESS, +) +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RensonCoordinator +from .const import DOMAIN + + +class RensonEntity(CoordinatorEntity): + """Renson entity.""" + + def __init__( + self, name: str, api: RensonVentilation, coordinator: RensonCoordinator + ) -> None: + """Initialize the Renson entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name)) + }, + manufacturer="Renson", + model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name), + name="Ventilation", + sw_version=api.get_field_value( + coordinator.data, FIRMWARE_VERSION_FIELD.name + ), + hw_version=api.get_field_value( + coordinator.data, HARDWARE_VERSION_FIELD.name + ), + ) + + self.api = api + + self._attr_unique_id = ( + api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}" + ) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json new file mode 100644 index 00000000000..5ff219cc26c --- /dev/null +++ b/homeassistant/components/renson/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "renson", + "name": "Renson", + "codeowners": ["@jimmyd-be"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/renson", + "iot_class": "local_polling", + "requirements": ["renson-endura-delta==1.5.0"] +} diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py new file mode 100644 index 00000000000..dc9f69c2914 --- /dev/null +++ b/homeassistant/components/renson/sensor.py @@ -0,0 +1,317 @@ +"""Sensor data of the Renson ventilation unit.""" +from __future__ import annotations + +from dataclasses import dataclass + +from renson_endura_delta.field_enum import ( + AIR_QUALITY_FIELD, + BREEZE_LEVEL_FIELD, + BREEZE_TEMPERATURE_FIELD, + BYPASS_LEVEL_FIELD, + BYPASS_TEMPERATURE_FIELD, + CO2_FIELD, + CO2_HYSTERESIS_FIELD, + CO2_QUALITY_FIELD, + CO2_THRESHOLD_FIELD, + CURRENT_AIRFLOW_EXTRACT_FIELD, + CURRENT_AIRFLOW_INGOING_FIELD, + CURRENT_LEVEL_FIELD, + DAY_POLLUTION_FIELD, + DAYTIME_FIELD, + FILTER_REMAIN_FIELD, + HUMIDITY_FIELD, + INDOOR_TEMP_FIELD, + MANUAL_LEVEL_FIELD, + NIGHT_POLLUTION_FIELD, + NIGHTTIME_FIELD, + OUTDOOR_TEMP_FIELD, + FieldEnum, +) +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfTemperature, + UnitOfTime, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + + +@dataclass +class RensonSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + field: FieldEnum + raw_format: bool + + +@dataclass +class RensonSensorEntityDescription( + SensorEntityDescription, RensonSensorEntityDescriptionMixin +): + """Description of sensor.""" + + +SENSORS: tuple[RensonSensorEntityDescription, ...] = ( + RensonSensorEntityDescription( + key="CO2_QUALITY_FIELD", + name="CO2 quality category", + field=CO2_QUALITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["good", "bad", "poor"], + ), + RensonSensorEntityDescription( + key="AIR_QUALITY_FIELD", + name="Air quality category", + field=AIR_QUALITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["good", "bad", "poor"], + ), + RensonSensorEntityDescription( + key="CO2_FIELD", + name="CO2 quality", + field=CO2_FIELD, + raw_format=True, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + RensonSensorEntityDescription( + key="AIR_FIELD", + name="Air quality", + field=AIR_QUALITY_FIELD, + state_class=SensorStateClass.MEASUREMENT, + raw_format=True, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + RensonSensorEntityDescription( + key="CURRENT_LEVEL_FIELD", + name="Ventilation level", + field=CURRENT_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + ), + RensonSensorEntityDescription( + key="CURRENT_AIRFLOW_EXTRACT_FIELD", + name="Total airflow out", + field=CURRENT_AIRFLOW_EXTRACT_FIELD, + raw_format=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + RensonSensorEntityDescription( + key="CURRENT_AIRFLOW_INGOING_FIELD", + name="Total airflow in", + field=CURRENT_AIRFLOW_INGOING_FIELD, + raw_format=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + RensonSensorEntityDescription( + key="OUTDOOR_TEMP_FIELD", + name="Outdoor air temperature", + field=OUTDOOR_TEMP_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="INDOOR_TEMP_FIELD", + name="Extract air temperature", + field=INDOOR_TEMP_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="FILTER_REMAIN_FIELD", + name="Filter change", + field=FILTER_REMAIN_FIELD, + raw_format=False, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.DAYS, + ), + RensonSensorEntityDescription( + key="HUMIDITY_FIELD", + name="Relative humidity", + field=HUMIDITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + RensonSensorEntityDescription( + key="MANUAL_LEVEL_FIELD", + name="Manual level", + field=MANUAL_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + ), + RensonSensorEntityDescription( + key="BREEZE_TEMPERATURE_FIELD", + name="Breeze temperature", + field=BREEZE_TEMPERATURE_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="BREEZE_LEVEL_FIELD", + name="Breeze level", + field=BREEZE_LEVEL_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze"], + ), + RensonSensorEntityDescription( + key="DAYTIME_FIELD", + name="Start day time", + field=DAYTIME_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="NIGHTTIME_FIELD", + name="Start night time", + field=NIGHTTIME_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="DAY_POLLUTION_FIELD", + name="Day pollution level", + field=DAY_POLLUTION_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "Level1", + "Level2", + "Level3", + "Level4", + ], + ), + RensonSensorEntityDescription( + key="NIGHT_POLLUTION_FIELD", + name="Night pollution level", + field=NIGHT_POLLUTION_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "Level1", + "Level2", + "Level3", + "Level4", + ], + ), + RensonSensorEntityDescription( + key="CO2_THRESHOLD_FIELD", + name="CO2 threshold", + field=CO2_THRESHOLD_FIELD, + raw_format=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="CO2_HYSTERESIS_FIELD", + name="CO2 hysteresis", + field=CO2_HYSTERESIS_FIELD, + raw_format=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="BYPASS_TEMPERATURE_FIELD", + name="Bypass activation temperature", + field=BYPASS_TEMPERATURE_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="BYPASS_LEVEL_FIELD", + name="Bypass level", + field=BYPASS_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +class RensonSensor(RensonEntity, SensorEntity): + """Get a sensor data from the Renson API and store it in the state of the class.""" + + def __init__( + self, + description: RensonSensorEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, api, coordinator) + + self.field = description.field + self.entity_description = description + + self.data_type = description.field.field_type + self.raw_format = description.raw_format + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + all_data = self.coordinator.data + + value = self.api.get_field_value(all_data, self.field.name) + + if self.raw_format: + self._attr_native_value = value + else: + self._attr_native_value = self.api.parse_value(value, self.data_type) + + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson sensor platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + entities: list = [] + for description in SENSORS: + entities.append(RensonSensor(description, api, coordinator)) + + async_add_entities(entities) diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json new file mode 100644 index 00000000000..16c5de158a9 --- /dev/null +++ b/homeassistant/components/renson/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f938bdfd8d1..96cb74cb316 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -365,6 +365,7 @@ FLOWS = { "rdw", "recollect_waste", "renault", + "renson", "reolink", "rfxtrx", "rhasspy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2203819cc85..044bb8fec68 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4557,6 +4557,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "renson": { + "name": "Renson", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "reolink": { "name": "Reolink IP NVR/camera", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ce7bce75f11..84bc7e6cf90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2264,6 +2264,9 @@ regenmaschine==2023.05.1 # homeassistant.components.renault renault-api==0.1.13 +# homeassistant.components.renson +renson-endura-delta==1.5.0 + # homeassistant.components.reolink reolink-aio==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3faf412388..dbc0c5d7e83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1651,6 +1651,9 @@ regenmaschine==2023.05.1 # homeassistant.components.renault renault-api==0.1.13 +# homeassistant.components.renson +renson-endura-delta==1.5.0 + # homeassistant.components.reolink reolink-aio==0.6.0 diff --git a/tests/components/renson/__init__.py b/tests/components/renson/__init__.py new file mode 100644 index 00000000000..fa2bbe6b4a0 --- /dev/null +++ b/tests/components/renson/__init__.py @@ -0,0 +1 @@ +"""Tests for the Renson integration.""" diff --git a/tests/components/renson/test_config_flow.py b/tests/components/renson/test_config_flow.py new file mode 100644 index 00000000000..6b9f54cd454 --- /dev/null +++ b/tests/components/renson/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Renson config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.renson.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.renson.config_flow.renson", + return_value={"title": "Renson"}, + ), patch( + "homeassistant.components.renson.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Renson" + assert result2["data"] == { + "host": "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.renson.config_flow.renson.RensonVentilation.connect", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.renson.config_flow.renson.RensonVentilation.connect", + side_effect=ValueError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} From ece5de73a4289c051a897c01aa640db4f04d6598 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 10 Jun 2023 10:41:51 +0200 Subject: [PATCH 188/857] Update xknxproject to 3.1.1 (#94375) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 92c44f87b26..3fdbcefcf25 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.10.0", - "xknxproject==3.1.0", + "xknxproject==3.1.1", "knx-frontend==2023.5.31.141540" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 84bc7e6cf90..651a8f2ff31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2691,7 +2691,7 @@ xiaomi-ble==0.17.2 xknx==2.10.0 # homeassistant.components.knx -xknxproject==3.1.0 +xknxproject==3.1.1 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbc0c5d7e83..ff28f4beed4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1967,7 +1967,7 @@ xiaomi-ble==0.17.2 xknx==2.10.0 # homeassistant.components.knx -xknxproject==3.1.0 +xknxproject==3.1.1 # homeassistant.components.bluesound # homeassistant.components.fritz From aa71c8e8f006d7d9879d3bf4335477a9b39152aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Jun 2023 12:53:09 -0500 Subject: [PATCH 189/857] Reduce I/O from cert_expiry (#94399) --- homeassistant/components/cert_expiry/helper.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index 582c6118f57..0817025c703 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -1,4 +1,5 @@ """Helper functions for the Cert Expiry platform.""" +from functools import cache import socket import ssl @@ -14,12 +15,18 @@ from .errors import ( ) +@cache +def _get_default_ssl_context(): + """Return the default SSL context.""" + return ssl.create_default_context() + + def get_cert( host: str, port: int, ): """Get the certificate for the host and port combination.""" - ctx = ssl.create_default_context() + ctx = _get_default_ssl_context() address = (host, port) with socket.create_connection(address, timeout=TIMEOUT) as sock, ctx.wrap_socket( sock, server_hostname=address[0] From b45659eb8441d43da06ee1c0bd350d139b98ced3 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 10 Jun 2023 20:48:14 +0200 Subject: [PATCH 190/857] Update knx-frontend to 2023.6.9.195839 (#94404) --- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/websocket.py | 12 ++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 3fdbcefcf25..1f0a6d3cc5e 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,6 +13,6 @@ "requirements": [ "xknx==2.10.0", "xknxproject==3.1.1", - "knx-frontend==2023.5.31.141540" + "knx-frontend==2023.6.9.195839" ] } diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index d63ba89fbcc..a9da5036857 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Final -from knx_frontend import get_build_id, locate_dir +from knx_frontend import entrypoint_js, is_dev_build, locate_dir import voluptuous as vol from xknx.telegram import TelegramDirection from xknxproject.exceptions import XknxProjectException @@ -31,9 +31,10 @@ async def register_panel(hass: HomeAssistant) -> None: if DOMAIN not in hass.data.get("frontend_panels", {}): path = locate_dir() - build_id = get_build_id() hass.http.register_static_path( - URL_BASE, path, cache_headers=(build_id != "dev") + URL_BASE, + path, + cache_headers=not is_dev_build, ) await panel_custom.async_register_panel( hass=hass, @@ -41,12 +42,13 @@ async def register_panel(hass: HomeAssistant) -> None: webcomponent_name="knx-frontend", sidebar_title=DOMAIN.upper(), sidebar_icon="mdi:bus-electric", - module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + module_url=f"{URL_BASE}/{entrypoint_js()}", embed_iframe=True, require_admin=True, ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/info", @@ -129,6 +131,7 @@ async def ws_project_file_remove( connection.send_result(msg["id"]) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/group_monitor_info", @@ -155,6 +158,7 @@ def ws_group_monitor_info( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/subscribe_telegrams", diff --git a/requirements_all.txt b/requirements_all.txt index 651a8f2ff31..4dbd943ad6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1089,7 +1089,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx-frontend==2023.5.31.141540 +knx-frontend==2023.6.9.195839 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff28f4beed4..e146450b7d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -842,7 +842,7 @@ justnimbus==0.6.0 kegtron-ble==0.4.0 # homeassistant.components.knx -knx-frontend==2023.5.31.141540 +knx-frontend==2023.6.9.195839 # homeassistant.components.konnected konnected==1.2.0 From 3b08d5f0c36ff45b3732a14f027d9da66ed09e25 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 11 Jun 2023 02:28:32 +0200 Subject: [PATCH 191/857] Improve renson typing (#94390) --- homeassistant/components/renson/__init__.py | 3 ++- homeassistant/components/renson/entity.py | 2 +- homeassistant/components/renson/sensor.py | 13 +++++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 2e2f4e8f253..211f7c88e40 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any import async_timeout from renson_endura_delta.renson import RensonVentilation @@ -81,7 +82,7 @@ class RensonCoordinator(DataUpdateCoordinator): ) self.api = api - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from API endpoint.""" async with async_timeout.timeout(30): return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py index 9ba30b43aa7..526077d2d7f 100644 --- a/homeassistant/components/renson/entity.py +++ b/homeassistant/components/renson/entity.py @@ -16,7 +16,7 @@ from . import RensonCoordinator from .const import DOMAIN -class RensonEntity(CoordinatorEntity): +class RensonEntity(CoordinatorEntity[RensonCoordinator]): """Renson entity.""" def __init__( diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index dc9f69c2914..9817951b094 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -46,7 +46,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator +from . import RensonCoordinator, RensonData from .const import DOMAIN from .entity import RensonEntity @@ -305,13 +305,10 @@ async def async_setup_entry( ) -> None: """Set up the Renson sensor platform.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + data: RensonData = hass.data[DOMAIN][config_entry.entry_id] - entities: list = [] - for description in SENSORS: - entities.append(RensonSensor(description, api, coordinator)) + entities = [ + RensonSensor(description, data.api, data.coordinator) for description in SENSORS + ] async_add_entities(entities) From eab024992eb91c70a4449faf53647bf36ab52fbb Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 10 Jun 2023 20:31:34 -0400 Subject: [PATCH 192/857] Add Cleaning area sensors to Roborock (#94200) add clean area sensor --- homeassistant/components/roborock/sensor.py | 17 ++++++++++++++++- homeassistant/components/roborock/strings.json | 6 ++++++ tests/components/roborock/test_sensor.py | 6 +++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index ec973addae3..8398995462f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import AREA_SQUARE_METERS, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -99,6 +99,20 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, options=RoborockStateCode.keys(), ), + RoborockSensorDescription( + key="cleaning_area", + icon="mdi:texture-box", + translation_key="cleaning_area", + value_fn=lambda data: data.status.square_meter_clean_area, + native_unit_of_measurement=AREA_SQUARE_METERS, + ), + RoborockSensorDescription( + key="total_cleaning_area", + icon="mdi:texture-box", + translation_key="total_cleaning_area", + value_fn=lambda data: data.clean_summary.square_meter_clean_area, + native_unit_of_measurement=AREA_SQUARE_METERS, + ), ] @@ -119,6 +133,7 @@ async def async_setup_entry( ) for device_id, coordinator in coordinators.items() for description in SENSOR_DESCRIPTIONS + if description.value_fn(coordinator.roborock_device_info.props) is not None ) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 00ebd3833a8..e36b8c89e34 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,6 +28,9 @@ }, "entity": { "sensor": { + "cleaning_area": { + "name": "Cleaning area" + }, "cleaning_time": { "name": "Cleaning time" }, @@ -73,6 +76,9 @@ }, "total_cleaning_time": { "name": "Total cleaning time" + }, + "total_cleaning_area": { + "name": "Total cleaning area" } }, "select": { diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 106508e6062..daa904d482a 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 7 + assert len(hass.states.async_all("sensor")) == 9 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -32,3 +32,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_total_cleaning_time").state == "74382" ) assert hass.states.get("sensor.roborock_s7_maxv_status").state == "charging" + assert ( + hass.states.get("sensor.roborock_s7_maxv_total_cleaning_area").state == "1159.2" + ) + assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" From 41d8ba3397eb058654ef86f1c5dc022de8811e0f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 11 Jun 2023 02:35:52 -0400 Subject: [PATCH 193/857] Re-add event listeners after Z-Wave server disconnection (#94383) * Re-add event listeners after Z-Wave server disconnection * switch order * Add tests --- homeassistant/components/zwave_js/__init__.py | 3 + .../components/zwave_js/triggers/event.py | 67 ++++++++----- .../zwave_js/triggers/value_updated.py | 61 ++++++++---- tests/components/zwave_js/test_trigger.py | 98 +++++++++++++++++++ 4 files changed, 187 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 57b0e2edc6f..b847b76ca17 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -215,6 +215,9 @@ async def start_client( LOGGER.info("Connection to Zwave JS Server initialized") assert client.driver + async_dispatcher_send( + hass, f"{DOMAIN}_{client.driver.controller.home_id}_connected_to_server" + ) await driver_events.setup(client.driver) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 12c9d267ca6..32bd3130e03 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -1,18 +1,20 @@ """Offer Z-Wave JS event listening automation trigger.""" from __future__ import annotations +from collections.abc import Callable import functools from pydantic import ValidationError import voluptuous as vol from zwave_js_server.client import Client from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP -from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP +from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -150,7 +152,7 @@ async def async_attach_trigger( event_name = config[ATTR_EVENT] event_data_filter = config.get(ATTR_EVENT_DATA, {}) - unsubs = [] + unsubs: list[Callable] = [] job = HassJob(action) trigger_data = trigger_info["trigger_data"] @@ -199,26 +201,6 @@ async def async_attach_trigger( hass.async_run_hass_job(job, {"trigger": payload}) - if not nodes: - entry_id = config[ATTR_CONFIG_ENTRY_ID] - client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] - assert client.driver - if event_source == "controller": - unsubs.append(client.driver.controller.on(event_name, async_on_event)) - else: - unsubs.append(client.driver.on(event_name, async_on_event)) - - for node in nodes: - driver = node.client.driver - assert driver is not None # The node comes from the driver. - device_identifier = get_device_id(driver, node) - device = dev_reg.async_get_device({device_identifier}) - assert device - # We need to store the device for the callback - unsubs.append( - node.on(event_name, functools.partial(async_on_event, device=device)) - ) - @callback def async_remove() -> None: """Remove state listeners async.""" @@ -226,4 +208,45 @@ async def async_attach_trigger( unsub() unsubs.clear() + @callback + def _create_zwave_listeners() -> None: + """Create Z-Wave JS listeners.""" + async_remove() + # Nodes list can come from different drivers and we will need to listen to + # server connections for all of them. + drivers: set[Driver] = set() + if not nodes: + entry_id = config[ATTR_CONFIG_ENTRY_ID] + client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + driver = client.driver + assert driver + drivers.add(driver) + if event_source == "controller": + unsubs.append(driver.controller.on(event_name, async_on_event)) + else: + unsubs.append(driver.on(event_name, async_on_event)) + + for node in nodes: + driver = node.client.driver + assert driver is not None # The node comes from the driver. + drivers.add(driver) + device_identifier = get_device_id(driver, node) + device = dev_reg.async_get_device({device_identifier}) + assert device + # We need to store the device for the callback + unsubs.append( + node.on(event_name, functools.partial(async_on_event, device=device)) + ) + + for driver in drivers: + unsubs.append( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{driver.controller.home_id}_connected_to_server", + _create_zwave_listeners, + ) + ) + + _create_zwave_listeners() + return async_remove diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 655d1f9070e..4e21774c98f 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -1,15 +1,18 @@ """Offer Z-Wave JS value updated listening automation trigger.""" from __future__ import annotations +from collections.abc import Callable import functools import voluptuous as vol from zwave_js_server.const import CommandClass +from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value, get_value_id_str from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM, MATCH_ALL from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -99,7 +102,7 @@ async def async_attach_trigger( property_ = config[ATTR_PROPERTY] endpoint = config.get(ATTR_ENDPOINT) property_key = config.get(ATTR_PROPERTY_KEY) - unsubs = [] + unsubs: list[Callable] = [] job = HassJob(action) trigger_data = trigger_info["trigger_data"] @@ -153,29 +156,11 @@ async def async_attach_trigger( ATTR_PREVIOUS_VALUE_RAW: prev_value_raw, ATTR_CURRENT_VALUE: curr_value, ATTR_CURRENT_VALUE_RAW: curr_value_raw, - "description": f"Z-Wave value {value_id} updated on {device_name}", + "description": f"Z-Wave value {value.value_id} updated on {device_name}", } hass.async_run_hass_job(job, {"trigger": payload}) - for node in nodes: - driver = node.client.driver - assert driver is not None # The node comes from the driver. - device_identifier = get_device_id(driver, node) - device = dev_reg.async_get_device({device_identifier}) - assert device - value_id = get_value_id_str( - node, command_class, property_, endpoint, property_key - ) - value = node.values[value_id] - # We need to store the current value and device for the callback - unsubs.append( - node.on( - "value updated", - functools.partial(async_on_value_updated, value, device), - ) - ) - @callback def async_remove() -> None: """Remove state listeners async.""" @@ -183,4 +168,40 @@ async def async_attach_trigger( unsub() unsubs.clear() + def _create_zwave_listeners() -> None: + """Create Z-Wave JS listeners.""" + async_remove() + # Nodes list can come from different drivers and we will need to listen to + # server connections for all of them. + drivers: set[Driver] = set() + for node in nodes: + driver = node.client.driver + assert driver is not None # The node comes from the driver. + drivers.add(driver) + device_identifier = get_device_id(driver, node) + device = dev_reg.async_get_device({device_identifier}) + assert device + value_id = get_value_id_str( + node, command_class, property_, endpoint, property_key + ) + value = node.values[value_id] + # We need to store the current value and device for the callback + unsubs.append( + node.on( + "value updated", + functools.partial(async_on_value_updated, value, device), + ) + ) + + for driver in drivers: + unsubs.append( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{driver.controller.home_id}_connected_to_server", + _create_zwave_listeners, + ) + ) + + _create_zwave_listeners() + return async_remove diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 9df8aa75f43..0fb3b829d9a 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -1109,3 +1109,101 @@ def test_get_trigger_platform_failure() -> None: """Test _get_trigger_platform.""" with pytest.raises(ValueError): _get_trigger_platform({CONF_PLATFORM: "zwave_js.invalid"}) + + +async def test_server_reconnect_event( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: + """Test that when we reconnect to server, event triggers reattach.""" + trigger_type = f"{DOMAIN}.event" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device( + {get_device_id(client.driver, lock_schlage_be469)} + ) + assert device + + event_name = "interview stage completed" + + original_len = len(node._listeners.get(event_name, [])) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": event_name, + }, + "action": { + "event": "blah", + }, + }, + ] + }, + ) + + assert len(node._listeners.get(event_name, [])) == original_len + 1 + old_listener = node._listeners.get(event_name, [])[original_len] + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + # Make sure there is still a listener added for the trigger + assert len(node._listeners.get(event_name, [])) == original_len + 1 + + # Make sure the old listener was removed + assert old_listener not in node._listeners.get(event_name, []) + + +async def test_server_reconnect_value_updated( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: + """Test that when we reconnect to server, value_updated triggers reattach.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device( + {get_device_id(client.driver, lock_schlage_be469)} + ) + assert device + + event_name = "value updated" + + original_len = len(node._listeners.get(event_name, [])) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + ] + }, + ) + + assert len(node._listeners.get(event_name, [])) == original_len + 1 + old_listener = node._listeners.get(event_name, [])[original_len] + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + # Make sure there is still a listener added for the trigger + assert len(node._listeners.get(event_name, [])) == original_len + 1 + + # Make sure the old listener was removed + assert old_listener not in node._listeners.get(event_name, []) From 9e666ae0c0c93137586103c1be71a327786a34a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jun 2023 01:41:38 -0700 Subject: [PATCH 194/857] Reduce in progress flow matching overhead (#94403) * Reduce config flow matching overhead Much of the config flow matching is happening on the context data after converting via a series of functions. Avoid the conversions by passing the context matcher deeper into the stack so only relvant entries need to be processed. The goal is to reduce the overhead and reduce the chance the event loop falls behind at the started event when all the discoveries are processed * Reduce config flow matching overhead Much of the config flow matching is happening on the context data after converting via a series of functions. Avoid the conversions by passing the context matcher deeper into the stack so only relvant entries need to be processed. The goal is to reduce the overhead and reduce the chance the event loop falls behind at the started event when all the discoveries are processed * Reduce config flow matching overhead Much of the config flow matching is happening on the context data after converting via a series of functions. Avoid the conversions by passing the context matcher deeper into the stack so only relvant entries need to be processed. The goal is to reduce the overhead and reduce the chance the event loop falls behind at the started event when all the discoveries are processed * augment cover --- homeassistant/config_entries.py | 44 ++++++++++++++++---------------- homeassistant/data_entry_flow.py | 44 +++++++++++++++++++++++++------- tests/test_data_entry_flow.py | 16 ++++++++++++ 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index adbb2f80f64..14d69e278fa 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -745,9 +745,10 @@ class ConfigEntry: """Get any active flows of certain sources for this entry.""" return ( flow - for flow in hass.config_entries.flow.async_progress_by_handler(self.domain) + for flow in hass.config_entries.flow.async_progress_by_handler( + self.domain, match_context={"entry_id": self.entry_id} + ) if flow["context"].get("source") in sources - and flow["context"].get("entry_id") == self.entry_id ) @callback @@ -1086,16 +1087,9 @@ class ConfigEntries: # If the configuration entry is removed during reauth, it should # abort any reauth flow that is active for the removed entry. for progress_flow in self.hass.config_entries.flow.async_progress_by_handler( - entry.domain + entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} ): - context = progress_flow.get("context") - if ( - context - and context["source"] == SOURCE_REAUTH - and "entry_id" in context - and context["entry_id"] == entry_id - and "flow_id" in progress_flow - ): + if "flow_id" in progress_flow: self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) # After we have fully removed an "ignore" config entry we can try and rediscover @@ -1577,17 +1571,20 @@ class ConfigFlow(data_entry_flow.FlowHandler): return None if raise_on_progress: - for progress in self._async_in_progress(include_uninitialized=True): - if progress["context"].get("unique_id") == unique_id: - raise data_entry_flow.AbortFlow("already_in_progress") + if self._async_in_progress( + include_uninitialized=True, match_context={"unique_id": unique_id} + ): + raise data_entry_flow.AbortFlow("already_in_progress") self.context["unique_id"] = unique_id # Abort discoveries done using the default discovery unique id if unique_id != DEFAULT_DISCOVERY_UNIQUE_ID: - for progress in self._async_in_progress(include_uninitialized=True): - if progress["context"].get("unique_id") == DEFAULT_DISCOVERY_UNIQUE_ID: - self.hass.config_entries.flow.async_abort(progress["flow_id"]) + for progress in self._async_in_progress( + include_uninitialized=True, + match_context={"unique_id": DEFAULT_DISCOVERY_UNIQUE_ID}, + ): + self.hass.config_entries.flow.async_abort(progress["flow_id"]) for entry in self._async_current_entries(include_ignore=True): if entry.unique_id == unique_id: @@ -1633,13 +1630,17 @@ class ConfigFlow(data_entry_flow.FlowHandler): @callback def _async_in_progress( - self, include_uninitialized: bool = False + self, + include_uninitialized: bool = False, + match_context: dict[str, Any] | None = None, ) -> list[data_entry_flow.FlowResult]: """Return other in progress flows for current domain.""" return [ flw for flw in self.hass.config_entries.flow.async_progress_by_handler( - self.handler, include_uninitialized=include_uninitialized + self.handler, + include_uninitialized=include_uninitialized, + match_context=match_context, ) if flw["flow_id"] != self.flow_id ] @@ -1713,11 +1714,10 @@ class ConfigFlow(data_entry_flow.FlowHandler): """Abort the config flow.""" # Remove reauth notification if no reauth flows are in progress if self.source == SOURCE_REAUTH and not any( - ent["context"]["source"] == SOURCE_REAUTH + ent["flow_id"] != self.flow_id for ent in self.hass.config_entries.flow.async_progress_by_handler( - self.handler + self.handler, match_context={"source": SOURCE_REAUTH} ) - if ent["flow_id"] != self.flow_id ): persistent_notification.async_dismiss( self.hass, RECONFIGURE_NOTIFICATION_ID diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e213814f52c..6f125ce359a 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -164,16 +164,19 @@ class FlowManager(abc.ABC): @callback def async_has_matching_flow( - self, handler: str, context: dict[str, Any], data: Any + self, handler: str, match_context: dict[str, Any], data: Any ) -> bool: """Check if an existing matching flow is in progress. A flow with the same handler, context, and data. + + If match_context is passed, only return flows with a context that is a + superset of match_context. """ return any( flow - for flow in self._async_progress_by_handler(handler) - if flow.context["source"] == context["source"] and flow.init_data == data + for flow in self._async_progress_by_handler(handler, match_context) + if flow.init_data == data ) @callback @@ -192,11 +195,19 @@ class FlowManager(abc.ABC): @callback def async_progress_by_handler( - self, handler: str, include_uninitialized: bool = False + self, + handler: str, + include_uninitialized: bool = False, + match_context: dict[str, Any] | None = None, ) -> list[FlowResult]: - """Return the flows in progress by handler as a partial FlowResult.""" + """Return the flows in progress by handler as a partial FlowResult. + + If match_context is specified, only return flows with a context that + is a superset of match_context. + """ return _async_flow_handler_to_flow_result( - self._async_progress_by_handler(handler), include_uninitialized + self._async_progress_by_handler(handler, match_context), + include_uninitialized, ) @callback @@ -217,11 +228,26 @@ class FlowManager(abc.ABC): ) @callback - def _async_progress_by_handler(self, handler: str) -> list[FlowHandler]: - """Return the flows in progress by handler.""" + def _async_progress_by_handler( + self, handler: str, match_context: dict[str, Any] | None + ) -> list[FlowHandler]: + """Return the flows in progress by handler. + + If match_context is specified, only return flows with a context that + is a superset of match_context. + """ + match_context_items = match_context.items() if match_context else None return [ - self._progress[flow_id] + progress for flow_id in self._handler_progress_index.get(handler, {}) + if (progress := self._progress[flow_id]) + and ( + not match_context_items + or ( + (context := progress.context) + and match_context_items <= context.items() + ) + ) ] async def async_init( diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index c3afc3bc8ba..168f97ba779 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -462,6 +462,22 @@ async def test_async_has_matching_flow( assert result["progress_action"] == "task_one" assert len(manager.async_progress()) == 1 assert len(manager.async_progress_by_handler("test")) == 1 + assert ( + len( + manager.async_progress_by_handler( + "test", match_context={"source": config_entries.SOURCE_HOMEKIT} + ) + ) + == 1 + ) + assert ( + len( + manager.async_progress_by_handler( + "test", match_context={"source": config_entries.SOURCE_BLUETOOTH} + ) + ) + == 0 + ) assert manager.async_get(result["flow_id"])["handler"] == "test" assert ( From a8dd2d520af40d0058b49edef82a42bd70caf9f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 11 Jun 2023 12:30:38 +0200 Subject: [PATCH 195/857] Abort youtube configuration if user has no channel (#94402) * Abort configuration if user has no channel * Clean up --------- Co-authored-by: Martin Hjelmare --- .../components/youtube/config_flow.py | 13 +++++- homeassistant/components/youtube/const.py | 1 + homeassistant/components/youtube/strings.json | 1 + .../youtube/fixtures/get_no_channel.json | 9 +++++ tests/components/youtube/test_config_flow.py | 40 +++++++++++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/components/youtube/fixtures/get_no_channel.json diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index a2adebc84af..92695f80a2e 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -22,7 +22,13 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN, LOGGER +from .const import ( + CHANNEL_CREATION_HELP_URL, + CONF_CHANNELS, + DEFAULT_ACCESS, + DOMAIN, + LOGGER, +) async def get_resource(hass: HomeAssistant, token: str) -> Resource: @@ -99,6 +105,11 @@ class OAuth2FlowHandler( response = await self.hass.async_add_executor_job( own_channel_request.execute ) + if not response["items"]: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) own_channel = response["items"][0] except HttpError as ex: error = ex.reason diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py index e2757e3856d..7404cd04665 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -4,6 +4,7 @@ import logging DEFAULT_ACCESS = ["https://www.googleapis.com/auth/youtube.readonly"] DOMAIN = "youtube" MANUFACTURER = "Google, Inc." +CHANNEL_CREATION_HELP_URL = "https://support.google.com/youtube/answer/1646861" CONF_CHANNELS = "channels" CONF_ID = "id" diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 24369ab26f9..eb89738708e 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "access_not_configured": "Please read the below message we got from Google:\n\n{message}", + "no_channel": "Please create a YouTube channel to be able to use the integration. Instructions can be found at {support_url}.", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/tests/components/youtube/fixtures/get_no_channel.json b/tests/components/youtube/fixtures/get_no_channel.json new file mode 100644 index 00000000000..7ec03c0461a --- /dev/null +++ b/tests/components/youtube/fixtures/get_no_channel.json @@ -0,0 +1,9 @@ +{ + "kind": "youtube#channelListResponse", + "etag": "8HTiiXpKCq-GJvDVOd88e5o_KGc", + "pageInfo": { + "totalResults": 0, + "resultsPerPage": 5 + }, + "items": [] +} diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index ed33947b593..5b91ff958f8 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -83,6 +83,46 @@ async def test_full_flow( assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} +async def test_flow_abort_without_channel( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check abort flow if user has no channel.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + service = MockService(channel_fixture="youtube/get_no_channel.json") + with patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ), patch("homeassistant.components.youtube.api.build", return_value=service), patch( + "homeassistant.components.youtube.config_flow.build", return_value=service + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_channel" + + async def test_flow_http_error( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, From 7d0f5733c2259e58c031c654ea65fe2353a76d46 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 11 Jun 2023 12:51:43 +0200 Subject: [PATCH 196/857] Improve the code quality of the Discovergy integration (#94165) * Remove option flow, refactor and improve the code quality after review in PR #54280 * Remove coordinator.py from coverage report * Some minor improvements for unit tests * Remove _LOGGER * Use pytest.fixture and some more improvments * Add back empty __init__ * Fix docstring --------- Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + .../components/discovergy/__init__.py | 37 ++++---- .../components/discovergy/config_flow.py | 57 ++----------- homeassistant/components/discovergy/const.py | 2 - .../components/discovergy/coordinator.py | 60 +++++++++++++ homeassistant/components/discovergy/sensor.py | 85 +++---------------- .../components/discovergy/strings.json | 9 -- tests/components/discovergy/__init__.py | 76 +---------------- tests/components/discovergy/conftest.py | 21 ++++- tests/components/discovergy/const.py | 49 +++++++++++ .../components/discovergy/test_config_flow.py | 58 +++++-------- .../components/discovergy/test_diagnostics.py | 22 +++-- 12 files changed, 210 insertions(+), 267 deletions(-) create mode 100644 homeassistant/components/discovergy/coordinator.py create mode 100644 tests/components/discovergy/const.py diff --git a/.coveragerc b/.coveragerc index 44fe8c41b52..242833b1262 100644 --- a/.coveragerc +++ b/.coveragerc @@ -204,6 +204,7 @@ omit = homeassistant/components/discord/notify.py homeassistant/components/discovergy/__init__.py homeassistant/components/discovergy/sensor.py + homeassistant/components/discovergy/coordinator.py homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/data.py diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 23687383dd9..54f6fca83d4 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -1,37 +1,32 @@ """The Discovergy integration.""" from __future__ import annotations -from dataclasses import dataclass, field -import logging +from dataclasses import dataclass import pydiscovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError -from pydiscovergy.models import Meter, Reading +from pydiscovergy.models import Meter from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import APP_NAME, DOMAIN +from .coordinator import DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - @dataclass class DiscovergyData: """Discovergy data class to share meters and api client.""" - api_client: pydiscovergy.Discovergy = field(default_factory=lambda: None) - meters: list[Meter] = field(default_factory=lambda: []) - coordinators: dict[str, DataUpdateCoordinator[Reading]] = field( - default_factory=lambda: {} - ) + api_client: pydiscovergy.Discovergy + meters: list[Meter] + coordinators: dict[str, DiscovergyUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -52,18 +47,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - # try to get meters from api to check if access token is still valid and later use + # try to get meters from api to check if credentials are still valid and for later use # if no exception is raised everything is fine to go discovergy_data.meters = await discovergy_data.api_client.get_meters() except discovergyError.InvalidLogin as err: - _LOGGER.debug("Invalid email or password: %s", err) raise ConfigEntryAuthFailed("Invalid email or password") from err except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unexpected error while communicating with API: %s", err) raise ConfigEntryNotReady( - "Unexpected error while communicating with API" + "Unexpected error while while getting meters" ) from err + # Init coordinators for meters + for meter in discovergy_data.meters: + # Create coordinator for meter, set config entry and fetch initial data, + # so we have data when entities are added + coordinator = DiscovergyUpdateCoordinator( + hass=hass, + config_entry=entry, + meter=meter, + discovergy_client=discovergy_data.api_client, + ) + await coordinator.async_config_entry_first_refresh() + + discovergy_data.coordinators[meter.get_meter_id()] = coordinator + hass.data[DOMAIN][entry.entry_id] = discovergy_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 1f685f3e23a..d6b81ed8837 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -13,16 +13,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client -from .const import ( - APP_NAME, - CONF_TIME_BETWEEN_UPDATE, - DEFAULT_TIME_BETWEEN_UPDATE, - DOMAIN, -) +from .const import APP_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -75,10 +69,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), ) - return await self._validate_and_save(dict(entry_data), step_id="reauth") + return await self._validate_and_save(entry_data, step_id="reauth") async def _validate_and_save( - self, user_input: dict[str, Any] | None = None, step_id: str = "user" + self, user_input: Mapping[str, Any] | None = None, step_id: str = "user" ) -> FlowResult: """Validate user input and create config entry.""" errors = {} @@ -92,14 +86,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): httpx_client=get_async_client(self.hass), authentication=BasicAuth(), ).get_meters() - - result = {"title": user_input[CONF_EMAIL], "data": user_input} except discovergyError.HTTPError: errors["base"] = "cannot_connect" except discovergyError.InvalidLogin: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected error occurred while getting meters") errors["base"] = "unknown" else: if self.existing_entry: @@ -116,11 +108,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") # set unique id to title which is the account email - await self.async_set_unique_id(result["title"].lower()) + await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) self._abort_if_unique_id_configured() return self.async_create_entry( - title=result["title"], data=result["data"] + title=user_input[CONF_EMAIL], data=user_input ) return self.async_show_form( @@ -128,40 +120,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=make_schema(), errors=errors, ) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: - """Create the options flow.""" - return DiscovergyOptionsFlowHandler(config_entry) - - -class DiscovergyOptionsFlowHandler(config_entries.OptionsFlow): - """Handle Discovergy options.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_TIME_BETWEEN_UPDATE, - default=self.config_entry.options.get( - CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE - ), - ): vol.All(vol.Coerce(int), vol.Range(min=1)), - } - ), - ) diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py index 31d834156d4..866e9f11def 100644 --- a/homeassistant/components/discovergy/const.py +++ b/homeassistant/components/discovergy/const.py @@ -4,5 +4,3 @@ from __future__ import annotations DOMAIN = "discovergy" MANUFACTURER = "Discovergy" APP_NAME = "homeassistant" -CONF_TIME_BETWEEN_UPDATE = "time_between_update" -DEFAULT_TIME_BETWEEN_UPDATE = 30 diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py new file mode 100644 index 00000000000..e3b6e91e03f --- /dev/null +++ b/homeassistant/components/discovergy/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for the Discovergy integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pydiscovergy import Discovergy +from pydiscovergy.error import AccessTokenExpired, HTTPError +from pydiscovergy.models import Meter, Reading + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): + """The Discovergy update coordinator.""" + + config_entry: ConfigEntry + discovergy_client: Discovergy + meter: Meter + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + meter: Meter, + discovergy_client: Discovergy, + ) -> None: + """Initialize the Discovergy coordinator.""" + self.config_entry = config_entry + self.meter = meter + self.discovergy_client = discovergy_client + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> Reading: + """Get last reading for meter.""" + try: + return await self.discovergy_client.get_last_reading( + self.meter.get_meter_id() + ) + except AccessTokenExpired as err: + raise ConfigEntryAuthFailed( + f"Auth expired while fetching last reading for meter {self.meter.get_meter_id()}" + ) from err + except HTTPError as err: + raise UpdateFailed( + f"Error while fetching last reading for meter {self.meter.get_meter_id()}" + ) from err diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index d659ec8a106..35955a6b189 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -1,11 +1,7 @@ """Discovergy sensor entity.""" from dataclasses import dataclass, field -from datetime import timedelta -import logging -from pydiscovergy import Discovergy -from pydiscovergy.error import AccessTokenExpired, HTTPError -from pydiscovergy.models import Meter, Reading +from pydiscovergy.models import Meter from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,25 +21,14 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DiscovergyData -from .const import ( - CONF_TIME_BETWEEN_UPDATE, - DEFAULT_TIME_BETWEEN_UPDATE, - DOMAIN, - MANUFACTURER, -) +from . import DiscovergyData, DiscovergyUpdateCoordinator +from .const import DOMAIN, MANUFACTURER PARALLEL_UPDATES = 1 -_LOGGER = logging.getLogger(__name__) @dataclass @@ -160,63 +145,16 @@ ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( ) -def get_coordinator_for_meter( - hass: HomeAssistant, - meter: Meter, - discovergy_instance: Discovergy, - update_interval: timedelta, -) -> DataUpdateCoordinator[Reading]: - """Create a new DataUpdateCoordinator for given meter.""" - - async def async_update_data() -> Reading: - """Fetch data from API endpoint.""" - try: - return await discovergy_instance.get_last_reading(meter.get_meter_id()) - except AccessTokenExpired as err: - raise ConfigEntryAuthFailed( - "Got token expired while communicating with API" - ) from err - except HTTPError as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - except Exception as err: # pylint: disable=broad-except - raise UpdateFailed( - f"Unexpected error while communicating with API: {err}" - ) from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="sensor", - update_method=async_update_data, - update_interval=update_interval, - ) - return coordinator - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Discovergy sensors.""" data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] - discovergy_instance: Discovergy = data.api_client meters: list[Meter] = data.meters # always returns a list - min_time_between_updates = timedelta( - seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) - ) - entities: list[DiscovergySensor] = [] for meter in meters: - # Get coordinator for meter, set config entry and fetch initial data - # so we have data when entities are added - coordinator = get_coordinator_for_meter( - hass, meter, discovergy_instance, min_time_between_updates - ) - coordinator.config_entry = entry - await coordinator.async_config_entry_first_refresh() - - # add coordinator to data for diagnostics - data.coordinators[meter.get_meter_id()] = coordinator + meter_id = meter.get_meter_id() sensors = None if meter.measurement_type == "ELECTRICITY": @@ -226,10 +164,11 @@ async def async_setup_entry( if sensors is not None: for description in sensors: - keys = [description.key] + description.alternative_keys - # check if this meter has this data, then add this sensor - for key in keys: + for key in {description.key, *description.alternative_keys}: + coordinator: DiscovergyUpdateCoordinator = data.coordinators[ + meter_id + ] if key in coordinator.data.values: entities.append( DiscovergySensor(key, description, meter, coordinator) @@ -238,7 +177,7 @@ async def async_setup_entry( async_add_entities(entities, False) -class DiscovergySensor(CoordinatorEntity[DataUpdateCoordinator[Reading]], SensorEntity): +class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEntity): """Represents a discovergy smart meter sensor.""" entity_description: DiscovergySensorEntityDescription @@ -250,7 +189,7 @@ class DiscovergySensor(CoordinatorEntity[DataUpdateCoordinator[Reading]], Sensor data_key: str, description: DiscovergySensorEntityDescription, meter: Meter, - coordinator: DataUpdateCoordinator[Reading], + coordinator: DiscovergyUpdateCoordinator, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -258,7 +197,7 @@ class DiscovergySensor(CoordinatorEntity[DataUpdateCoordinator[Reading]], Sensor self.data_key = data_key self.entity_description = description - self._attr_unique_id = f"{meter.full_serial_number}-{description.key}" + self._attr_unique_id = f"{meter.full_serial_number}-{data_key}" self._attr_device_info = { ATTR_IDENTIFIERS: {(DOMAIN, meter.get_meter_id())}, ATTR_NAME: f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 11d6b74a822..e8dbbab2021 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -29,15 +29,6 @@ "api_endpoint_reachable": "Discovergy API endpoint reachable" } }, - "options": { - "step": { - "init": { - "data": { - "time_between_update": "Minimum time between entity updates [s]" - } - } - } - }, "entity": { "sensor": { "total_gas_consumption": { diff --git a/tests/components/discovergy/__init__.py b/tests/components/discovergy/__init__.py index f721b5842c9..321e66ef74e 100644 --- a/tests/components/discovergy/__init__.py +++ b/tests/components/discovergy/__init__.py @@ -1,75 +1 @@ -"""Tests for the Discovergy integration.""" -import datetime -from unittest.mock import patch - -from pydiscovergy.models import Meter, Reading - -from homeassistant.components.discovergy import DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - -GET_METERS = [ - Meter( - meterId="f8d610b7a8cc4e73939fa33b990ded54", - serialNumber="abc123", - fullSerialNumber="abc123", - type="TST", - measurementType="ELECTRICITY", - loadProfileType="SLP", - location={ - "city": "Testhause", - "street": "Teststraße", - "streetNumber": "1", - "country": "Germany", - }, - manufacturerId="TST", - printedFullSerialNumber="abc123", - administrationNumber="12345", - scalingFactor=1, - currentScalingFactor=1, - voltageScalingFactor=1, - internalMeters=1, - firstMeasurementTime=1517569090926, - lastMeasurementTime=1678430543742, - ), -] - -LAST_READING = Reading( - time=datetime.datetime(2023, 3, 10, 7, 32, 6, 702000), - values={ - "energy": 119348699715000.0, - "energy1": 2254180000.0, - "energy2": 119346445534000.0, - "energyOut": 55048723044000.0, - "energyOut1": 0.0, - "energyOut2": 0.0, - "power": 531750.0, - "power1": 142680.0, - "power2": 138010.0, - "power3": 251060.0, - "voltage1": 239800.0, - "voltage2": 239700.0, - "voltage3": 239000.0, - }, -) - - -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: - """Set up the Discovergy integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="user@example.org", - unique_id="user@example.org", - data={CONF_EMAIL: "user@example.org", CONF_PASSWORD: "supersecretpassword"}, - ) - - with patch("pydiscovergy.Discovergy.get_meters", return_value=GET_METERS), patch( - "pydiscovergy.Discovergy.get_last_reading", return_value=LAST_READING - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry +"""Test the Discovergy integration.""" diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index 40bd0bb8aa1..313985bd7d2 100644 --- a/tests/components/discovergy/conftest.py +++ b/tests/components/discovergy/conftest.py @@ -3,7 +3,12 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from tests.components.discovergy import GET_METERS +from homeassistant.components.discovergy import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.discovergy.const import GET_METERS @pytest.fixture @@ -12,3 +17,17 @@ def mock_meters() -> Mock: with patch("pydiscovergy.Discovergy.get_meters") as discovergy: discovergy.side_effect = AsyncMock(return_value=GET_METERS) yield discovergy + + +@pytest.fixture +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="user@example.org", + unique_id="user@example.org", + data={CONF_EMAIL: "user@example.org", CONF_PASSWORD: "supersecretpassword"}, + ) + entry.add_to_hass(hass) + + return entry diff --git a/tests/components/discovergy/const.py b/tests/components/discovergy/const.py new file mode 100644 index 00000000000..2205a70830e --- /dev/null +++ b/tests/components/discovergy/const.py @@ -0,0 +1,49 @@ +"""Constants for Discovergy integration tests.""" +import datetime + +from pydiscovergy.models import Meter, Reading + +GET_METERS = [ + Meter( + meterId="f8d610b7a8cc4e73939fa33b990ded54", + serialNumber="abc123", + fullSerialNumber="abc123", + type="TST", + measurementType="ELECTRICITY", + loadProfileType="SLP", + location={ + "city": "Testhause", + "street": "Teststraße", + "streetNumber": "1", + "country": "Germany", + }, + manufacturerId="TST", + printedFullSerialNumber="abc123", + administrationNumber="12345", + scalingFactor=1, + currentScalingFactor=1, + voltageScalingFactor=1, + internalMeters=1, + firstMeasurementTime=1517569090926, + lastMeasurementTime=1678430543742, + ), +] + +LAST_READING = Reading( + time=datetime.datetime(2023, 3, 10, 7, 32, 6, 702000), + values={ + "energy": 119348699715000.0, + "energy1": 2254180000.0, + "energy2": 119346445534000.0, + "energyOut": 55048723044000.0, + "energyOut1": 0.0, + "energyOut2": 0.0, + "power": 531750.0, + "power1": 142680.0, + "power2": 138010.0, + "power3": 251060.0, + "voltage1": 239800.0, + "voltage2": 239700.0, + "voltage3": 239000.0, + }, +) diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 312828a7997..f42a4a983fb 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -1,20 +1,19 @@ """Test the Discovergy config flow.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from pydiscovergy.error import HTTPError, InvalidLogin -from homeassistant import data_entry_flow, setup +from homeassistant import data_entry_flow from homeassistant.components.discovergy.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from tests.components.discovergy import init_integration +from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_meters) -> None: +async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -43,29 +42,35 @@ async def test_form(hass: HomeAssistant, mock_meters) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_reauth(hass: HomeAssistant, mock_meters) -> None: +async def test_reauth( + hass: HomeAssistant, mock_meters: Mock, mock_config_entry: MockConfigEntry +) -> None: """Test reauth flow.""" - entry = await init_integration(hass) - init_result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": entry.unique_id}, + context={"source": SOURCE_REAUTH, "unique_id": mock_config_entry.unique_id}, data=None, ) assert init_result["type"] == data_entry_flow.FlowResultType.FORM assert init_result["step_id"] == "reauth" - configure_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) + with patch( + "homeassistant.components.discovergy.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + configure_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() - assert configure_result["type"] == data_entry_flow.FlowResultType.ABORT - assert configure_result["reason"] == "reauth_successful" + assert configure_result["type"] == data_entry_flow.FlowResultType.ABORT + assert configure_result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 async def test_form_invalid_auth(hass: HomeAssistant) -> None: @@ -126,20 +131,3 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} - - -async def test_options_flow_init(hass: HomeAssistant) -> None: - """Test the options flow.""" - entry = await init_integration(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - - create_result = await hass.config_entries.options.async_configure( - result["flow_id"], {"time_between_update": 2} - ) - - assert create_result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert create_result["data"] == {"time_between_update": 2} diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index 7d7b3508f95..1d465dda0e0 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -1,23 +1,33 @@ """Test Discovergy diagnostics.""" +from unittest.mock import patch + from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant -from . import init_integration - +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.components.discovergy.const import GET_METERS, LAST_READING from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass) + with patch("pydiscovergy.Discovergy.get_meters", return_value=GET_METERS), patch( + "pydiscovergy.Discovergy.get_last_reading", return_value=LAST_READING + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) assert result["entry"] == { - "entry_id": entry.entry_id, + "entry_id": mock_config_entry.entry_id, "version": 1, "domain": "discovergy", "title": REDACTED, From 3cf2c81baaaf81ca3c16f601ec33759390afd345 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 11 Jun 2023 16:21:06 +0200 Subject: [PATCH 197/857] Fix dep noaa-coops for noaa_tides (#94370) Bump noaa-coops to 0.1.9 --- homeassistant/components/noaa_tides/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json index 7b954153cf1..85c6fbcb788 100644 --- a/homeassistant/components/noaa_tides/manifest.json +++ b/homeassistant/components/noaa_tides/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/noaa_tides", "iot_class": "cloud_polling", "loggers": ["noaa_coops"], - "requirements": ["noaa-coops==0.1.8"] + "requirements": ["noaa-coops==0.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4dbd943ad6f..b3676bbb88a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1275,7 +1275,7 @@ niko-home-control==0.2.1 niluclient==0.1.2 # homeassistant.components.noaa_tides -noaa-coops==0.1.8 +noaa-coops==0.1.9 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 3eedbe92ad0cec0f3d5921e9e23dfcedabd6eb4b Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Sun, 11 Jun 2023 10:21:33 -0400 Subject: [PATCH 198/857] Fix issue with Insteon linked devices maintaining current state (#94286) * Bump pyinsteon * Update tests --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/insteon/mock_devices.py | 4 ++-- tests/components/insteon/test_api_properties.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index cc8495384b1..ad3fb7bfbe8 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.4.2", + "pyinsteon==1.4.3", "insteon-frontend-home-assistant==0.3.5" ], "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index b3676bbb88a..4deee264855 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1724,7 +1724,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.2 +pyinsteon==1.4.3 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e146450b7d9..4b75eaf815d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1270,7 +1270,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.2 +pyinsteon==1.4.3 # homeassistant.components.ipma pyipma==3.0.6 diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py index dd0ab0b56a0..dea9fb4e34f 100644 --- a/tests/components/insteon/mock_devices.py +++ b/tests/components/insteon/mock_devices.py @@ -151,11 +151,11 @@ class MockDevices: for flag in operating_flags: value = operating_flags[flag] if device.operating_flags.get(flag): - device.operating_flags[flag].load(value) + device.operating_flags[flag].set_value(value) for flag in properties: value = properties[flag] if device.properties.get(flag): - device.properties[flag].load(value) + device.properties[flag].set_value(value) async def async_add_device(self, address=None, multiple=False): """Mock the async_add_device method.""" diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index a667e2144d0..850ccc85411 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -119,7 +119,7 @@ async def test_get_read_only_properties( mock_read_only = ExtendedProperty( "44.44.44", "mock_read_only", bool, is_read_only=True ) - mock_read_only.load(False) + mock_read_only.set_value(False) ws_client, devices = await _setup( hass, hass_ws_client, "44.44.44", iolinc_properties_data @@ -368,7 +368,7 @@ async def test_change_float_property( ) device = devices["44.44.44"] delay_prop = device.configuration[MOMENTARY_DELAY] - delay_prop.load(0) + delay_prop.set_value(0) with patch.object(insteon.api.properties, "devices", devices): await ws_client.send_json( { From fd43687833741b21221769d46b4d1ecef8a94711 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sun, 11 Jun 2023 10:22:12 -0400 Subject: [PATCH 199/857] Bump unifiprotect to 4.10.3 (#94416) * Bump unifiprotect to 4.10.3 * Reqs --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a414c03a0d4..cfa90664f36 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.10.2", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.3", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 4deee264855..8a9c84533bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.2 +pyunifiprotect==4.10.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b75eaf815d..aba6b6d2a49 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.2 +pyunifiprotect==4.10.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 2d62735b0d0fd8762f9b9a798b8753c8e8750cea Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sun, 11 Jun 2023 20:01:41 +0200 Subject: [PATCH 200/857] Fix deprecated asyncio.wait use with coroutines (#94371) --- homeassistant/components/xiaomi_miio/fan.py | 4 +--- homeassistant/components/xiaomi_miio/light.py | 4 +++- homeassistant/components/xiaomi_miio/switch.py | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index aaf471518d8..247b91d1b06 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -275,9 +275,7 @@ async def async_setup_entry( if not entity_method: continue await entity_method(**params) - update_tasks.append( - hass.async_create_task(entity.async_update_ha_state(True)) - ) + update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) if update_tasks: await asyncio.wait(update_tasks) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index ed1bdef9e33..9b8357a534f 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -229,7 +229,9 @@ async def async_setup_entry( if not hasattr(target_device, method["method"]): continue await getattr(target_device, method["method"])(**params) - update_tasks.append(target_device.async_update_ha_state(True)) + update_tasks.append( + asyncio.create_task(target_device.async_update_ha_state(True)) + ) if update_tasks: await asyncio.wait(update_tasks) diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 08b15f58217..9bba9f61123 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -500,7 +500,9 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): if not hasattr(device, method["method"]): continue await getattr(device, method["method"])(**params) - update_tasks.append(device.async_update_ha_state(True)) + update_tasks.append( + asyncio.create_task(device.async_update_ha_state(True)) + ) if update_tasks: await asyncio.wait(update_tasks) From 0fd28c91a80635dd6b1eb86c60bd5a88ddb9a996 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 11 Jun 2023 23:29:53 +0200 Subject: [PATCH 201/857] Remove overridden entity_id property from WirelessTagSensor (#94339) --- homeassistant/components/wirelesstag/sensor.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index cb5f3c59f57..e4505e59666 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -105,7 +105,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): # sensor.wirelesstag_bedroom_temperature # and not as sensor.bedroom for temperature and # sensor.bedroom_2 for humidity - self._entity_id = ( + self.entity_id = ( f"sensor.{WIRELESSTAG_DOMAIN}_{self.underscored_name}_{self._sensor_type}" ) @@ -119,11 +119,6 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): ) ) - @property - def entity_id(self): - """Overridden version.""" - return self._entity_id - @property def underscored_name(self): """Provide name savvy to be used in entity_id name of self.""" From acaa9ef9def07e9baad2eb6e85f0f7da2c2696c1 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sun, 11 Jun 2023 21:25:01 -0400 Subject: [PATCH 202/857] Bump elkm1-lib to 2.2.5 (#94296) Co-authored-by: J. Nick Koston --- homeassistant/components/elkm1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index d7094a2e60b..ccac1593fa0 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.2"] + "requirements": ["elkm1-lib==2.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a9c84533bd..0f19ad2bcb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -713,7 +713,7 @@ elgato==4.0.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.2 +elkm1-lib==2.2.5 # homeassistant.components.elmax elmax-api==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aba6b6d2a49..764a5a0c6df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -569,7 +569,7 @@ easyenergy==0.3.0 elgato==4.0.1 # homeassistant.components.elkm1 -elkm1-lib==2.2.2 +elkm1-lib==2.2.5 # homeassistant.components.elmax elmax-api==0.0.4 From 3adea14ddb43de5bac35ed4c53101b3c717554a2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 12 Jun 2023 06:30:23 +0200 Subject: [PATCH 203/857] Use TemplateSelector on imap custom imap_content event template config option (#94429) Use TemplateSelector for imap custom template --- homeassistant/components/imap/config_flow.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 6a737df7476..92c34e5cc78 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -26,9 +26,8 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, - TextSelector, - TextSelectorConfig, - TextSelectorType, + TemplateSelector, + TemplateSelectorConfig, ) from homeassistant.helpers.template import Template from homeassistant.util.ssl import SSLCipherList @@ -57,9 +56,7 @@ CIPHER_SELECTOR = SelectSelector( translation_key=CONF_SSL_CIPHER_LIST, ) ) -TEMPLATE_SELECTOR = TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True) -) +TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) CONFIG_SCHEMA = vol.Schema( { From c79b9d0b07608ebe9b375dba7d75f52f6f03f942 Mon Sep 17 00:00:00 2001 From: Sander Date: Mon, 12 Jun 2023 08:25:09 +0200 Subject: [PATCH 204/857] Fix: Xiaomi Miio Fan, delay off countdown unit conversion (#94428) --- homeassistant/components/xiaomi_miio/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 732710f7129..a8346caa894 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -441,7 +441,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): return await self._try_command( "Setting delay off miio device failed.", self._device.delay_off, - delay_off_countdown * 60, + delay_off_countdown, ) async def async_set_led_brightness_level(self, level: int) -> bool: From f17773233b3f9a2d19e54a2cf061dd5399bf1150 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 12 Jun 2023 10:26:02 +0200 Subject: [PATCH 205/857] Add check for integration config schema to hassfest (#93587) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- script/hassfest/__main__.py | 4 +- script/hassfest/config_schema.py | 108 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 script/hassfest/config_schema.py diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 87024619765..1c626ac3c5b 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -11,6 +11,7 @@ from . import ( bluetooth, codeowners, config_flow, + config_schema, coverage, dependencies, dhcp, @@ -32,6 +33,7 @@ INTEGRATION_PLUGINS = [ application_credentials, bluetooth, codeowners, + config_schema, dependencies, dhcp, json, @@ -43,7 +45,7 @@ INTEGRATION_PLUGINS = [ translations, usb, zeroconf, - config_flow, + config_flow, # This needs to run last, after translations are processed ] HASS_PLUGINS = [ coverage, diff --git a/script/hassfest/config_schema.py b/script/hassfest/config_schema.py new file mode 100644 index 00000000000..b794834161d --- /dev/null +++ b/script/hassfest/config_schema.py @@ -0,0 +1,108 @@ +"""Validate integrations which can be setup from YAML have config schemas.""" +from __future__ import annotations + +import ast + +from .model import Config, Integration + +CONFIG_SCHEMA_IGNORE = { + # Configuration under the homeassistant key is a special case, it's handled by + # conf_util.async_process_ha_core_config already during bootstrapping, not by + # a schema in the homeassistant integration. + "homeassistant", +} + + +def _has_assignment(module: ast.Module, name: str) -> bool: + """Test if the module assigns to a name.""" + for item in module.body: + if type(item) not in (ast.Assign, ast.AnnAssign, ast.AugAssign): + continue + if type(item) == ast.Assign: + for target in item.targets: + if target.id == name: + return True + continue + if item.target.id == name: + return True + return False + + +def _has_function( + module: ast.Module, _type: ast.AsyncFunctionDef | ast.FunctionDef, name: str +) -> bool: + """Test if the module defines a function.""" + for item in module.body: + if type(item) == _type and item.name == name: + return True + return False + + +def _has_import(module: ast.Module, name: str) -> bool: + """Test if the module imports to a name.""" + for item in module.body: + if type(item) not in (ast.Import, ast.ImportFrom): + continue + for alias in item.names: + if alias.asname == name or (alias.asname is None and alias.name == name): + return True + return False + + +def _validate_integration(config: Config, integration: Integration) -> None: + """Validate integration has has a configuration schema.""" + if integration.domain in CONFIG_SCHEMA_IGNORE: + return + + init_file = integration.path / "__init__.py" + + if not init_file.is_file(): + # Virtual integrations don't have any implementation + return + + init = ast.parse(init_file.read_text()) + + # No YAML Support + if not _has_function( + init, ast.AsyncFunctionDef, "async_setup" + ) and not _has_function(init, ast.FunctionDef, "setup"): + return + + # No schema + if ( + _has_assignment(init, "CONFIG_SCHEMA") + or _has_assignment(init, "PLATFORM_SCHEMA") + or _has_assignment(init, "PLATFORM_SCHEMA_BASE") + or _has_import(init, "CONFIG_SCHEMA") + or _has_import(init, "PLATFORM_SCHEMA") + or _has_import(init, "PLATFORM_SCHEMA_BASE") + ): + return + + config_file = integration.path / "config.py" + if config_file.is_file(): + config_module = ast.parse(config_file.read_text()) + if _has_function(config_module, ast.AsyncFunctionDef, "async_validate_config"): + return + + if config.specific_integrations: + notice_method = integration.add_warning + else: + notice_method = integration.add_error + + notice_method( + "config_schema", + "Integrations which implement 'async_setup' or 'setup' must define either " + "'CONFIG_SCHEMA', 'PLATFORM_SCHEMA' or 'PLATFORM_SCHEMA_BASE'. If the " + "integration has no configuration parameters, can only be set up from platforms" + " or can only be set up from config entries, one of the helpers " + "cv.empty_config_schema, cv.platform_only_config_schema or " + "cv.config_entry_only_config_schema can be used.", + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate integrations have configuration schemas.""" + for domain in sorted(integrations): + integration = integrations[domain] + _validate_integration(config, integration) From 4d3db038d6d3168cd19cbc4dff3a2ab6efe31afa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jun 2023 10:42:40 +0200 Subject: [PATCH 206/857] Bump actions/checkout from 3.5.2 to 3.5.3 (#94452) Bumps [actions/checkout](https://github.com/actions/checkout) from 3.5.2 to 3.5.3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.5.2...v3.5.3) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 ++++++------ .github/workflows/ci.yaml | 28 ++++++++++++++-------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 8 ++++---- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index a6997b58139..aee4bb785df 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 with: fetch-depth: 0 @@ -68,7 +68,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 @@ -106,7 +106,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -262,7 +262,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set build additional args run: | @@ -306,7 +306,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -346,7 +346,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b0aa9c74ea1..a60c6807af8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -82,7 +82,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -206,7 +206,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -251,7 +251,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 id: python @@ -297,7 +297,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 id: python @@ -346,7 +346,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 id: python @@ -440,7 +440,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -508,7 +508,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -540,7 +540,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -573,7 +573,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -617,7 +617,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -699,7 +699,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -824,7 +824,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -931,7 +931,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -1008,7 +1008,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 997543f0cf1..e57d4f1d196 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 323376dacd1..cd617283299 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Get information id: info @@ -87,7 +87,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Download env_file uses: actions/download-artifact@v3 @@ -125,7 +125,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Download env_file uses: actions/download-artifact@v3 @@ -233,7 +233,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Write alternative env-file for cp311 run: | From f2cf92050a52797a435c33940b9c9ce96fff728c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 12 Jun 2023 11:27:37 +0200 Subject: [PATCH 207/857] Remove Workday YAML configuration (#94102) Remove platform yaml from workday --- .../components/workday/binary_sensor.py | 82 +---------- .../components/workday/config_flow.py | 27 ---- homeassistant/components/workday/strings.json | 6 - .../components/workday/test_binary_sensor.py | 45 ------ tests/components/workday/test_config_flow.py | 139 ------------------ 5 files changed, 2 insertions(+), 297 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index a68a8215cb4..7c0dc8ff0a6 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,25 +2,17 @@ from __future__ import annotations from datetime import date, timedelta -from typing import Any import holidays from holidays import DateLike, HolidayBase -import voluptuous as vol -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - BinarySensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from .const import ( @@ -32,81 +24,11 @@ from .const import ( CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, - DEFAULT_EXCLUDES, - DEFAULT_NAME, - DEFAULT_OFFSET, - DEFAULT_WORKDAYS, DOMAIN, LOGGER, ) -def valid_country(value: Any) -> str: - """Validate that the given country is supported.""" - value = cv.string(value) - all_supported_countries = holidays.list_supported_countries() - - try: - raw_value = value.encode("utf-8") - except UnicodeError as err: - raise vol.Invalid( - "The country name or the abbreviation must be a valid UTF-8 string." - ) from err - if not raw_value: - raise vol.Invalid("Country name or the abbreviation must not be empty.") - if value not in all_supported_countries: - raise vol.Invalid("Country is not supported.") - return value - - -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COUNTRY): valid_country, - vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): vol.All( - cv.ensure_list, [vol.In(ALLOWED_DAYS)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), - vol.Optional(CONF_PROVINCE): cv.string, - vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All( - cv.ensure_list, [vol.In(ALLOWED_DAYS)] - ), - vol.Optional(CONF_ADD_HOLIDAYS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Workday sensor.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.7.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 7f0c7906621..a2d804a11c4 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -155,33 +155,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return WorkdayOptionsFlowHandler(config_entry) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a configuration from config.yaml.""" - - abort_match = { - CONF_COUNTRY: config[CONF_COUNTRY], - CONF_EXCLUDES: config[CONF_EXCLUDES], - CONF_OFFSET: config[CONF_OFFSET], - CONF_WORKDAYS: config[CONF_WORKDAYS], - CONF_ADD_HOLIDAYS: config[CONF_ADD_HOLIDAYS], - CONF_REMOVE_HOLIDAYS: config[CONF_REMOVE_HOLIDAYS], - CONF_PROVINCE: config.get(CONF_PROVINCE), - } - new_config = config.copy() - new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE) - LOGGER.debug("Importing with %s", new_config) - - self._async_abort_entries_match(abort_match) - - self.data[CONF_NAME] = config.get(CONF_NAME, DEFAULT_NAME) - self.data[CONF_COUNTRY] = config[CONF_COUNTRY] - LOGGER.debug( - "No duplicate, next step with name %s for country %s", - self.data[CONF_NAME], - self.data[CONF_COUNTRY], - ) - return await self.async_step_options(user_input=new_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index b81e027bb05..4ec1cf34e99 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -63,12 +63,6 @@ "already_configured": "Service with this configuration already exist" } }, - "issues": { - "deprecated_yaml": { - "title": "The Workday YAML configuration is being removed", - "description": "Configuring Workday using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Workday YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "selector": { "province": { "options": { diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 71dd23c19a3..d2ae9895544 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -4,9 +4,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -import voluptuous as vol -from homeassistant.components.workday import binary_sensor from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC @@ -30,21 +28,6 @@ from . import ( ) -async def test_valid_country_yaml() -> None: - """Test valid country from yaml.""" - # Invalid UTF-8, must not contain U+D800 to U+DFFF - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\ud800") - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\udfff") - # Country MUST NOT be empty - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("") - # Country must be supported by holidays - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("HomeAssistantLand") - - @pytest.mark.parametrize( ("config", "expected_state"), [ @@ -79,34 +62,6 @@ async def test_setup( } -async def test_setup_from_import( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, -) -> None: - """Test setup from various configs.""" - freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday - await async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "workday", - "country": "DE", - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.workday_sensor") - assert state.state == "off" - assert state.attributes == { - "friendly_name": "Workday Sensor", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - } - - async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: """Test setup invalid province with import.""" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 7e28471c78c..ce4dd127778 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -13,7 +13,6 @@ from homeassistant.components.workday.const import ( CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, - DEFAULT_NAME, DEFAULT_OFFSET, DEFAULT_WORKDAYS, DOMAIN, @@ -24,8 +23,6 @@ from homeassistant.data_entry_flow import FlowResultType from . import init_integration -from tests.common import MockConfigEntry - pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -114,142 +111,6 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: } -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: DEFAULT_NAME, - CONF_COUNTRY: "DE", - CONF_EXCLUDES: DEFAULT_EXCLUDES, - CONF_OFFSET: DEFAULT_OFFSET, - CONF_WORKDAYS: DEFAULT_WORKDAYS, - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Workday Sensor" - assert result["options"] == { - "name": "Workday Sensor", - "country": "DE", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - "province": None, - } - - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Workday Sensor 2", - CONF_COUNTRY: "DE", - CONF_PROVINCE: "BW", - CONF_EXCLUDES: DEFAULT_EXCLUDES, - CONF_OFFSET: DEFAULT_OFFSET, - CONF_WORKDAYS: DEFAULT_WORKDAYS, - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Workday Sensor 2" - assert result2["options"] == { - "name": "Workday Sensor 2", - "country": "DE", - "province": "BW", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - } - - -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: - """Test import of yaml already exist.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - "name": "Workday Sensor", - "country": "DE", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - "province": None, - }, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Workday sensor 2", - CONF_COUNTRY: "DE", - CONF_EXCLUDES: ["sat", "sun", "holiday"], - CONF_OFFSET: 0, - CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_flow_province_no_conflict(hass: HomeAssistant) -> None: - """Test import of yaml with province.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - "name": "Workday Sensor", - "country": "DE", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - }, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Workday sensor 2", - CONF_COUNTRY: "DE", - CONF_PROVINCE: "BW", - CONF_EXCLUDES: ["sat", "sun", "holiday"], - CONF_OFFSET: 0, - CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - - async def test_options_form(hass: HomeAssistant) -> None: """Test we get the form in options.""" From ce02261303aee8fcc0d5cf77fd27367ebec4cf83 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 12 Jun 2023 11:28:21 +0200 Subject: [PATCH 208/857] Remove Brottsplatskartan YAML configuration (#94101) Remove platform yaml --- .../brottsplatskartan/config_flow.py | 15 --- .../components/brottsplatskartan/sensor.py | 51 +-------- .../components/brottsplatskartan/strings.json | 6 - .../brottsplatskartan/test_config_flow.py | 108 ------------------ 4 files changed, 4 insertions(+), 176 deletions(-) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index 1de24ffa76c..09d6cd96087 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -34,21 +34,6 @@ class BPKConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a configuration from config.yaml.""" - - if config.get(CONF_LATITUDE): - config[CONF_LOCATION] = { - CONF_LATITUDE: config[CONF_LATITUDE], - CONF_LONGITUDE: config[CONF_LONGITUDE], - } - if not config.get(CONF_AREA): - config[CONF_AREA] = "none" - else: - config[CONF_AREA] = config[CONF_AREA][0] - - return await self.async_step_user(user_input=config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index ca6173d2ef5..63af7530b79 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -5,62 +5,19 @@ from collections import defaultdict from datetime import timedelta from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN, LOGGER +from .const import CONF_APP_ID, CONF_AREA, DOMAIN, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_AREA, default=[]): vol.All(cv.ensure_list, [vol.In(AREAS)]), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Brottsplatskartan platform.""" - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.7.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/brottsplatskartan/strings.json b/homeassistant/components/brottsplatskartan/strings.json index 8d9677a0af4..f10120f7884 100644 --- a/homeassistant/components/brottsplatskartan/strings.json +++ b/homeassistant/components/brottsplatskartan/strings.json @@ -16,12 +16,6 @@ } } }, - "issues": { - "deprecated_yaml": { - "title": "The Brottsplatskartan YAML configuration is being removed", - "description": "Configuring Brottsplatskartan using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Brottsplatskartan YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "selector": { "areas": { "options": { diff --git a/tests/components/brottsplatskartan/test_config_flow.py b/tests/components/brottsplatskartan/test_config_flow.py index dd3139dc2b9..efd259fa73c 100644 --- a/tests/components/brottsplatskartan/test_config_flow.py +++ b/tests/components/brottsplatskartan/test_config_flow.py @@ -1,8 +1,6 @@ """Test the Brottsplatskartan config flow.""" from __future__ import annotations -from unittest.mock import patch - import pytest from homeassistant import config_entries @@ -11,8 +9,6 @@ from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -103,107 +99,3 @@ async def test_form_area(hass: HomeAssistant) -> None: "area": "Stockholms län", "app_id": "ha-1234567890", } - - -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={}, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Brottsplatskartan HOME" - assert result2["data"] == { - "latitude": hass.config.latitude, - "longitude": hass.config.longitude, - "area": None, - "app_id": "ha-1234567890", - } - - -async def test_import_flow_location_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml with location.""" - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_LATITUDE: 59.32, - CONF_LONGITUDE: 18.06, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Brottsplatskartan 59.32, 18.06" - assert result2["data"] == { - "latitude": 59.32, - "longitude": 18.06, - "area": None, - "app_id": "ha-1234567890", - } - - -async def test_import_flow_location_area_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml with location and area.""" - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_LATITUDE: 59.32, - CONF_LONGITUDE: 18.06, - CONF_AREA: ["Blekinge län"], - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Brottsplatskartan Blekinge län" - assert result2["data"] == { - "latitude": None, - "longitude": None, - "area": "Blekinge län", - "app_id": "ha-1234567890", - } - - -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: - """Test import of yaml already exist.""" - - MockConfigEntry( - domain=DOMAIN, - data={ - "latitude": hass.config.latitude, - "longitude": hass.config.longitude, - "area": None, - "app_id": "ha-1234567890", - }, - unique_id="bpk-home", - ).add_to_hass(hass) - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result3 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={}, - ) - await hass.async_block_till_done() - - assert result3["type"] == FlowResultType.ABORT - assert result3["reason"] == "already_configured" From eb2b1d2970ac039812d64a766adac9dbd741e340 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 12 Jun 2023 11:33:47 +0200 Subject: [PATCH 209/857] Add diagnostic support to Rituals Perfume Genie (#94373) --- .../rituals_perfume_genie/diagnostics.py | 31 +++++ .../rituals_perfume_genie/common.py | 3 +- .../rituals_perfume_genie/fixtures/data.json | 121 +++++++++++++++++ .../snapshots/test_diagnostics.ambr | 128 ++++++++++++++++++ .../rituals_perfume_genie/test_diagnostics.py | 25 ++++ 5 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/rituals_perfume_genie/diagnostics.py create mode 100644 tests/components/rituals_perfume_genie/fixtures/data.json create mode 100644 tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr create mode 100644 tests/components/rituals_perfume_genie/test_diagnostics.py diff --git a/homeassistant/components/rituals_perfume_genie/diagnostics.py b/homeassistant/components/rituals_perfume_genie/diagnostics.py new file mode 100644 index 00000000000..75b622b48b1 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for Rituals Perfume Genie.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator + +TO_REDACT = { + "hublot", + "hash", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + entry.entry_id + ] + return { + "diffusers": [ + async_redact_data(coordinator.diffuser.data, TO_REDACT) + for coordinator in coordinators.values() + ] + } diff --git a/tests/components/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py index 1f12d3e651e..f8bcc10ca59 100644 --- a/tests/components/rituals_perfume_genie/common.py +++ b/tests/components/rituals_perfume_genie/common.py @@ -7,7 +7,7 @@ from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, D from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConfigEntry: @@ -58,6 +58,7 @@ def mock_diffuser( diffuser_mock.update_data = AsyncMock() diffuser_mock.version = version diffuser_mock.wifi_percentage = wifi_percentage + diffuser_mock.data = load_json_object_fixture("data.json", DOMAIN) return diffuser_mock diff --git a/tests/components/rituals_perfume_genie/fixtures/data.json b/tests/components/rituals_perfume_genie/fixtures/data.json new file mode 100644 index 00000000000..e583c87e3da --- /dev/null +++ b/tests/components/rituals_perfume_genie/fixtures/data.json @@ -0,0 +1,121 @@ +{ + "hub": { + "hublot": "LOT123", + "hash": "1234567890abcdefgghijklmnopqrstuvwxyz", + "status": 1, + "title": null, + "current_time": "2023-06-09T20:50", + "cached_time": "2023-06-09T20:48", + "ping_update": "25", + "attributes": { + "roomc": "4", + "speedc": "3", + "fanc": "1", + "roomnamec": "Living room", + "resetc": "", + "fspacenamec": "", + "fspacetypec": "" + }, + "sensors": { + "wific": { + "id": 10, + "sensor_id": 1, + "title": "High", + "description": "", + "icon": "icon-signal.png", + "image": "", + "discover_image": "", + "discover_url": null, + "min_value": "-69.99", + "max_value": "-0.00", + "interval": "1", + "created_at": "2017-03-10 16:17:30", + "updated_at": "2020-06-17 16:57:53", + "default": 0 + }, + "fillc": { + "id": 38, + "sensor_id": 3, + "title": "90-100%", + "description": "", + "icon": "icon-fill.png", + "image": "", + "discover_image": "", + "discover_url": "", + "min_value": "0", + "max_value": "2000", + "interval": "", + "created_at": "2017-10-16 07:50:23", + "updated_at": "2021-02-17 13:42:16", + "default": 0 + }, + "rfidc": { + "id": 54, + "sensor_id": 4, + "title": "Private Collection Sweet Jasmine", + "description": "", + "icon": "icon-jasmine.png", + "image": "background-jasmine.png", + "discover_image": "discover-jasmine.png", + "discover_url": "sweet-jasmine-cartridge-1105402.html", + "min_value": "05377650", + "max_value": "05377650", + "interval": "", + "created_at": "2019-04-04 07:53:32", + "updated_at": "2021-01-26 14:17:03", + "default": 0 + }, + "versionc": "5.2-rc15", + "ipc": "1682963060", + "rpsc": { + "id": 48, + "sensor_id": 12, + "title": "Fan on", + "description": "", + "icon": "", + "image": "", + "discover_image": "", + "discover_url": null, + "min_value": "5", + "max_value": "10000", + "interval": "", + "created_at": "2018-01-23 12:05:45", + "updated_at": "2019-08-01 14:54:53", + "default": 0 + }, + "resetc": "External System", + "chipidc": "9820410", + "errorc": "", + "onlinec": { + "id": 31, + "sensor_id": 16, + "title": "Online", + "description": "", + "icon": "", + "image": "", + "discover_image": "", + "discover_url": null, + "min_value": "1", + "max_value": "1", + "interval": "", + "created_at": "2017-09-07 08:23:30", + "updated_at": "2017-09-07 08:23:30", + "default": 0 + } + }, + "settings": [ + { + "schedule_id": 1835730, + "start": "07:30", + "end": "08:30", + "mon": 1, + "tue": 1, + "wed": 1, + "thu": 1, + "fri": 1, + "sat": 1, + "sun": 1 + } + ] + } +} diff --git a/tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr b/tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..4edc7622af7 --- /dev/null +++ b/tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr @@ -0,0 +1,128 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'diffusers': list([ + dict({ + 'hub': dict({ + 'attributes': dict({ + 'fanc': '1', + 'fspacenamec': '', + 'fspacetypec': '', + 'resetc': '', + 'roomc': '4', + 'roomnamec': 'Living room', + 'speedc': '3', + }), + 'cached_time': '2023-06-09T20:48', + 'current_time': '2023-06-09T20:50', + 'hash': '**REDACTED**', + 'hublot': '**REDACTED**', + 'ping_update': '25', + 'sensors': dict({ + 'chipidc': '9820410', + 'errorc': '', + 'fillc': dict({ + 'created_at': '2017-10-16 07:50:23', + 'default': 0, + 'description': '', + 'discover_image': '', + 'discover_url': '', + 'icon': 'icon-fill.png', + 'id': 38, + 'image': '', + 'interval': '', + 'max_value': '2000', + 'min_value': '0', + 'sensor_id': 3, + 'title': '90-100%', + 'updated_at': '2021-02-17 13:42:16', + }), + 'ipc': '1682963060', + 'onlinec': dict({ + 'created_at': '2017-09-07 08:23:30', + 'default': 0, + 'description': '', + 'discover_image': '', + 'discover_url': None, + 'icon': '', + 'id': 31, + 'image': '', + 'interval': '', + 'max_value': '1', + 'min_value': '1', + 'sensor_id': 16, + 'title': 'Online', + 'updated_at': '2017-09-07 08:23:30', + }), + 'resetc': 'External System', + 'rfidc': dict({ + 'created_at': '2019-04-04 07:53:32', + 'default': 0, + 'description': '', + 'discover_image': 'discover-jasmine.png', + 'discover_url': 'sweet-jasmine-cartridge-1105402.html', + 'icon': 'icon-jasmine.png', + 'id': 54, + 'image': 'background-jasmine.png', + 'interval': '', + 'max_value': '05377650', + 'min_value': '05377650', + 'sensor_id': 4, + 'title': 'Private Collection Sweet Jasmine', + 'updated_at': '2021-01-26 14:17:03', + }), + 'rpsc': dict({ + 'created_at': '2018-01-23 12:05:45', + 'default': 0, + 'description': '', + 'discover_image': '', + 'discover_url': None, + 'icon': '', + 'id': 48, + 'image': '', + 'interval': '', + 'max_value': '10000', + 'min_value': '5', + 'sensor_id': 12, + 'title': 'Fan on', + 'updated_at': '2019-08-01 14:54:53', + }), + 'versionc': '5.2-rc15', + 'wific': dict({ + 'created_at': '2017-03-10 16:17:30', + 'default': 0, + 'description': '', + 'discover_image': '', + 'discover_url': None, + 'icon': 'icon-signal.png', + 'id': 10, + 'image': '', + 'interval': '1', + 'max_value': '-0.00', + 'min_value': '-69.99', + 'sensor_id': 1, + 'title': 'High', + 'updated_at': '2020-06-17 16:57:53', + }), + }), + 'settings': list([ + dict({ + 'end': '08:30', + 'fri': 1, + 'mon': 1, + 'sat': 1, + 'schedule_id': 1835730, + 'start': '07:30', + 'sun': 1, + 'thu': 1, + 'tue': 1, + 'wed': 1, + }), + ]), + 'status': 1, + 'title': None, + }), + }), + ]), + }) +# --- diff --git a/tests/components/rituals_perfume_genie/test_diagnostics.py b/tests/components/rituals_perfume_genie/test_diagnostics.py new file mode 100644 index 00000000000..a57f14f9afd --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_diagnostics.py @@ -0,0 +1,25 @@ +"""Tests for the diagnostics data provided by the Rituals Perfume Genie integration.""" +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from .common import init_integration, mock_config_entry, mock_diffuser + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry = mock_config_entry(unique_id="number_test") + diffuser = mock_diffuser(hublot="lot123", perfume_amount=2) + await init_integration(hass, config_entry, [diffuser]) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From d84b8a028898d1cd0facd77106d82db10647c2d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jun 2023 12:14:32 +0200 Subject: [PATCH 210/857] Bump home-assistant/builder from 2023.03.0 to 2023.06.0 (#94453) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index aee4bb785df..fce25f3fd75 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -211,7 +211,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.03.0 + uses: home-assistant/builder@2023.06.0 with: args: | $BUILD_ARGS \ @@ -289,7 +289,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.03.0 + uses: home-assistant/builder@2023.06.0 with: args: | $BUILD_ARGS \ From 86f2aa84e5457500157fbff75d0336f0b00275e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 12 Jun 2023 16:59:11 +0200 Subject: [PATCH 211/857] Fix flaky ESPHome test fixture (#94465) --- tests/components/esphome/conftest.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 3f8df691573..37ab3123919 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -1,9 +1,10 @@ """esphome session fixtures.""" from __future__ import annotations +from asyncio import Event from unittest.mock import AsyncMock, Mock, patch -from aioesphomeapi import APIClient, APIVersion, DeviceInfo +from aioesphomeapi import APIClient, APIVersion, DeviceInfo, ReconnectLogic import pytest from zeroconf import Zeroconf @@ -160,10 +161,18 @@ async def mock_voice_assistant_entry( mock_client.device_info = AsyncMock(return_value=device_info) mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() + try_connect_done = Event() + real_try_connect = ReconnectLogic._try_connect + + async def mock_try_connect(self): + """Set an event when ReconnectLogic._try_connect has been awaited.""" + result = await real_try_connect(self) + try_connect_done.set() + return result + + with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): + await hass.config_entries.async_setup(entry.entry_id) + await try_connect_done.wait() return entry From a25f3c9b2771e540b2fb7c25f7cfec4cbaaf885b Mon Sep 17 00:00:00 2001 From: jasonkuster Date: Mon, 12 Jun 2023 10:07:42 -0700 Subject: [PATCH 212/857] Fix ZHA binding api to actually return responses (#94388) --- homeassistant/components/zha/websocket_api.py | 13 ++- tests/components/zha/test_websocket_api.py | 94 ++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 28e115c0ec4..97862bd36f0 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -907,6 +907,7 @@ async def websocket_bind_devices( ATTR_TARGET_IEEE, target_ieee, ) + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -935,6 +936,7 @@ async def websocket_unbind_devices( ATTR_TARGET_IEEE, target_ieee, ) + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -951,13 +953,14 @@ async def websocket_bind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = get_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) assert source_device await source_device.async_bind_to_group(group_id, bindings) + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -974,13 +977,19 @@ async def websocket_unbind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Unbind a device from a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = get_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) assert source_device await source_device.async_unbind_from_group(group_id, bindings) + connection.send_result(msg[ID]) + + +def get_gateway(hass: HomeAssistant) -> ZHAGateway: + """Return Gateway, mainly as fixture for mocking during testing.""" + return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] async def async_binding_operation( diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 5250b62a9b0..0904fc1f685 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -4,15 +4,17 @@ from __future__ import annotations from binascii import unhexlify from copy import deepcopy from typing import TYPE_CHECKING -from unittest.mock import ANY, AsyncMock, call, patch +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch import pytest import voluptuous as vol import zigpy.backups import zigpy.profiles.zha import zigpy.types +from zigpy.types.named import EUI64 import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.security as security +import zigpy.zdo.types as zdo_types from homeassistant.components.websocket_api import const from homeassistant.components.zha import DOMAIN @@ -26,6 +28,8 @@ from homeassistant.components.zha.core.const import ( ATTR_MODEL, ATTR_NEIGHBORS, ATTR_QUIRK_APPLIED, + ATTR_TYPE, + BINDINGS, CLUSTER_TYPE_IN, EZSP_OVERWRITE_EUI64, GROUP_ID, @@ -37,6 +41,7 @@ from homeassistant.components.zha.websocket_api import ( ATTR_INSTALL_CODE, ATTR_QR_CODE, ATTR_SOURCE_IEEE, + ATTR_TARGET_IEEE, ID, SERVICE_PERMIT, TYPE, @@ -884,3 +889,90 @@ async def test_websocket_change_channel( assert msg["success"] change_channel_mock.mock_calls == [call(ANY, new_channel)] + + +@pytest.mark.parametrize( + "operation", + [("bind", zdo_types.ZDOCmd.Bind_req), ("unbind", zdo_types.ZDOCmd.Unbind_req)], +) +async def test_websocket_bind_unbind_devices( + operation: tuple[str, zdo_types.ZDOCmd], + app_controller: ControllerApplication, + zha_client, +) -> None: + """Test websocket API for binding and unbinding devices to devices.""" + + command_type, req = operation + with patch( + "homeassistant.components.zha.websocket_api.async_binding_operation", + autospec=True, + ) as binding_operation_mock: + await zha_client.send_json( + { + ID: 27, + TYPE: f"zha/devices/{command_type}", + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_TARGET_IEEE: IEEE_GROUPABLE_DEVICE, + } + ) + msg = await zha_client.receive_json() + + assert msg["id"] == 27 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert binding_operation_mock.mock_calls == [ + call( + ANY, + EUI64.convert(IEEE_SWITCH_DEVICE), + EUI64.convert(IEEE_GROUPABLE_DEVICE), + req, + ) + ] + + +@pytest.mark.parametrize("command_type", ["bind", "unbind"]) +async def test_websocket_bind_unbind_group( + command_type: str, + app_controller: ControllerApplication, + zha_client, +) -> None: + """Test websocket API for binding and unbinding devices to groups.""" + + test_group_id = 0x0001 + gateway_mock = MagicMock() + with patch( + "homeassistant.components.zha.websocket_api.get_gateway", + return_value=gateway_mock, + ): + device_mock = MagicMock() + bind_mock = AsyncMock() + unbind_mock = AsyncMock() + device_mock.async_bind_to_group = bind_mock + device_mock.async_unbind_from_group = unbind_mock + gateway_mock.get_device = MagicMock() + gateway_mock.get_device.return_value = device_mock + await zha_client.send_json( + { + ID: 27, + TYPE: f"zha/groups/{command_type}", + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + GROUP_ID: test_group_id, + BINDINGS: [ + { + ATTR_ENDPOINT_ID: 1, + ID: 6, + ATTR_NAME: "OnOff", + ATTR_TYPE: "out", + }, + ], + } + ) + msg = await zha_client.receive_json() + + assert msg["id"] == 27 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + if command_type == "bind": + assert bind_mock.mock_calls == [call(test_group_id, ANY)] + elif command_type == "unbind": + assert unbind_mock.mock_calls == [call(test_group_id, ANY)] From c756c7aceb4a11b148fd9f82aec2116ca9d5217d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 12 Jun 2023 20:18:05 +0200 Subject: [PATCH 213/857] Cleanup help_setup_helper in common mqtt tests (#94482) --- tests/components/mqtt/test_common.py | 70 +++++++++------------------- 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index ce4e7909154..cd1cc7280c6 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -27,7 +27,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.generated.mqtt import MQTT from homeassistant.helpers import ( - config_validation as cv, device_registry as dr, entity_registry as er, ) @@ -35,7 +34,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from tests.common import MockConfigEntry, async_fire_mqtt_message -from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG_DEVICE_INFO_ID = { "identifiers": ["helloworld"], @@ -77,39 +76,6 @@ def help_all_subscribe_calls(mqtt_client_mock: MqttMockPahoClient) -> list[Any]: return all_calls -async def help_setup_component( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator | None, - domain: str, - config: ConfigType, - use_discovery: bool = False, -) -> MqttMockHAClient | None: - """Help to set up the MQTT component.""" - # `async_setup_component` will call `async_setup` and - # after that it will also start the entry `async_start_entry` - # when `async_setup` removed mqtt_mock_entry_with_no_config should be awaited. - - if use_discovery: - comp_config = cv.ensure_list(config[mqtt.DOMAIN][domain]) - item = 0 - assert mqtt_mock_entry is not None - mqtt_mock = await mqtt_mock_entry() - for comp in comp_config: - item += 1 - topic = f"homeassistant/{domain}/item_{item}/config" - async_fire_mqtt_message(hass, topic, json.dumps(comp)) - await hass.async_block_till_done() - else: - entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} - ) - entry.add_to_hass(hass) - with patch("homeassistant.config.load_yaml_config_file", return_value=config): - await entry.async_setup(hass) - mqtt_mock = None - return mqtt_mock - - def help_custom_config( mqtt_entity_domain: str, mqtt_base_config: ConfigType, @@ -170,7 +136,7 @@ async def help_test_availability_without_topic( async def help_test_default_availability_payload( hass: HomeAssistant, - mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -185,7 +151,8 @@ async def help_test_default_availability_payload( config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -233,7 +200,8 @@ async def help_test_default_availability_list_payload( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -294,7 +262,8 @@ async def help_test_default_availability_list_payload_all( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -356,7 +325,8 @@ async def help_test_default_availability_list_payload_any( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -439,7 +409,8 @@ async def help_test_custom_availability_payload( config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" config[mqtt.DOMAIN][domain]["payload_available"] = "good" config[mqtt.DOMAIN][domain]["payload_not_available"] = "nogood" - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -559,7 +530,8 @@ async def help_test_setting_attribute_via_mqtt_json_message( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') state = hass.states.get(f"{domain}.test") @@ -616,7 +588,8 @@ async def help_test_setting_attribute_with_template( config[mqtt.DOMAIN][domain][ "json_attributes_template" ] = "{{ value_json['Timer1'] | tojson }}" - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() async_fire_mqtt_message( hass, "attr-topic", json.dumps({"Timer1": {"Arm": 0, "Time": "22:18"}}) @@ -642,7 +615,8 @@ async def help_test_update_with_json_attrs_not_dict( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') state = hass.states.get(f"{domain}.test") @@ -665,7 +639,8 @@ async def help_test_update_with_json_attrs_bad_json( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - await help_setup_component(hass, mqtt_mock_entry, domain, config) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await mqtt_mock_entry() async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") @@ -1162,9 +1137,8 @@ async def help_test_entity_id_update_subscriptions( assert len(topics) > 0 entity_registry = er.async_get(hass) - mqtt_mock = await help_setup_component( - hass, mqtt_mock_entry, domain, config, use_discovery=True - ) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + mqtt_mock = await mqtt_mock_entry() assert mqtt_mock is not None state = hass.states.get(f"{domain}.test") From 854c70933217e4674d2b11672f9276e491d807b2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 12 Jun 2023 20:19:46 +0200 Subject: [PATCH 214/857] Update OwnTracks UI strings to reflect OwnTracks UI (#94476) OwnTracks: update UI strings to reflect OwnTracks UI --- homeassistant/components/owntracks/config_flow.py | 4 ++-- homeassistant/components/owntracks/strings.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index b82a8727388..13b2051ffa2 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -34,8 +34,8 @@ class OwnTracksFlow(config_entries.ConfigFlow, domain=DOMAIN): if supports_encryption(): secret_desc = ( - f"The encryption key is {secret} (on Android under preferences ->" - " advanced)" + f"The encryption key is {secret} (on Android under Preferences >" + " Advanced)" ) else: secret_desc = "Encryption is not supported because nacl is not installed." diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json index a127d9d6a4a..2486e01223f 100644 --- a/homeassistant/components/owntracks/strings.json +++ b/homeassistant/components/owntracks/strings.json @@ -11,7 +11,7 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { - "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `''`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `''`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." } } } From 5cdb65100f4c5811fcc5ab1313091be877628575 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 12 Jun 2023 14:30:15 -0400 Subject: [PATCH 215/857] Add Roborock DnD switch (#94474) * add Dnd switch * add dnd test * fix typing --- homeassistant/components/roborock/device.py | 10 +++ .../components/roborock/strings.json | 3 + homeassistant/components/roborock/switch.py | 90 +++++++++++++++++-- homeassistant/components/roborock/vacuum.py | 1 - tests/components/roborock/test_switch.py | 1 + 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 41db2cc08ac..3801ccbecc9 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -72,3 +72,13 @@ class RoborockCoordinatedEntity( if status: return status return Status({}) + + async def send( + self, + command: RoborockCommand, + params: dict[str, Any] | list[Any] | None = None, + ) -> dict: + """Overloads normal send command but refreshes coordinator.""" + res = await super().send(command, params) + await self.coordinator.async_refresh() + return res diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index e36b8c89e34..f711ceaf74a 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -110,6 +110,9 @@ "child_lock": { "name": "Child lock" }, + "dnd_switch": { + "name": "Do not disturb" + }, "status_indicator": { "name": "Status indicator light" } diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index d8ff50430cb..d16d7437202 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -17,7 +17,7 @@ from homeassistant.util import slugify from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntity +from .device import RoborockCoordinatedEntity, RoborockEntity _LOGGER = logging.getLogger(__name__) @@ -36,6 +36,16 @@ class RoborockSwitchDescriptionMixin: check_support: Callable[[RoborockDataUpdateCoordinator], Coroutine[Any, Any, dict]] +@dataclass +class RoborockCoordinatedSwitchDescriptionMixIn: + """Define an entity description mixin for switch entities.""" + + get_value: Callable[[RoborockCoordinatedEntity], bool] + set_command: Callable[[RoborockCoordinatedEntity, bool], Coroutine[Any, Any, dict]] + # Check support of this feature + check_support: Callable[[RoborockDataUpdateCoordinator], dict] + + @dataclass class RoborockSwitchDescription( SwitchEntityDescription, RoborockSwitchDescriptionMixin @@ -43,6 +53,13 @@ class RoborockSwitchDescription( """Class to describe an Roborock switch entity.""" +@dataclass +class RoborockCoordinatedSwitchDescription( + SwitchEntityDescription, RoborockCoordinatedSwitchDescriptionMixIn +): + """Class to describe an Roborock switch entity that needs a coordinator.""" + + SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ RoborockSwitchDescription( set_command=lambda entity, value: entity.send( @@ -74,6 +91,28 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ ), ] +COORDINATED_SWITCH_DESCRIPTION = [ + RoborockCoordinatedSwitchDescription( + set_command=lambda entity, value: entity.send( + RoborockCommand.SET_DND_TIMER, + [ + entity.coordinator.roborock_device_info.props.dnd_timer.start_hour, + entity.coordinator.roborock_device_info.props.dnd_timer.start_minute, + entity.coordinator.roborock_device_info.props.dnd_timer.end_hour, + entity.coordinator.roborock_device_info.props.dnd_timer.end_minute, + ], + ) + if value + else entity.send(RoborockCommand.CLOSE_DND_TIMER), + check_support=lambda data: data.roborock_device_info.props.dnd_timer, + get_value=lambda data: data.coordinator.roborock_device_info.props.dnd_timer.enabled, + key="dnd_switch", + translation_key="dnd_switch", + icon="mdi:bell-cancel", + entity_category=EntityCategory.CONFIG, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -100,7 +139,7 @@ async def async_setup_entry( ), return_exceptions=True, ) - valid_entities: list[RoborockSwitchEntity] = [] + valid_entities: list[RoborockNonCoordinatedSwitchEntity] = [] for posible_entity, result in zip(possible_entities, results): if isinstance(result, Exception): if not isinstance(result, RoborockException): @@ -108,7 +147,7 @@ async def async_setup_entry( _LOGGER.debug("Not adding entity because of %s", result) else: valid_entities.append( - RoborockSwitchEntity( + RoborockNonCoordinatedSwitchEntity( f"{posible_entity[2].key}_{slugify(posible_entity[0])}", posible_entity[1], posible_entity[2], @@ -119,10 +158,22 @@ async def async_setup_entry( valid_entities, True, ) + async_add_entities( + ( + RoborockCoordinatedSwitchEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in COORDINATED_SWITCH_DESCRIPTION + if description.check_support(coordinator) is not None + ) + ) -class RoborockSwitchEntity(RoborockEntity, SwitchEntity): - """A class to let you turn functionality on Roborock devices on and off.""" +class RoborockNonCoordinatedSwitchEntity(RoborockEntity, SwitchEntity): + """A class to let you turn functionality on Roborock devices on and off that does not need a coordinator.""" entity_description: RoborockSwitchDescription @@ -151,3 +202,32 @@ class RoborockSwitchEntity(RoborockEntity, SwitchEntity): self._attr_is_on = self.entity_description.evaluate_value( await self.entity_description.get_value(self) ) + + +class RoborockCoordinatedSwitchEntity(RoborockCoordinatedEntity, SwitchEntity): + """A class to let you turn functionality on Roborock devices on and off that does need a coordinator.""" + + entity_description: RoborockCoordinatedSwitchDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockCoordinatedSwitchDescription, + ) -> None: + """Create a switch entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.entity_description.set_command(self, False) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.entity_description.set_command(self, True) + + @property + def is_on(self) -> bool | None: + """Use the coordinator to determine if the switch is on.""" + return self.entity_description.get_value(self) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 9e486fa54b1..932febd80f0 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -144,7 +144,6 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): RoborockCommand.SET_CUSTOM_MODE, [self._device_status.fan_power.as_dict().get(fan_speed)], ) - await self.coordinator.async_request_refresh() async def async_start_pause(self) -> None: """Start, pause or resume the cleaning task.""" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 276c2758da4..9c079ef85b6 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -16,6 +16,7 @@ from tests.common import MockConfigEntry [ ("switch.roborock_s7_maxv_child_lock"), ("switch.roborock_s7_maxv_status_indicator_light"), + ("switch.roborock_s7_maxv_do_not_disturb"), ], ) async def test_update_success( From eb0485ebb0fda917f0fdc620a27fda368c0f085a Mon Sep 17 00:00:00 2001 From: mover85 Date: Tue, 13 Jun 2023 06:58:20 +1200 Subject: [PATCH 216/857] Revert "Bump pydaikin 2.9.1 (#93635)" (#94469) Revert to pydaikin 2.9.0 --- homeassistant/components/daikin/manifest.json | 2 +- homeassistant/components/daikin/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 02a8cdbe68f..6f90b0cf5ef 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["pydaikin"], "quality_scale": "platinum", - "requirements": ["pydaikin==2.9.1"], + "requirements": ["pydaikin==2.9.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 1b83f7f7330..37b3ec45c4c 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -42,7 +42,7 @@ async def async_setup_entry( [ DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone[0] != ("-", "0") + if zone != ("-", "0") ] ) if daikin_api.device.support_advanced_modes: diff --git a/requirements_all.txt b/requirements_all.txt index 0f19ad2bcb2..aab746ecfb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1616,7 +1616,7 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.9.1 +pydaikin==2.9.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 764a5a0c6df..3c95bcec7d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1195,7 +1195,7 @@ pycoolmasternet-async==0.1.5 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.9.1 +pydaikin==2.9.0 # homeassistant.components.deconz pydeconz==113 From 82b9a31ea7f6e8593e3050ded2d24aa3fd19b035 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 12 Jun 2023 21:28:28 +0200 Subject: [PATCH 217/857] Improve logging of mqtt discovery message errors (#94491) * Improve logging on mqtt discovery message errors * Create shared helper * Apply suggestion * Catch base class vol.Invalid --- homeassistant/components/mqtt/mixins.py | 29 +++++++++++++++++++++++-- tests/components/mqtt/test_discovery.py | 6 +---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 46744c4d65d..34b61d89c48 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -244,6 +244,20 @@ class SetupEntity(Protocol): """Define setup_entities type.""" +@callback +def async_handle_schema_error( + discovery_payload: MQTTDiscoveryPayload, err: vol.MultipleInvalid +) -> None: + """Help handling schema errors on MQTT discovery messages.""" + discovery_topic: str = discovery_payload.discovery_data[ATTR_DISCOVERY_TOPIC] + _LOGGER.error( + "Error '%s' when processing MQTT discovery message topic: '%s', message: '%s'", + err, + discovery_topic, + discovery_payload, + ) + + async def async_setup_entry_helper( hass: HomeAssistant, domain: str, @@ -269,8 +283,15 @@ async def async_setup_entry_helper( try: config: DiscoveryInfoType = discovery_schema(discovery_payload) await async_setup(config, discovery_data=discovery_data) + except vol.Invalid as err: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send( + hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None + ) + async_handle_schema_error(discovery_payload, err) except Exception: - discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] clear_discovery_hash(hass, discovery_hash) async_dispatcher_send( hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None @@ -1037,7 +1058,11 @@ class MqttEntity( async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> None: """Handle updated discovery message.""" - config: DiscoveryInfoType = self.config_schema()(discovery_payload) + try: + config: DiscoveryInfoType = self.config_schema()(discovery_payload) + except vol.Invalid as err: + async_handle_schema_error(discovery_payload, err) + return self._config = config self._setup_common_attributes_from_config(self._config) self._setup_from_config(self._config) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 8d3c43744fc..f35af9fb037 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1115,7 +1115,6 @@ async def test_discovery_expansion_2( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) -@pytest.mark.no_fail_on_log_exception async def test_discovery_expansion_3( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, @@ -1146,10 +1145,7 @@ async def test_discovery_expansion_3( assert hass.states.get("switch.DiscoveryExpansionTest1") is None # Make sure the malformed availability data does not trip up discovery by asserting # there are schema valdiation errors in the log - assert ( - "voluptuous.error.MultipleInvalid: expected a dictionary @ data['availability'][0]" - in caplog.text - ) + assert "expected a dictionary @ data['availability'][0]" in caplog.text async def test_discovery_expansion_without_encoding_and_value_template_1( From d51982f12fe7bc66896e6fcd35106ccf09ed3a9f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 12 Jun 2023 21:48:12 +0200 Subject: [PATCH 218/857] Add missing assert to test_async_remove_ignores_in_flight_polling (#94487) --- tests/helpers/test_entity.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 3ea820e684c..f1133e30483 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -602,10 +602,16 @@ async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> No ent.async_on_remove(lambda: result.append(1)) await platform.async_add_entities([ent]) assert hass.states.get("test.test").state == STATE_UNKNOWN + + # Remove the entity from the entity registry await ent.async_remove() assert len(result) == 1 assert hass.states.get("test.test") is None + + # Simulate an in-flight poll after the entity was removed ent.async_write_ha_state() + assert len(result) == 1 + assert hass.states.get("test.test") is None async def test_set_context(hass: HomeAssistant) -> None: From af6dac85841c1619a7a8e60fea786fdbedd1bd5e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 12 Jun 2023 21:48:49 +0200 Subject: [PATCH 219/857] Remove unnecessary condition from edl21 sensor (#94493) --- homeassistant/components/edl21/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index c2436c15057..f3152ce8230 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -340,7 +340,7 @@ class EDL21: ) else: entity_description = SENSORS.get(obis) - if entity_description and entity_description.name: + if entity_description: # self._name is only used for backwards YAML compatibility # This needs to be cleaned up when YAML support is removed device_name = self._name or DEFAULT_DEVICE_NAME From f931cc3d1c22a65c5b73a7beedb59205af8b3094 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 12 Jun 2023 21:50:23 +0200 Subject: [PATCH 220/857] Fix manual update for Command Line (#94433) Manual update command line --- .../components/command_line/binary_sensor.py | 8 +++ .../components/command_line/cover.py | 9 ++- .../components/command_line/sensor.py | 8 +++ .../components/command_line/switch.py | 9 ++- .../command_line/test_binary_sensor.py | 59 ++++++++++++++++++- tests/components/command_line/test_cover.py | 58 ++++++++++++++++++ tests/components/command_line/test_sensor.py | 56 ++++++++++++++++++ tests/components/command_line/test_switch.py | 59 +++++++++++++++++++ 8 files changed, 263 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 06aa58ca068..fb8f57b4d5a 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -31,6 +31,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .sensor import CommandSensorData @@ -185,3 +186,10 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): self._process_manual_data(value) self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 29236bbed08..ebbc8a9b30b 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -32,7 +32,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log @@ -224,6 +224,13 @@ class CommandCover(ManualTriggerEntity, CoverEntity): self._process_manual_data(payload) await self.async_update_ha_state(True) + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_open) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index b9dffd3ca45..c164c6636fa 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -36,6 +36,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import check_output_or_log @@ -216,6 +217,13 @@ class CommandSensor(ManualTriggerEntity, SensorEntity): self._process_manual_data(value) self.async_write_ha_state() + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) + class CommandSensorData: """The class for handling the data retrieval.""" diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 1a3dd39a342..4a33d8072d7 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -34,7 +34,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log @@ -240,6 +240,13 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): self._process_manual_data(payload) await self.async_update_ha_state(True) + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if await self._switch(self._command_on) and not self._command_state: diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index eb6b52a66be..9e97f053e07 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -12,7 +12,11 @@ from homeassistant import setup from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.command_line.binary_sensor import CommandBinarySensor from homeassistant.components.command_line.const import DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir @@ -252,3 +256,56 @@ async def test_updating_to_often( ) await asyncio.sleep(0.2) + + +async def test_updating_manually( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling manual updating using homeassistant udate_entity service.""" + await setup.async_setup_component(hass, HA_DOMAIN, {}) + called = [] + + class MockCommandBinarySensor(CommandBinarySensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", + side_effect=MockCommandBinarySensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "scan_interval": 10, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["binary_sensor.test"]}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(called) == 2 + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index d621d98c744..e0187b80f41 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -13,6 +13,10 @@ from homeassistant import config as hass_config, setup from homeassistant.components.command_line import DOMAIN from homeassistant.components.command_line.cover import CommandCover from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -378,3 +382,57 @@ async def test_updating_to_often( ) await asyncio.sleep(0.2) + + +async def test_updating_manually( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling manual updating using homeassistant udate_entity service.""" + await setup.async_setup_component(hass, HA_DOMAIN, {}) + called = [] + + class MockCommandCover(CommandCover): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.cover.CommandCover", + side_effect=MockCommandCover, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "cover": { + "command_state": "echo 1", + "value_template": "{{ value }}", + "name": "Test", + "scan_interval": 10, + } + } + ] + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(called) == 1 + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["cover.test"]}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(called) == 2 + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 244a1b992ce..b837f580862 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -11,7 +11,12 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN from homeassistant.components.command_line.sensor import CommandSensor +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir @@ -586,3 +591,54 @@ async def test_updating_to_often( ) await asyncio.sleep(0.2) + + +async def test_updating_manually( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling manual updating using homeassistant udate_entity service.""" + await setup.async_setup_component(hass, HA_DOMAIN, {}) + called = [] + + class MockCommandSensor(CommandSensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.sensor.CommandSensor", + side_effect=MockCommandSensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 1", + "scan_interval": 10, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["sensor.test"]}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(called) == 2 + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 88a87588375..e5331fbe7dd 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -14,6 +14,10 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN from homeassistant.components.command_line.switch import CommandSwitch +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, @@ -696,3 +700,58 @@ async def test_updating_to_often( ) await asyncio.sleep(0.2) + + +async def test_updating_manually( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling manual updating using homeassistant udate_entity service.""" + await setup.async_setup_component(hass, HA_DOMAIN, {}) + called = [] + + class MockCommandSwitch(CommandSwitch): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.switch.CommandSwitch", + side_effect=MockCommandSwitch, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "scan_interval": 10, + } + } + ] + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(called) == 1 + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["switch.test"]}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(called) == 2 + + await asyncio.sleep(0.2) From bd74f03d0b44be6bfa7b2d65b4e3d8c63236a007 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 12 Jun 2023 21:51:57 +0200 Subject: [PATCH 221/857] Fix reload service in Command Line (#94436) * Fix reload in Command Line * Add read new yaml --- .../components/command_line/__init__.py | 41 ++++++++++--- .../command_line/fixtures/configuration.yaml | 13 ++-- .../fixtures/configuration_empty.yaml | 0 tests/components/command_line/test_cover.py | 44 +------------ tests/components/command_line/test_init.py | 61 ++++++++++++++++++- 5 files changed, 101 insertions(+), 58 deletions(-) create mode 100644 tests/components/command_line/fixtures/configuration_empty.yaml diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 906e28052da..6f536bf4744 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -46,12 +46,15 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, + SERVICE_RELOAD, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN @@ -163,14 +166,39 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Command Line from yaml config.""" - command_line_config: list[dict[str, dict[str, Any]]] = config.get(DOMAIN, []) + + async def _reload_config(call: Event | ServiceCall) -> None: + """Reload Command Line.""" + reload_config = await async_integration_yaml_config(hass, "command_line") + reset_platforms = async_get_platforms(hass, "command_line") + for reset_platform in reset_platforms: + _LOGGER.debug("Reload resetting platform: %s", reset_platform.domain) + await reset_platform.async_reset() + if not reload_config: + return + await async_load_platforms(hass, reload_config.get(DOMAIN, []), reload_config) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + + await async_load_platforms(hass, config.get(DOMAIN, []), config) + + return True + + +async def async_load_platforms( + hass: HomeAssistant, + command_line_config: list[dict[str, dict[str, Any]]], + config: ConfigType, +) -> None: + """Load platforms from yaml.""" if not command_line_config: - return True + return _LOGGER.debug("Full config loaded: %s", command_line_config) load_coroutines: list[Coroutine[Any, Any, None]] = [] platforms: list[Platform] = [] + reload_configs: list[tuple] = [] for platform_config in command_line_config: for platform, _config in platform_config.items(): if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms: @@ -180,6 +208,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: platform_config, PLATFORM_MAPPING[platform], ) + reload_configs.append((PLATFORM_MAPPING[platform], _config)) load_coroutines.append( discovery.async_load_platform( hass, @@ -190,10 +219,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) - await async_setup_reload_service(hass, DOMAIN, platforms) - if load_coroutines: _LOGGER.debug("Loading platforms: %s", platforms) await asyncio.gather(*load_coroutines) - - return True diff --git a/tests/components/command_line/fixtures/configuration.yaml b/tests/components/command_line/fixtures/configuration.yaml index f210b640338..43e6f641966 100644 --- a/tests/components/command_line/fixtures/configuration.yaml +++ b/tests/components/command_line/fixtures/configuration.yaml @@ -1,6 +1,7 @@ -cover: - - platform: command_line - covers: - from_yaml: - command_state: "echo closed" - value_template: "{{ value }}" +command_line: + - "binary_sensor": + "name": "Test" + "command": "echo 1" + "payload_on": "1" + "payload_off": "0" + "command_timeout": 15 diff --git a/tests/components/command_line/fixtures/configuration_empty.yaml b/tests/components/command_line/fixtures/configuration_empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index e0187b80f41..ac0a33fc7a9 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from homeassistant import config as hass_config, setup +from homeassistant import setup from homeassistant.components.command_line import DOMAIN from homeassistant.components.command_line.cover import CommandCover from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL @@ -21,7 +21,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, - SERVICE_RELOAD, SERVICE_STOP_COVER, ) from homeassistant.core import HomeAssistant @@ -29,7 +28,7 @@ from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, get_fixture_path +from tests.common import async_fire_time_changed async def test_no_covers_platform_yaml( @@ -214,45 +213,6 @@ async def test_state_value(hass: HomeAssistant) -> None: assert entity_state.state == "closed" -@pytest.mark.parametrize( - "get_config", - [ - { - "command_line": [ - { - "cover": { - "command_state": "echo open", - "value_template": "{{ value }}", - "name": "Test", - } - } - ] - } - ], -) -async def test_reload(hass: HomeAssistant, load_yaml_integration: None) -> None: - """Verify we can reload command_line covers.""" - - entity_state = hass.states.get("cover.test") - assert entity_state - assert entity_state.state == "unknown" - - yaml_path = get_fixture_path("configuration.yaml", "command_line") - with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - "command_line", - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 1 - - assert not hass.states.get("cover.test") - assert hass.states.get("cover.from_yaml") - - @pytest.mark.parametrize( "get_config", [ diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py index 06d7b8c41dc..53f985961f3 100644 --- a/tests/components/command_line/test_init.py +++ b/tests/components/command_line/test_init.py @@ -2,12 +2,17 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch -from homeassistant.const import STATE_ON, STATE_OPEN +import pytest + +from homeassistant import config as hass_config +from homeassistant.components.command_line.const import DOMAIN +from homeassistant.const import SERVICE_RELOAD, STATE_ON, STATE_OPEN from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, get_fixture_path async def test_setup_config(hass: HomeAssistant, load_yaml_integration: None) -> None: @@ -25,3 +30,55 @@ async def test_setup_config(hass: HomeAssistant, load_yaml_integration: None) -> assert state_sensor.state == "5" assert state_cover.state == STATE_OPEN assert state_switch.state == STATE_ON + + +async def test_reload_service( + hass: HomeAssistant, load_yaml_integration: None, caplog: pytest.LogCaptureFixture +) -> None: + """Test reload serviice.""" + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + state_binary_sensor = hass.states.get("binary_sensor.test") + state_sensor = hass.states.get("sensor.test") + assert state_binary_sensor.state == STATE_ON + assert state_sensor.state == "5" + + caplog.clear() + + yaml_path = get_fixture_path("configuration.yaml", "command_line") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert "Loading config" in caplog.text + + state_binary_sensor = hass.states.get("binary_sensor.test") + state_sensor = hass.states.get("sensor.test") + assert state_binary_sensor.state == STATE_ON + assert not state_sensor + + caplog.clear() + + yaml_path = get_fixture_path("configuration_empty.yaml", "command_line") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + state_binary_sensor = hass.states.get("binary_sensor.test") + state_sensor = hass.states.get("sensor.test") + assert not state_binary_sensor + assert not state_sensor + + assert "Loading config" not in caplog.text From 8e0833811801597fb7b74af7e7a7b6897f8e8c58 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 12 Jun 2023 12:58:19 -0700 Subject: [PATCH 222/857] Bump google-nest-sdm to 2.2.5 (#94398) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 6af293aba97..dbb30ceb52a 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm", "nest"], "quality_scale": "platinum", - "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.4"] + "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index aab746ecfb8..3cd446e72db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -887,7 +887,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0rc2 # homeassistant.components.nest -google-nest-sdm==2.2.4 +google-nest-sdm==2.2.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c95bcec7d2..3918ef3f21e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -697,7 +697,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0rc2 # homeassistant.components.nest -google-nest-sdm==2.2.4 +google-nest-sdm==2.2.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 From f3ca3a8ee0156e06984991586da2b6fc43e35e5b Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 12 Jun 2023 21:34:09 -0400 Subject: [PATCH 223/857] Set default value for endpoint in zwave device automations (#94445) * Set default value for endpoint in zwave device automations * add test case --- .../components/zwave_js/device_action.py | 2 +- .../components/zwave_js/device_trigger.py | 2 +- .../components/zwave_js/test_device_action.py | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 20c37b5cbb6..18a3ccef7d8 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -101,7 +101,7 @@ RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER, - vol.Required(ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT, default=0): vol.Coerce(int), vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str), vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str), vol.Required(ATTR_VALUE): vol.Coerce(int), diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index a0ac70ccb31..da26e4f293e 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -161,7 +161,7 @@ BASE_VALUE_UPDATED_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), vol.Required(ATTR_PROPERTY): vol.Any(int, str), vol.Optional(ATTR_PROPERTY_KEY): vol.Any(None, vol.Coerce(int), str), - vol.Optional(ATTR_ENDPOINT): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_ENDPOINT, default=0): vol.Any(None, vol.Coerce(int)), vol.Optional(ATTR_FROM): VALUE_SCHEMA, vol.Optional(ATTR_TO): VALUE_SCHEMA, } diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index 97631c94501..ccb65c1d8fa 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -196,6 +196,21 @@ async def test_actions( "value": 1, }, }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_config_parameter_no_endpoint", + }, + "action": { + "domain": DOMAIN, + "type": "set_config_parameter", + "device_id": device.id, + "parameter": 1, + "bitmask": None, + "subtype": "3 (Beeper)", + "value": 1, + }, + }, ] }, ) @@ -245,6 +260,18 @@ async def test_actions( assert args[1] == 1 assert args[2] == 1 + with patch( + "homeassistant.components.zwave_js.services.async_set_config_parameter" + ) as mock_call: + hass.bus.async_fire("test_event_set_config_parameter_no_endpoint") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 3 + assert args[0].node_id == 13 + assert args[1] == 1 + assert args[2] == 1 + async def test_actions_multiple_calls( hass: HomeAssistant, From 5d8a50baf0fd58431c59d7caa7f53a497e8f0555 Mon Sep 17 00:00:00 2001 From: FFT Date: Tue, 13 Jun 2023 14:46:58 +0800 Subject: [PATCH 224/857] Change pyoppleio to pyoppleio-legacy (#88050) * Change pyoppleio to pyoppleio-310 (#75268) * [m] change opple component's dependency to a new working one --- homeassistant/components/opple/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opple/manifest.json b/homeassistant/components/opple/manifest.json index 9d87114c2d0..174907dfd0f 100644 --- a/homeassistant/components/opple/manifest.json +++ b/homeassistant/components/opple/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/opple", "iot_class": "local_polling", "loggers": ["pyoppleio"], - "requirements": ["pyoppleio==1.0.5"] + "requirements": ["pyoppleio-legacy==1.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3cd446e72db..2321d1ab143 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1883,7 +1883,7 @@ pyopenuv==2023.02.0 pyopnsense==0.2.0 # homeassistant.components.opple -pyoppleio==1.0.5 +pyoppleio-legacy==1.0.8 # homeassistant.components.opentherm_gw pyotgw==2.1.3 From fd9b273f46ac20f355c9a8f9135a19888e569c88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jun 2023 10:33:21 +0200 Subject: [PATCH 225/857] Bump dessant/lock-threads from 4.0.0 to 4.0.1 (#94523) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index c0593fa3a9a..2b5364fa950 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4.0.0 + - uses: dessant/lock-threads@v4.0.1 with: github-token: ${{ github.token }} issue-inactive-days: "30" From e8c87874b3eedc1f0204ca2e677efba93764f0e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jun 2023 22:36:25 -1000 Subject: [PATCH 226/857] Bump anyio to 3.7.0 (#94516) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f30433d0a79..f90f42d1430 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -100,7 +100,7 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.6.2 +anyio==3.7.0 h11==0.14.0 httpcore==0.17.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e71cdcf7bc1..089bc688f43 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -104,7 +104,7 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.6.2 +anyio==3.7.0 h11==0.14.0 httpcore==0.17.0 From 829ca7c567b84b17b45da32db7b5476c43deea51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jun 2023 22:36:56 -1000 Subject: [PATCH 227/857] Bump orjson to 3.9.1 (#94514) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f90f42d1430..330407f5f80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ janus==1.0.0 Jinja2==3.1.2 lru-dict==1.1.8 mutagen==1.46.0 -orjson==3.8.12 +orjson==3.9.1 paho-mqtt==1.6.1 Pillow==9.5.0 pip>=21.0,<23.2 diff --git a/pyproject.toml b/pyproject.toml index 60c534b8bf2..10932bbd50b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==40.0.2", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.12", + "orjson==3.9.1", "pip>=21.0,<23.2", "python-slugify==4.0.1", "PyYAML==6.0", diff --git a/requirements.txt b/requirements.txt index ee8b2d7821a..a51216b899f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.7.0 cryptography==40.0.2 pyOpenSSL==23.1.0 -orjson==3.8.12 +orjson==3.9.1 pip>=21.0,<23.2 python-slugify==4.0.1 PyYAML==6.0 From d2fa6435daa0241dab9dadc4bbc817d5b33d9071 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jun 2023 22:37:59 -1000 Subject: [PATCH 228/857] Bump lru-dict to 1.2.0 (#94513) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 330407f5f80..32d7999779b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 -lru-dict==1.1.8 +lru-dict==1.2.0 mutagen==1.46.0 orjson==3.9.1 paho-mqtt==1.6.1 diff --git a/pyproject.toml b/pyproject.toml index 10932bbd50b..247b63d688a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "home-assistant-bluetooth==1.10.0", "ifaddr==0.2.0", "Jinja2==3.1.2", - "lru-dict==1.1.8", + "lru-dict==1.2.0", "PyJWT==2.7.0", # PyJWT has loose dependency. We want the latest one. "cryptography==40.0.2", diff --git a/requirements.txt b/requirements.txt index a51216b899f..6d5c40777c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ httpx==0.24.1 home-assistant-bluetooth==1.10.0 ifaddr==0.2.0 Jinja2==3.1.2 -lru-dict==1.1.8 +lru-dict==1.2.0 PyJWT==2.7.0 cryptography==40.0.2 pyOpenSSL==23.1.0 From 8a1ca0a3f575457eab240fbf5bf7df86bd0a88e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jun 2023 22:38:26 -1000 Subject: [PATCH 229/857] Bump zeroconf to 0.66.0 (#94512) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 85cf503bb0d..1c3835f109e 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.64.0"] + "requirements": ["zeroconf==0.66.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 32d7999779b..1e19cdfea60 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.64.0 +zeroconf==0.66.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 2321d1ab143..ef7fad7b629 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2736,7 +2736,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.64.0 +zeroconf==0.66.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3918ef3f21e..10d80b0c308 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.64.0 +zeroconf==0.66.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 0d1bcd8a8f577ed7ca8ffe495fa6d15f31db26a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jun 2023 22:38:51 -1000 Subject: [PATCH 230/857] Bump httpcore to 0.17.2 (#94515) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1e19cdfea60..3019047966d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -102,7 +102,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==3.7.0 h11==0.14.0 -httpcore==0.17.0 +httpcore==0.17.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 089bc688f43..0bbbd97c926 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -106,7 +106,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==3.7.0 h11==0.14.0 -httpcore==0.17.0 +httpcore==0.17.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation From aab58ad89c4747f8e0984a05bf98b03812c419a4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 13 Jun 2023 04:52:55 -0400 Subject: [PATCH 231/857] Fix entity and device selector TypedDict's (#94510) --- homeassistant/helpers/selector.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 2e7df07cf04..afd38bf7636 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -189,8 +189,6 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str - entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] - filter: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class ActionSelectorConfig(TypedDict): @@ -546,14 +544,12 @@ class DateTimeSelector(Selector[DateTimeSelectorConfig]): return data -class DeviceSelectorConfig(TypedDict, total=False): +class DeviceSelectorConfig(DeviceFilterSelectorConfig, total=False): """Class to represent a device selector config.""" - integration: str - manufacturer: str - model: str entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] multiple: bool + filter: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] @SELECTORS.register("device") @@ -622,6 +618,7 @@ class EntitySelectorConfig(EntityFilterSelectorConfig, total=False): exclude_entities: list[str] include_entities: list[str] multiple: bool + filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @SELECTORS.register("entity") From 771a73c034503328a842e65a655189d155ea574d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 13 Jun 2023 11:16:43 +0200 Subject: [PATCH 232/857] Add missing callback decorator to event helpers (#94483) --- homeassistant/helpers/event.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c7e34ac2eda..b7254c5c347 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -412,6 +412,7 @@ def _async_entity_registry_updated_filter( @bind_hass +@callback def async_track_entity_registry_updated_event( hass: HomeAssistant, entity_ids: str | Iterable[str], @@ -463,6 +464,7 @@ def _async_dispatch_device_id_event( ) +@callback def async_track_device_registry_updated_event( hass: HomeAssistant, device_ids: str | Iterable[str], From 889f3c36fcf7b935c2e0f4b9c83ebccb70ba62d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 13 Jun 2023 11:41:53 +0200 Subject: [PATCH 233/857] Adjust default name in backup service calls to match documentation (#94468) --- homeassistant/components/hassio/__init__.py | 4 +++- tests/components/hassio/test_init.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 2ae4faa7878..8c7f86700e7 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -159,7 +159,9 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( SCHEMA_BACKUP_FULL = vol.Schema( { - vol.Optional(ATTR_NAME): cv.string, + vol.Optional( + ATTR_NAME, default=lambda: utcnow().strftime("%Y-%m-%d %H:%M:%S") + ): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, vol.Optional(ATTR_LOCATION): vol.All( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 9d83537859a..0dff261d864 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -483,6 +483,7 @@ async def test_service_register(hassio_env, hass: HomeAssistant) -> None: assert hass.services.has_service("hassio", "restore_partial") +@pytest.mark.freeze_time("2021-11-13 11:48:00") async def test_service_calls( hassio_env, hass: HomeAssistant, @@ -541,6 +542,7 @@ async def test_service_calls( assert aioclient_mock.call_count == 14 assert aioclient_mock.mock_calls[-1][2] == { + "name": "2021-11-13 11:48:00", "homeassistant": True, "addons": ["test"], "folders": ["ssl"], @@ -575,6 +577,7 @@ async def test_service_calls( "hassio", "backup_full", { + "name": "backup_name", "location": "backup_share", }, ) @@ -582,6 +585,7 @@ async def test_service_calls( assert aioclient_mock.call_count == 17 assert aioclient_mock.mock_calls[-1][2] == { + "name": "backup_name", "location": "backup_share", } @@ -596,6 +600,7 @@ async def test_service_calls( assert aioclient_mock.call_count == 18 assert aioclient_mock.mock_calls[-1][2] == { + "name": "2021-11-13 11:48:00", "location": None, } From 47995fc2740794cd2dc39f29514d83eb6d1561f8 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 13 Jun 2023 03:51:46 -0600 Subject: [PATCH 234/857] Bump pylitterbot to 2023.4.2 (#94301) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index d3dcf77f324..2a4a3447eb6 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.0"] + "requirements": ["pylitterbot==2023.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef7fad7b629..1b9d6e0fe8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1796,7 +1796,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.0 +pylitterbot==2023.4.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10d80b0c308..25abfcd18ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1324,7 +1324,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.0 +pylitterbot==2023.4.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 From b1bdd92383e370bf772b6a02133dbb12cdbcf69c Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Tue, 13 Jun 2023 10:38:56 -0400 Subject: [PATCH 235/857] Add unit inference for Amps and VA in APCUPSD integration (#94431) * Add unit inference for Amps and VA * Rename `init_integration` to `async_init_integration` for better consistency with HA naming style --- homeassistant/components/apcupsd/sensor.py | 2 ++ tests/components/apcupsd/__init__.py | 6 ++++-- tests/components/apcupsd/test_binary_sensor.py | 6 +++--- tests/components/apcupsd/test_init.py | 12 ++++++------ tests/components/apcupsd/test_sensor.py | 6 +++--- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 17168700f66..8b7034357df 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -430,7 +430,9 @@ INFERRED_UNITS = { " Percent": PERCENTAGE, " Volts": UnitOfElectricPotential.VOLT, " Ampere": UnitOfElectricCurrent.AMPERE, + " Amps": UnitOfElectricCurrent.AMPERE, " Volt-Ampere": UnitOfApparentPower.VOLT_AMPERE, + " VA": UnitOfApparentPower.VOLT_AMPERE, " Watts": UnitOfPower.WATT, " Hz": UnitOfFrequency.HERTZ, " C": UnitOfTemperature.CELSIUS, diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index f99b29c7bb7..f5c3f573030 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -26,6 +26,7 @@ MOCK_STATUS: Final = OrderedDict( ("LOADPCT", "14.0 Percent"), ("BCHARGE", "100.0 Percent"), ("TIMELEFT", "51.0 Minutes"), + ("NOMAPNT", "60.0 VA"), ("ITEMP", "34.6 C Internal"), ("MBATTCHG", "5 Percent"), ("MINTIMEL", "3 Minutes"), @@ -35,6 +36,7 @@ MOCK_STATUS: Final = OrderedDict( ("HITRANS", "139.0 Volts"), ("ALARMDEL", "30 Seconds"), ("BATTV", "13.7 Volts"), + ("OUTCURNT", "0.88 Amps"), ("LASTXFER", "Automatic or explicit self test"), ("NUMXFERS", "1"), ("XONBATT", "1970-01-01 00:00:00 0000"), @@ -74,7 +76,7 @@ MOCK_MINIMAL_STATUS: Final = OrderedDict( ) -async def init_integration( +async def async_init_integration( hass: HomeAssistant, host: str = "test", status=None ) -> MockConfigEntry: """Set up the APC UPS Daemon integration in HomeAssistant.""" @@ -95,7 +97,7 @@ async def init_integration( with patch("apcaccess.status.parse", return_value=status), patch( "apcaccess.status.get", return_value=b"" ): - await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/apcupsd/test_binary_sensor.py b/tests/components/apcupsd/test_binary_sensor.py index c00707b7ff1..6ba9a09f837 100644 --- a/tests/components/apcupsd/test_binary_sensor.py +++ b/tests/components/apcupsd/test_binary_sensor.py @@ -2,12 +2,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_STATUS, init_integration +from . import MOCK_STATUS, async_init_integration async def test_binary_sensor(hass: HomeAssistant) -> None: """Test states of binary sensor.""" - await init_integration(hass, status=MOCK_STATUS) + await async_init_integration(hass, status=MOCK_STATUS) registry = er.async_get(hass) state = hass.states.get("binary_sensor.ups_online_status") @@ -22,7 +22,7 @@ async def test_no_binary_sensor(hass: HomeAssistant) -> None: """Test binary sensor when STATFLAG is not available.""" status = MOCK_STATUS.copy() status.pop("STATFLAG") - await init_integration(hass, status=status) + await async_init_integration(hass, status=status) state = hass.states.get("binary_sensor.ups_online_status") assert state is None diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index eae5df9b0c1..6e00a382e79 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, init_integration +from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration from tests.common import MockConfigEntry @@ -19,7 +19,7 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No """Test a successful setup entry.""" # Minimal status does not contain "SERIALNO" field, which is used to determine the # unique ID of this integration. But, the integration should work fine without it. - await init_integration(hass, status=status) + await async_init_integration(hass, status=status) # Verify successful setup by querying the status sensor. state = hass.states.get("binary_sensor.ups_online_status") @@ -34,8 +34,8 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: status1 = MOCK_STATUS | {"LOADPCT": "15.0 Percent", "SERIALNO": "XXXXX1"} status2 = MOCK_STATUS | {"LOADPCT": "16.0 Percent", "SERIALNO": "XXXXX2"} entries = ( - await init_integration(hass, host="test1", status=status1), - await init_integration(hass, host="test2", status=status2), + await async_init_integration(hass, host="test1", status=status1), + await async_init_integration(hass, host="test2", status=status2), ) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 @@ -70,8 +70,8 @@ async def test_unload_remove(hass: HomeAssistant) -> None: """Test successful unload of entry.""" # Load two integrations from two mock hosts. entries = ( - await init_integration(hass, host="test1", status=MOCK_STATUS), - await init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), + await async_init_integration(hass, host="test1", status=MOCK_STATUS), + await async_init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), ) # Assert they are loaded. diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index a9f6820faa0..1b09e107682 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -16,12 +16,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_STATUS, init_integration +from . import MOCK_STATUS, async_init_integration async def test_sensor(hass: HomeAssistant) -> None: """Test states of sensor.""" - await init_integration(hass, status=MOCK_STATUS) + await async_init_integration(hass, status=MOCK_STATUS) registry = er.async_get(hass) # Test a representative string sensor. @@ -89,7 +89,7 @@ async def test_sensor(hass: HomeAssistant) -> None: async def test_sensor_disabled(hass: HomeAssistant) -> None: """Test sensor disabled by default.""" - await init_integration(hass) + await async_init_integration(hass) registry = er.async_get(hass) # Test a representative integration-disabled sensor. From 985fb3cd5d395832caefec5834465a76e39346ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Jun 2023 06:32:43 -1000 Subject: [PATCH 236/857] Bump yalexs-ble to 2.1.18 (#94547) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index eeaa5f6c622..ca4e799f16b 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.5.1", "yalexs-ble==2.1.17"] + "requirements": ["yalexs==1.5.1", "yalexs-ble==2.1.18"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 8aa795b970e..4822b2d2704 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.17"] + "requirements": ["yalexs-ble==2.1.18"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b9d6e0fe8e..f6c4892a8c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2709,7 +2709,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.17 +yalexs-ble==2.1.18 # homeassistant.components.august yalexs==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25abfcd18ce..649a2d4800b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.17 +yalexs-ble==2.1.18 # homeassistant.components.august yalexs==1.5.1 From 223394eaeec7eb8c582e567cfcf4a93f83fb4b37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Jun 2023 06:42:10 -1000 Subject: [PATCH 237/857] Bump bluetooth-data-tools to 1.0.0 (#94145) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8d936b7286f..25bd2651c6f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.2.0", - "bluetooth-data-tools==0.4.0", + "bluetooth-data-tools==1.0.0", "dbus-fast==1.86.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index fa18c14aa46..64e4b2dcce2 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==14.0.0", - "bluetooth-data-tools==0.4.0", + "bluetooth-data-tools==1.0.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 4716519ac18..7107a365d72 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble/", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==0.4.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.0.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index a19680ffa5c..37257e0a604 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble/", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==0.4.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.0.0", "led-ble==1.0.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3019047966d..48c947f62f3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.0.2 bleak==0.20.2 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.2.0 -bluetooth-data-tools==0.4.0 +bluetooth-data-tools==1.0.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==40.0.2 diff --git a/requirements_all.txt b/requirements_all.txt index f6c4892a8c3..9c984314de4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -540,7 +540,7 @@ bluetooth-auto-recovery==1.2.0 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==0.4.0 +bluetooth-data-tools==1.0.0 # homeassistant.components.bond bond-async==0.1.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 649a2d4800b..9d7275de26d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ bluetooth-auto-recovery==1.2.0 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==0.4.0 +bluetooth-data-tools==1.0.0 # homeassistant.components.bond bond-async==0.1.23 From 2406b235b436f591398914bf40e821cb7218cb58 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 13 Jun 2023 19:48:54 +0200 Subject: [PATCH 238/857] Name unnamed binary sensors by their device class (#92940) * Name unnamed binary sensors by their device class * Update type annotations * Fix loading of entity component translations * Add test * Update integrations * Set abode and rfxtrx binary_sensor name to None * Revert changes in homekit_controller --- .../components/abode/binary_sensor.py | 1 + homeassistant/components/aranet/sensor.py | 25 +++- .../components/august/binary_sensor.py | 49 ++++---- .../components/balboa/binary_sensor.py | 4 + .../components/binary_sensor/__init__.py | 7 ++ homeassistant/components/bond/button.py | 4 + homeassistant/components/emonitor/sensor.py | 4 +- .../components/enphase_envoy/sensor.py | 3 +- homeassistant/components/gree/switch.py | 8 +- homeassistant/components/huawei_lte/sensor.py | 4 + homeassistant/components/incomfort/sensor.py | 3 + homeassistant/components/isy994/switch.py | 16 ++- homeassistant/components/repetier/sensor.py | 5 +- .../components/rfxtrx/binary_sensor.py | 1 + homeassistant/components/shelly/entity.py | 12 ++ .../components/switchbot/binary_sensor.py | 1 - .../components/system_bridge/binary_sensor.py | 4 + .../components/system_bridge/sensor.py | 4 + homeassistant/components/tomorrowio/sensor.py | 4 + homeassistant/components/tradfri/sensor.py | 3 +- .../components/unifiprotect/entity.py | 7 +- homeassistant/components/wemo/sensor.py | 3 + homeassistant/components/zwave_js/entity.py | 2 + homeassistant/components/zwave_js/sensor.py | 4 +- homeassistant/helpers/entity.py | 36 +++++- homeassistant/helpers/entity_platform.py | 13 ++- tests/components/binary_sensor/test_init.py | 107 ++++++++++++++++++ 27 files changed, 287 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 402b636e5d6..a10dbc8e664 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -42,6 +42,7 @@ async def async_setup_entry( class AbodeBinarySensor(AbodeDevice, BinarySensorEntity): """A binary sensor implementation for Abode device.""" + _attr_name = None _device: ABBinarySensor @property diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 6ac27b1652b..90448b9c89d 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -1,6 +1,8 @@ """Support for Aranet sensors.""" from __future__ import annotations +from dataclasses import dataclass + from aranet4.client import Aranet4Advertisement from bleak.backends.device import BLEDevice @@ -33,43 +35,54 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN + +@dataclass +class AranetSensorEntityDescription(SensorEntityDescription): + """Class to describe an Aranet sensor entity.""" + + # PassiveBluetoothDataUpdate does not support UNDEFINED + # Restrict the type to satisfy the type checker and catch attempts + # to use UNDEFINED in the entity descriptions. + name: str | None = None + + SENSOR_DESCRIPTIONS = { - "temperature": SensorEntityDescription( + "temperature": AranetSensorEntityDescription( key="temperature", name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), - "humidity": SensorEntityDescription( + "humidity": AranetSensorEntityDescription( key="humidity", name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - "pressure": SensorEntityDescription( + "pressure": AranetSensorEntityDescription( key="pressure", name="Pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), - "co2": SensorEntityDescription( + "co2": AranetSensorEntityDescription( key="co2", name="Carbon Dioxide", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ), - "battery": SensorEntityDescription( + "battery": AranetSensorEntityDescription( key="battery", name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - "interval": SensorEntityDescription( + "interval": AranetSensorEntityDescription( key="update_interval", name="Update Interval", device_class=SensorDeviceClass.DURATION, diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index d380ee11834..c6f406a5094 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -5,7 +5,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import cast from yalexs.activity import ( ACTION_DOORBELL_CALL_MISSED, @@ -104,7 +103,16 @@ def _native_datetime() -> datetime: @dataclass -class AugustRequiredKeysMixin: +class AugustBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes August binary_sensor entity.""" + + # AugustBinarySensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + + +@dataclass +class AugustDoorbellRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[AugustData, DoorbellDetail], bool] @@ -112,41 +120,45 @@ class AugustRequiredKeysMixin: @dataclass -class AugustBinarySensorEntityDescription( - BinarySensorEntityDescription, AugustRequiredKeysMixin +class AugustDoorbellBinarySensorEntityDescription( + BinarySensorEntityDescription, AugustDoorbellRequiredKeysMixin ): """Describes August binary_sensor entity.""" + # AugustDoorbellBinarySensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" -SENSOR_TYPE_DOOR = BinarySensorEntityDescription( + +SENSOR_TYPE_DOOR = AugustBinarySensorEntityDescription( key="door_open", name="Open", ) -SENSOR_TYPES_DOORBELL: tuple[AugustBinarySensorEntityDescription, ...] = ( - AugustBinarySensorEntityDescription( +SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = ( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_ding", name="Ding", device_class=BinarySensorDeviceClass.OCCUPANCY, value_fn=_retrieve_ding_state, is_time_based=True, ), - AugustBinarySensorEntityDescription( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION, value_fn=_retrieve_motion_state, is_time_based=True, ), - AugustBinarySensorEntityDescription( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_image_capture", name="Image Capture", icon="mdi:file-image", value_fn=_retrieve_image_capture_state, is_time_based=True, ), - AugustBinarySensorEntityDescription( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_online", name="Online", device_class=BinarySensorDeviceClass.CONNECTIVITY, @@ -199,7 +211,10 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.DOOR def __init__( - self, data: AugustData, device: Lock, description: BinarySensorEntityDescription + self, + data: AugustData, + device: Lock, + description: AugustBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(data, device) @@ -207,9 +222,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): self._data = data self._device = device self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = ( - f"{self._device_id}_{cast(str, description.name).lower()}" - ) + self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" @callback def _update_from_data(self): @@ -243,13 +256,13 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August binary sensor.""" - entity_description: AugustBinarySensorEntityDescription + entity_description: AugustDoorbellBinarySensorEntityDescription def __init__( self, data: AugustData, device: Doorbell, - description: AugustBinarySensorEntityDescription, + description: AugustDoorbellBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(data, device) @@ -257,9 +270,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self._check_for_off_update_listener = None self._data = data self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = ( - f"{self._device_id}_{cast(str, description.name).lower()}" - ) + self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" @callback def _update_from_data(self): diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 11a0cae0a01..9f363746a8f 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -47,6 +47,10 @@ class BalboaBinarySensorEntityDescription( ): """A class that describes Balboa binary sensor entities.""" + # BalboaBinarySensorEntity does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + FILTER_CYCLE_ICONS = ("mdi:sync", "mdi:sync-off") BINARY_SENSOR_DESCRIPTIONS = ( diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index d99f569ed59..1c2d6d779fb 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -190,6 +190,13 @@ class BinarySensorEntity(Entity): _attr_is_on: bool | None = None _attr_state: None = None + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For binary sensors this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 32b76c6fcae..1109cf0d311 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -35,6 +35,10 @@ class BondButtonEntityDescription( ): """Class to describe a Bond Button entity.""" + # BondEntity does not support UNDEFINED, + # restrict the type to str | None + name: str | None = None + STOP_BUTTON = BondButtonEntityDescription( key=Action.STOP, diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index dc7159001d8..0cf4f0f2346 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -80,7 +80,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): mac_address = self.emonitor_status.network.mac_address device_name = name_short_mac(mac_address[-6:]) label = self.channel_data.label or f"{device_name} {channel_number}" - if description.name: + if description.name is not UNDEFINED: self._attr_name = f"{label} {description.name}" self._attr_unique_id = f"{mac_address}_{channel_number}_{description.key}" else: diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 2870a61d9a0..44ffbcdb497 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -168,7 +169,7 @@ class EnvoyInverter(CoordinatorEntity, SensorEntity): """Initialize Envoy inverter entity.""" self.entity_description = description self._serial_number = serial_number - if description.name: + if description.name is not UNDEFINED: self._attr_name = ( f"{envoy_name} Inverter {serial_number} {description.name}" ) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 01f98b996dd..68c11ad6e1f 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any from greeclimate.device import Device @@ -33,6 +33,10 @@ class GreeRequiredKeysMixin: class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): """Describes Gree switch entity.""" + # GreeSwitch does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" @@ -130,7 +134,7 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, cast(str, description.name)) + super().__init__(coordinator, description.name) @property def is_on(self) -> bool: diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index f63cc4aac39..133b569c751 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -117,6 +117,10 @@ class HuaweiSensorGroup: class HuaweiSensorEntityDescription(SensorEntityDescription): """Class describing Huawei LTE sensor entities.""" + # HuaweiLteSensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + format_fn: Callable[[str], tuple[StateType, str | None]] = format_default icon_fn: Callable[[StateType], str] | None = None device_class_fn: Callable[[StateType], SensorDeviceClass | None] | None = None diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index b1b391aaaab..9e8cabbe253 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -28,6 +28,9 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" extra_key: str | None = None + # IncomfortSensor does not support UNDEFINED or None, + # restrict the type to str + name: str = "" SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index a150c052678..62ae375736d 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -1,6 +1,7 @@ """Support for ISY switches.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any from pyisy.constants import ( @@ -22,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -30,6 +31,15 @@ from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity from .models import IsyData +@dataclass +class ISYSwitchEntityDescription(SwitchEntityDescription): + """Describes IST switch.""" + + # ISYEnableSwitchEntity does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -53,7 +63,7 @@ async def async_setup_entry( for node, control in isy_data.aux_properties[Platform.SWITCH]: # Currently only used for enable switches, will need to be updated for # NS support by making sure control == TAG_ENABLED - description = SwitchEntityDescription( + description = ISYSwitchEntityDescription( key=control, device_class=SwitchDeviceClass.SWITCH, name=control.title(), @@ -135,7 +145,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): node: Node, control: str, unique_id: str, - description: EntityDescription, + description: ISYSwitchEntityDescription, device_info: DeviceInfo | None, ) -> None: """Initialize the ISY Aux Control Number entity.""" diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 24c97c74b0f..784555e6c73 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL, RepetierSensorEntityDescription @@ -45,7 +45,8 @@ def setup_platform( sensor_type = info["sensor_type"] temp_id = info["temp_id"] description = SENSOR_TYPES[sensor_type] - name = f"{info['name']}{description.name or ''}" + name_suffix = "" if description.name is UNDEFINED else description.name + name = f"{info['name']}{name_suffix}" if temp_id is not None: _LOGGER.debug("%s Temp_id: %s", sensor_type, temp_id) name = f"{name}{temp_id}" diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index b729138f73e..03cf65a49ff 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -130,6 +130,7 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): """ _attr_force_update = True + _attr_name = None def __init__( self, diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 50d41899800..daffcb006d1 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -275,6 +275,10 @@ def async_setup_entry_rest( class BlockEntityDescription(EntityDescription): """Class to describe a BLOCK entity.""" + # BlockEntity does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + icon_fn: Callable[[dict], str] | None = None unit_fn: Callable[[dict], str] | None = None value: Callable[[Any], Any] = lambda val: val @@ -295,6 +299,10 @@ class RpcEntityRequiredKeysMixin: class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): """Class to describe a RPC entity.""" + # BlockEntity does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + value: Callable[[Any, Any], Any] | None = None available: Callable[[dict], bool] | None = None removal_condition: Callable[[dict, dict, str], bool] | None = None @@ -307,6 +315,10 @@ class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): class RestEntityDescription(EntityDescription): """Class to describe a REST entity.""" + # BlockEntity does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + value: Callable[[dict, Any], Any] | None = None extra_state_attributes: Callable[[dict], dict | None] | None = None diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 0a0cf40ca8b..cb11c64f16a 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -94,7 +94,6 @@ class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity): self._sensor = binary_sensor self._attr_unique_id = f"{coordinator.base_unique_id}-{binary_sensor}" self.entity_description = BINARY_SENSOR_TYPES[binary_sensor] - self._attr_name = self.entity_description.name @property def is_on(self) -> bool: diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index bb83d90235f..5c23c3110d8 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -23,6 +23,10 @@ from .coordinator import SystemBridgeDataUpdateCoordinator class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing System Bridge binary sensor entities.""" + # SystemBridgeBinarySensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + value: Callable = round diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index a6bf29ac546..ede94863af4 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -46,6 +46,10 @@ PIXELS: Final = "px" class SystemBridgeSensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" + # SystemBridgeSensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + value: Callable = round diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 2b7d466d2f0..046dc79f2c6 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -72,6 +72,10 @@ from .const import ( class TomorrowioSensorEntityDescription(SensorEntityDescription): """Describes a Tomorrow.io sensor entity.""" + # TomorrowioSensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + unit_imperial: str | None = None unit_metric: str | None = None multiplication_factor: Callable[[float], float] | float | None = None diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 81cce80aa73..1b3839ce2d7 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED from .base_class import TradfriBaseEntity from .const import ( @@ -202,7 +203,7 @@ class TradfriSensor(TradfriBaseEntity, SensorEntity): self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" - if description.name: + if description.name is not UNDEFINED: self._attr_name = f"{self._attr_name}: {description.name}" self._refresh() # Set initial state diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index e123a4bf1bc..15bd17554ad 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -23,6 +23,7 @@ from pyunifiprotect.data import ( from homeassistant.core import callback import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.typing import UNDEFINED from .const import ( ATTR_EVENT_ID, @@ -201,7 +202,11 @@ class ProtectDeviceEntity(Entity): else: self.entity_description = description self._attr_unique_id = f"{self.device.mac}_{description.key}" - name = description.name or "" + name = ( + description.name + if description.name and description.name is not UNDEFINED + else "" + ) self._attr_name = f"{self.device.display_name} {name.title()}" if isinstance(description, ProtectRequiredKeysMixin): self._async_get_ufp_enabled = description.get_ufp_enabled diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 634d4a9e41d..15e396cc660 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -28,6 +28,9 @@ from .wemo_device import DeviceCoordinator class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" + # AttributeSensor does not support UNDEFINED, + # restrict the type to str | None. + name: str | None = None state_conversion: Callable[[StateType], StateType] | None = None unique_id_suffix: str | None = None diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index ba086b3e8bf..2a0f5ff4e72 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -14,6 +14,7 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.typing import UNDEFINED from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -161,6 +162,7 @@ class ZWaveBaseEntity(Entity): hasattr(self, "entity_description") and self.entity_description and self.entity_description.name + and self.entity_description.name is not UNDEFINED ): name = self.entity_description.name diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 316c0b81eeb..f3568588287 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -48,7 +48,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType from .const import ( ATTR_METER_TYPE, @@ -610,7 +610,7 @@ class ZwaveSensor(ZWaveBaseEntity, SensorEntity): # Entity class attributes self._attr_force_update = True - if not entity_description.name: + if not entity_description.name or entity_description.name is UNDEFINED: self._attr_name = self.generate_name(include_value_name=True) @property diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 68f64f0c749..cb947ac7604 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -44,7 +44,7 @@ from .event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) -from .typing import StateType +from .typing import UNDEFINED, StateType, UndefinedType if TYPE_CHECKING: from .entity_platform import EntityPlatform @@ -222,7 +222,7 @@ class EntityDescription: force_update: bool = False icon: str | None = None has_entity_name: bool = False - name: str | None = None + name: str | UndefinedType | None = UNDEFINED translation_key: str | None = None unit_of_measurement: str | None = None @@ -328,6 +328,22 @@ class Entity(ABC): return self.entity_description.has_entity_name return False + def _device_class_name(self) -> str | None: + """Return a translated name of the entity based on its device class.""" + assert self.platform + if not self.has_entity_name: + return None + device_class_key = self.device_class or "_" + name_translation_key = ( + f"component.{self.platform.domain}.entity_component." + f"{device_class_key}.name" + ) + return self.platform.component_translations.get(name_translation_key) + + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class.""" + return False + @property def name(self) -> str | None: """Return the name of the entity.""" @@ -338,11 +354,21 @@ class Entity(ABC): f"component.{self.platform.platform_name}.entity.{self.platform.domain}" f".{self.translation_key}.name" ) - if name_translation_key in self.platform.entity_translations: - name: str = self.platform.entity_translations[name_translation_key] + if name_translation_key in self.platform.platform_translations: + name: str = self.platform.platform_translations[name_translation_key] return name if hasattr(self, "entity_description"): - return self.entity_description.name + description_name = self.entity_description.name + if description_name is UNDEFINED and self._default_to_device_class_name(): + return self._device_class_name() + if description_name is not UNDEFINED: + return description_name + return None + + # The entity has no name set by _attr_name, translation_key or entity_description + # Check if the entity should be named by its device class + if self._default_to_device_class_name(): + return self._device_class_name() return None @property diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index f7793a4329c..ddc741b7d35 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -126,7 +126,8 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} - self.entity_translations: dict[str, Any] = {} + self.component_translations: dict[str, Any] = {} + self.platform_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -295,7 +296,15 @@ class EntityPlatform: full_name = f"{self.domain}.{self.platform_name}" try: - self.entity_translations = await translation.async_get_translations( + self.component_translations = await translation.async_get_translations( + hass, hass.config.language, "entity_component", {self.domain} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", self.domain, exc_info=err + ) + try: + self.platform_translations = await translation.async_get_translations( hass, hass.config.language, "entity", {self.platform_name} ) except Exception as err: # pylint: disable=broad-exception-caught diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 79596d95d98..df377cd09d1 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -1,8 +1,25 @@ """The tests for the Binary sensor component.""" +from collections.abc import Generator from unittest import mock +import pytest + from homeassistant.components import binary_sensor +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" def test_state() -> None: @@ -19,3 +36,93 @@ def test_state() -> None: new=True, ): assert binary_sensor.BinarySensorEntity().state == STATE_ON + + +class STTFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, STTFlow): + yield + + +async def test_name(hass: HomeAssistant) -> None: + """Test binary sensor name.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup( + config_entry, binary_sensor.DOMAIN + ) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed binary sensor without device class -> no name + entity1 = binary_sensor.BinarySensorEntity() + entity1.entity_id = "binary_sensor.test1" + + # Unnamed binary sensor with device class but has_entity_name False -> no name + entity2 = binary_sensor.BinarySensorEntity() + entity2.entity_id = "binary_sensor.test2" + entity2._attr_device_class = binary_sensor.BinarySensorDeviceClass.BATTERY + + # Unnamed binary sensor with device class and has_entity_name True -> named + entity3 = binary_sensor.BinarySensorEntity() + entity3.entity_id = "binary_sensor.test3" + entity3._attr_device_class = binary_sensor.BinarySensorDeviceClass.BATTERY + entity3._attr_has_entity_name = True + + # Unnamed binary sensor with device class and has_entity_name True -> named + entity4 = binary_sensor.BinarySensorEntity() + entity4.entity_id = "binary_sensor.test4" + entity4.entity_description = binary_sensor.BinarySensorEntityDescription( + "test", + binary_sensor.BinarySensorDeviceClass.BATTERY, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{binary_sensor.DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state.attributes == {} + + state = hass.states.get(entity2.entity_id) + assert state.attributes == {"device_class": "battery"} + + state = hass.states.get(entity3.entity_id) + assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} + + state = hass.states.get(entity4.entity_id) + assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} From ddf004d5c7903f1317b61154d079a9f42d92f44f Mon Sep 17 00:00:00 2001 From: Chris Phillips Date: Tue, 13 Jun 2023 12:37:30 -0700 Subject: [PATCH 239/857] Bump russound_rio to 1.0.0 (#94500) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 70d519c16bd..4f35bf69736 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/russound_rio", "iot_class": "local_push", "loggers": ["russound_rio"], - "requirements": ["russound-rio==0.1.8"] + "requirements": ["russound-rio==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c984314de4..e5b9a2ab6d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2307,7 +2307,7 @@ rpi-bad-power==0.1.0 rtsp-to-webrtc==0.5.1 # homeassistant.components.russound_rio -russound-rio==0.1.8 +russound-rio==1.0.0 # homeassistant.components.russound_rnet russound==0.1.9 From 08262f480b82ef9d09b3a36618a19ac4c7197963 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 13 Jun 2023 13:37:56 -0600 Subject: [PATCH 240/857] Bump `regenmaschine` to 2023.06.0 (#94554) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index 574ca3d7f43..dabae5ff8c6 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["regenmaschine"], - "requirements": ["regenmaschine==2023.05.1"], + "requirements": ["regenmaschine==2023.06.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index e5b9a2ab6d5..a2eab46ffe0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2259,7 +2259,7 @@ rapt-ble==0.1.1 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2023.05.1 +regenmaschine==2023.06.0 # homeassistant.components.renault renault-api==0.1.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d7275de26d..f6790321d0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1646,7 +1646,7 @@ radiotherm==2.1.0 rapt-ble==0.1.1 # homeassistant.components.rainmachine -regenmaschine==2023.05.1 +regenmaschine==2023.06.0 # homeassistant.components.renault renault-api==0.1.13 From d5be77b53d6e0cf4f4f758f1618c5e4de4051534 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 13 Jun 2023 22:10:56 +0200 Subject: [PATCH 241/857] Update sentry-sdk to 1.25.1 (#94374) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index d684ee345b1..336c1cbc7ef 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.23.1"] + "requirements": ["sentry-sdk==1.25.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a2eab46ffe0..16404f70007 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2358,7 +2358,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.23.1 +sentry-sdk==1.25.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6790321d0b..5d2878d4214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1715,7 +1715,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.23.1 +sentry-sdk==1.25.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 66400fca0d14e30820df52cf97db6f6ac510ddb6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 13 Jun 2023 22:27:14 +0200 Subject: [PATCH 242/857] Update Home Assistant base image to 2023.06.0 (#94556) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 11b60a66295..b32aa38dff6 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.05.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.05.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.05.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.05.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.05.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.06.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.06.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.06.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.06.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.06.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 508cd7ef7e3048c4bf76d4042d2c108c047a4540 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 14 Jun 2023 02:07:03 -0500 Subject: [PATCH 243/857] Fix failed recovery in roku (#94572) --- homeassistant/components/roku/__init__.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 7f922c2eea5..583d26a4a5b 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -22,13 +22,11 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Roku from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - if not (coordinator := hass.data[DOMAIN].get(entry.entry_id)): - coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) - hass.data[DOMAIN][entry.entry_id] = coordinator - + coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -36,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok From 6a75f69e0f19285c8909bf34dca9dbf97fe86ea8 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 14 Jun 2023 02:07:24 -0500 Subject: [PATCH 244/857] Fix failed recovery in ipp (#94573) --- homeassistant/components/ipp/__init__.py | 26 ++++++++++-------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 42dc2b8d93b..9df377b939a 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -19,21 +19,18 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IPP from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - if not (coordinator := hass.data[DOMAIN].get(entry.entry_id)): - # Create IPP instance for this entry - coordinator = IPPDataUpdateCoordinator( - hass, - host=entry.data[CONF_HOST], - port=entry.data[CONF_PORT], - base_path=entry.data[CONF_BASE_PATH], - tls=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], - ) - hass.data[DOMAIN][entry.entry_id] = coordinator - + coordinator = IPPDataUpdateCoordinator( + hass, + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + base_path=entry.data[CONF_BASE_PATH], + tls=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -41,7 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok From bd156bb129a3eaca8f1748d966c1869aa6546f0f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 10:26:54 +0200 Subject: [PATCH 245/857] Improve multipan debug logging (#94580) --- .../silabs_multiprotocol_addon.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 34ab9a3cedb..c5f7049e54f 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -96,19 +96,29 @@ class MultiprotocolAddonManager(AddonManager): ) -> None: """Register a multipan platform.""" self._platforms[integration_domain] = platform - if self._channel is not None or not await platform.async_using_multipan(hass): + + channel = await platform.async_get_channel(hass) + using_multipan = await platform.async_using_multipan(hass) + + _LOGGER.info( + "Registering new multipan platform '%s', using multipan: %s, channel: %s", + integration_domain, + using_multipan, + channel, + ) + + if self._channel is not None or not using_multipan: return - new_channel = await platform.async_get_channel(hass) - if new_channel is None: + if channel is None: return _LOGGER.info( "Setting multipan channel to %s (source: '%s')", - new_channel, + channel, integration_domain, ) - self.async_set_channel(new_channel) + self.async_set_channel(channel) async def async_change_channel( self, channel: int, delay: float From 4ac2dd3de74fdffc9106284ca61321c2798c31c1 Mon Sep 17 00:00:00 2001 From: dupondje Date: Wed, 14 Jun 2023 11:38:47 +0200 Subject: [PATCH 246/857] Bump python devcontainer (#94540) --- Dockerfile.dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index de49bb77f12..857ccfa3997 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.10 +FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.11 SHELL ["/bin/bash", "-o", "pipefail", "-c"] From e5b2801f5bfb4c2819eaafd920a2a88b694e9366 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 14:21:34 +0200 Subject: [PATCH 247/857] Fix ZHA tests (#94588) --- tests/components/zha/test_config_flow.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 7c0d3eac2a9..48c45dd241d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -48,6 +48,19 @@ def disable_platform_only(): yield +@pytest.fixture(autouse=True) +def mock_multipan_platform(): + """Mock the multipan platform.""" + with patch( + "homeassistant.components.zha.silabs_multiprotocol.async_get_channel", + return_value=None, + ), patch( + "homeassistant.components.zha.silabs_multiprotocol.async_using_multipan", + return_value=False, + ): + yield + + @pytest.fixture(autouse=True) def reduce_reconnect_timeout(): """Reduces reconnect timeout to speed up tests.""" From 1b8c72e6443b92e4112844067aec2866bf4f459f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 14:55:50 +0200 Subject: [PATCH 248/857] Remove legacy STT provider from the demo integration (#94585) --- homeassistant/components/demo/stt.py | 56 ---------------------------- tests/components/demo/test_stt.py | 37 ++++++++---------- 2 files changed, 15 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 6458bf47397..8cbc287b71d 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -9,7 +9,6 @@ from homeassistant.components.stt import ( AudioCodecs, AudioFormats, AudioSampleRates, - Provider, SpeechMetadata, SpeechResult, SpeechResultState, @@ -18,20 +17,10 @@ from homeassistant.components.stt import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_LANGUAGES = ["en", "de"] -async def async_get_engine( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> Provider: - """Set up Demo speech component.""" - return DemoProvider() - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -86,48 +75,3 @@ class DemoProviderEntity(SpeechToTextEntity): pass return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) - - -class DemoProvider(Provider): - """Demo speech API provider.""" - - @property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return SUPPORT_LANGUAGES - - @property - def supported_formats(self) -> list[AudioFormats]: - """Return a list of supported formats.""" - return [AudioFormats.WAV] - - @property - def supported_codecs(self) -> list[AudioCodecs]: - """Return a list of supported codecs.""" - return [AudioCodecs.PCM] - - @property - def supported_bit_rates(self) -> list[AudioBitRates]: - """Return a list of supported bit rates.""" - return [AudioBitRates.BITRATE_16] - - @property - def supported_sample_rates(self) -> list[AudioSampleRates]: - """Return a list of supported sample rates.""" - return [AudioSampleRates.SAMPLERATE_16000, AudioSampleRates.SAMPLERATE_44100] - - @property - def supported_channels(self) -> list[AudioChannels]: - """Return a list of supported channels.""" - return [AudioChannels.CHANNEL_STEREO] - - async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] - ) -> SpeechResult: - """Process an audio stream to STT service.""" - - # Read available data - async for _ in stream: - pass - - return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/tests/components/demo/test_stt.py b/tests/components/demo/test_stt.py index 5d4242844ee..6ce25135ae0 100644 --- a/tests/components/demo/test_stt.py +++ b/tests/components/demo/test_stt.py @@ -4,42 +4,38 @@ from unittest.mock import patch import pytest -from homeassistant.components import stt from homeassistant.components.demo import DOMAIN as DEMO_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @pytest.fixture -async def setup_legacy_platform(hass: HomeAssistant) -> None: - """Set up legacy demo platform.""" - assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "demo"}}) - await hass.async_block_till_done() - - -@pytest.fixture -async def setup_config_entry(hass: HomeAssistant) -> None: - """Set up demo component from config entry.""" - config_entry = MockConfigEntry(domain=DEMO_DOMAIN) - config_entry.add_to_hass(hass) +async def stt_only(hass: HomeAssistant) -> None: + """Enable only the stt platform.""" with patch( "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", [Platform.STT], ): - assert await hass.config_entries.async_setup(config_entry.entry_id) + yield + + +@pytest.fixture(autouse=True) +async def setup_config_entry(hass: HomeAssistant, stt_only) -> None: + """Set up demo component from config entry.""" + config_entry = MockConfigEntry(domain=DEMO_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() -@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_settings(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() - response = await client.get("/api/stt/demo") + response = await client.get("/api/stt/stt.demo_stt") response_data = await response.json() assert response.status == HTTPStatus.OK @@ -53,22 +49,20 @@ async def test_demo_settings(hass_client: ClientSessionGenerator) -> None: } -@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_speech_no_metadata(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() - response = await client.post("/api/stt/demo", data=b"Test") + response = await client.post("/api/stt/stt.demo_stt", data=b"Test") assert response.status == HTTPStatus.BAD_REQUEST -@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_speech_wrong_metadata(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() response = await client.post( - "/api/stt/demo", + "/api/stt/stt.demo_stt", headers={ "X-Speech-Content": ( "format=wav; codec=pcm; sample_rate=8000; bit_rate=16; channel=1;" @@ -80,13 +74,12 @@ async def test_demo_speech_wrong_metadata(hass_client: ClientSessionGenerator) - assert response.status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE -@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_speech(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() response = await client.post( - "/api/stt/demo", + "/api/stt/stt.demo_stt", headers={ "X-Speech-Content": ( "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=2;" From 9a3077d64abf9540b6625b2e1e05371bdac8aa92 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 16:50:35 +0200 Subject: [PATCH 249/857] Always setup demo platforms with device support from config entry (#94586) * Always setup demo platforms with device support from config entry * Adjust test fixutres * Update tests depending on the demo integration --- homeassistant/components/demo/__init__.py | 6 +- .../components/demo/binary_sensor.py | 30 ++----- homeassistant/components/demo/button.py | 38 ++++----- homeassistant/components/demo/camera.py | 23 ++---- homeassistant/components/demo/climate.py | 43 +++------- homeassistant/components/demo/cover.py | 28 ++----- homeassistant/components/demo/date.py | 24 ++---- homeassistant/components/demo/datetime.py | 24 ++---- homeassistant/components/demo/light.py | 39 +++------- homeassistant/components/demo/number.py | 25 ++---- homeassistant/components/demo/select.py | 26 ++----- homeassistant/components/demo/sensor.py | 34 +++----- homeassistant/components/stt/legacy.py | 2 +- tests/components/calendar/test_recorder.py | 13 +++- tests/components/camera/conftest.py | 11 +++ tests/components/climate/test_recorder.py | 15 +++- .../components/config/test_config_entries.py | 4 +- tests/components/demo/conftest.py | 12 +++ tests/components/demo/test_button.py | 14 +++- tests/components/demo/test_camera.py | 14 +++- tests/components/demo/test_climate.py | 14 +++- tests/components/demo/test_cover.py | 14 +++- tests/components/demo/test_date.py | 16 +++- tests/components/demo/test_datetime.py | 16 +++- tests/components/demo/test_fan.py | 2 +- tests/components/demo/test_geo_location.py | 2 +- tests/components/demo/test_humidifier.py | 2 +- tests/components/demo/test_light.py | 16 +++- tests/components/demo/test_lock.py | 2 +- tests/components/demo/test_media_player.py | 5 ++ tests/components/demo/test_notify.py | 5 ++ tests/components/demo/test_number.py | 15 +++- tests/components/demo/test_remote.py | 2 +- tests/components/demo/test_select.py | 15 +++- tests/components/demo/test_sensor.py | 12 +++ tests/components/demo/test_siren.py | 2 +- tests/components/demo/test_switch.py | 2 +- tests/components/demo/test_text.py | 2 +- tests/components/demo/test_time.py | 2 +- tests/components/demo/test_update.py | 2 +- tests/components/demo/test_vacuum.py | 2 +- tests/components/demo/test_water_heater.py | 2 +- tests/components/demo/test_weather.py | 2 +- tests/components/emulated_hue/test_hue_api.py | 26 +++++-- tests/components/fan/test_recorder.py | 15 +++- .../google_assistant/test_diagnostics.py | 15 +++- .../google_assistant/test_google_assistant.py | 78 ++++++------------- .../google_assistant/test_smart_home.py | 39 +++++++++- tests/components/light/test_recorder.py | 15 +++- .../components/media_player/test_recorder.py | 15 +++- tests/components/number/test_recorder.py | 15 +++- tests/components/select/test_recorder.py | 15 +++- tests/components/text/test_recorder.py | 15 +++- tests/components/tts/test_notify.py | 15 +++- 54 files changed, 491 insertions(+), 351 deletions(-) diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index a84d7bf4f0b..48ee7cf852f 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -63,9 +63,6 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" - if DOMAIN not in config: - return True - if not hass.config_entries.async_entries(DOMAIN): hass.async_create_task( hass.config_entries.flow.async_init( @@ -73,6 +70,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) + if DOMAIN not in config: + return True + # Set up demo platforms for platform in COMPONENTS_WITH_DEMO_PLATFORM: hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index ee718e85cc0..9f808ae1f61 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -9,18 +9,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo binary sensor platform.""" + """Set up the demo binary sensor platform.""" async_add_entities( [ DemoBinarySensor( @@ -36,42 +34,30 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoBinarySensor(BinarySensorEntity): """representation of a Demo binary sensor.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: bool, device_class: BinarySensorDeviceClass, ) -> None: """Initialize the demo sensor.""" self._unique_id = unique_id - self._attr_name = name + self._attr_name = None self._state = state self._attr_device_class = device_class - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - name=self.name, + name=device_name, ) @property diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index 626403009ce..70c255ad4b5 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -4,59 +4,47 @@ from __future__ import annotations from homeassistant.components import persistent_notification from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the demo Button entity.""" - async_add_entities( - [ - DemoButton( - unique_id="push", - name="Push", - icon="mdi:gesture-tap-button", - ), - ] - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + """Set up the demo button platform.""" + async_add_entities( + [ + DemoButton( + unique_id="push", + device_name="Push", + icon="mdi:gesture-tap-button", + ), + ] + ) class DemoButton(ButtonEntity): """Representation of a demo button entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, icon: str, ) -> None: """Initialize the Demo button entity.""" self._attr_unique_id = unique_id - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_icon = icon self._attr_device_info = { "identifiers": {(DOMAIN, unique_id)}, - "name": name, + "name": device_name, } async def async_press(self) -> None: diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index b55fb4ba0e9..722693280a0 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -7,22 +7,6 @@ from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo camera platform.""" - async_add_entities( - [ - DemoCamera("Demo camera", "image/jpg"), - DemoCamera("Demo camera png", "image/png"), - ] - ) async def async_setup_entry( @@ -31,7 +15,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + async_add_entities( + [ + DemoCamera("Demo camera", "image/jpg"), + DemoCamera("Demo camera png", "image/png"), + ] + ) class DemoCamera(Camera): diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 7c0a4a5c9c8..9855bfc2695 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -14,27 +14,24 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN SUPPORT_FLAGS = ClimateEntityFeature(0) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo climate devices.""" + """Set up the demo climate platform.""" async_add_entities( [ DemoClimate( unique_id="climate_1", - name="HeatPump", + device_name="HeatPump", target_temperature=68, unit_of_measurement=UnitOfTemperature.FAHRENHEIT, preset=None, @@ -52,7 +49,7 @@ async def async_setup_platform( ), DemoClimate( unique_id="climate_2", - name="Hvac", + device_name="Hvac", target_temperature=21, unit_of_measurement=UnitOfTemperature.CELSIUS, preset=None, @@ -70,7 +67,7 @@ async def async_setup_platform( ), DemoClimate( unique_id="climate_3", - name="Ecobee", + device_name="Ecobee", target_temperature=None, unit_of_measurement=UnitOfTemperature.CELSIUS, preset="home", @@ -91,25 +88,17 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo climate devices config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoClimate(ClimateEntity): """Representation of a demo climate device.""" + _attr_has_entity_name = True _attr_should_poll = False _attr_translation_key = "ubercool" def __init__( self, unique_id: str, - name: str, + device_name: str, target_temperature: float | None, unit_of_measurement: str, preset: str | None, @@ -128,7 +117,6 @@ class DemoClimate(ClimateEntity): ) -> None: """Initialize the climate device.""" self._unique_id = unique_id - self._attr_name = name self._attr_supported_features = SUPPORT_FLAGS if target_temperature is not None: self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE @@ -163,17 +151,10 @@ class DemoClimate(ClimateEntity): self._swing_modes = ["auto", "1", "2", "3", "off"] self._target_temperature_high = target_temp_high self._target_temperature_low = target_temp_low - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={ - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, self.unique_id) - }, - name=self.name, - ) + self._attr_device_info = { + "identifiers": {(DOMAIN, unique_id)}, + "name": device_name, + } @property def unique_id(self) -> str: diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 6f443329661..3d611297c0b 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -16,18 +16,16 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_utc_time_change -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo covers.""" + """Set up the demo cover platform.""" async_add_entities( [ DemoCover(hass, "cover_1", "Kitchen Window"), @@ -56,25 +54,17 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoCover(CoverEntity): """Representation of a demo cover.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, hass: HomeAssistant, unique_id: str, - name: str, + device_name: str, position: int | None = None, tilt_position: int | None = None, device_class: CoverDeviceClass | None = None, @@ -83,7 +73,6 @@ class DemoCover(CoverEntity): """Initialize the cover.""" self.hass = hass self._unique_id = unique_id - self._attr_name = name self._position = position self._attr_device_class = device_class self._attr_supported_features = supported_features @@ -101,15 +90,12 @@ class DemoCover(CoverEntity): else: self._closed = position <= 0 - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - name=self.name, + name=device_name, ) @property diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py index eb96bc49038..718fa3dc4a4 100644 --- a/homeassistant/components/demo/date.py +++ b/homeassistant/components/demo/date.py @@ -5,22 +5,19 @@ from datetime import date from homeassistant.components.date import DateEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo date entity.""" + """Set up the demo date platform.""" async_add_entities( [ DemoDate( @@ -34,24 +31,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoDate(DateEntity): """Representation of a Demo date entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: date, icon: str, assumed_state: bool, @@ -59,12 +48,11 @@ class DemoDate(DateEntity): """Initialize the Demo date entity.""" self._attr_assumed_state = assumed_state self._attr_icon = icon - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_value = state self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, name=self.name + identifiers={(DOMAIN, unique_id)}, name=device_name ) async def async_set_value(self, value: date) -> None: diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py index 88027f58b92..57d14be24b6 100644 --- a/homeassistant/components/demo/datetime.py +++ b/homeassistant/components/demo/datetime.py @@ -5,22 +5,19 @@ from datetime import datetime, timezone from homeassistant.components.datetime import DateTimeEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo date/time entity.""" + """Set up the demo datetime platform.""" async_add_entities( [ DemoDateTime( @@ -34,24 +31,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoDateTime(DateTimeEntity): """Representation of a Demo date/time entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: datetime, icon: str, assumed_state: bool, @@ -59,7 +48,6 @@ class DemoDateTime(DateTimeEntity): """Initialize the Demo date/time entity.""" self._attr_assumed_state = assumed_state self._attr_icon = icon - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_value = state self._attr_unique_id = unique_id @@ -68,7 +56,7 @@ class DemoDateTime(DateTimeEntity): # Serial numbers are unique identifiers within a specific domain (DOMAIN, unique_id) }, - name=self.name, + name=device_name, ) async def async_set_value(self, value: datetime) -> None: diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index 2e5291b8a13..91fc49b7c7e 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -20,7 +20,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN @@ -34,11 +33,10 @@ SUPPORT_DEMO = {ColorMode.HS, ColorMode.COLOR_TEMP} SUPPORT_DEMO_HS_WHITE = {ColorMode.HS, ColorMode.WHITE} -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the demo light platform.""" async_add_entities( @@ -47,28 +45,28 @@ async def async_setup_platform( available=True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0], - name="Bed Light", + device_name="Bed Light", state=False, unique_id="light_1", ), DemoLight( available=True, ct=LIGHT_TEMPS[1], - name="Ceiling Lights", + device_name="Ceiling Lights", state=True, unique_id="light_2", ), DemoLight( available=True, hs_color=LIGHT_COLORS[1], - name="Kitchen Lights", + device_name="Kitchen Lights", state=True, unique_id="light_3", ), DemoLight( available=True, ct=LIGHT_TEMPS[1], - name="Office RGBW Lights", + device_name="Office RGBW Lights", rgbw_color=(255, 0, 0, 255), state=True, supported_color_modes={ColorMode.RGBW}, @@ -76,7 +74,7 @@ async def async_setup_platform( ), DemoLight( available=True, - name="Living Room RGBWW Lights", + device_name="Living Room RGBWW Lights", rgbww_color=(255, 0, 0, 255, 0), state=True, supported_color_modes={ColorMode.RGBWW}, @@ -84,7 +82,7 @@ async def async_setup_platform( ), DemoLight( available=True, - name="Entrance Color + White Lights", + device_name="Entrance Color + White Lights", hs_color=LIGHT_COLORS[1], state=True, supported_color_modes=SUPPORT_DEMO_HS_WHITE, @@ -94,24 +92,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoLight(LightEntity): """Representation of a demo light.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: bool, available: bool = False, brightness: int = 180, @@ -130,7 +120,6 @@ class DemoLight(LightEntity): self._effect = effect self._effect_list = effect_list self._hs_color = hs_color - self._attr_name = name self._rgbw_color = rgbw_color self._rgbww_color = rgbww_color self._state = state @@ -148,16 +137,12 @@ class DemoLight(LightEntity): self._color_modes = supported_color_modes if self._effect_list is not None: self._attr_supported_features |= LightEntityFeature.EFFECT - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - name=self.name, + name=device_name, ) @property diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 25ed7347bda..38bab325c92 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -3,22 +3,20 @@ from __future__ import annotations from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME, UnitOfTemperature +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the demo Number entity.""" + """Set up the demo number platform.""" async_add_entities( [ DemoNumber( @@ -77,24 +75,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoNumber(NumberEntity): """Representation of a demo Number entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: float, icon: str, assumed_state: bool, @@ -111,7 +101,6 @@ class DemoNumber(NumberEntity): self._attr_device_class = device_class self._attr_icon = icon self._attr_mode = mode - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_unique_id = unique_id @@ -128,7 +117,7 @@ class DemoNumber(NumberEntity): # Serial numbers are unique identifiers within a specific domain (DOMAIN, unique_id) }, - name=self.name, + name=device_name, ) async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index e30d65c9f0e..48ad4c6931b 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -3,27 +3,24 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the demo Select entity.""" + """Set up the demo select platform.""" async_add_entities( [ DemoSelect( unique_id="speed", - name="Speed", + device_name="Speed", icon="mdi:speedometer", current_option="ridiculous_speed", options=[ @@ -37,24 +34,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoSelect(SelectEntity): """Representation of a demo select entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, icon: str, current_option: str | None, options: list[str], @@ -62,14 +51,13 @@ class DemoSelect(SelectEntity): ) -> None: """Initialize the Demo select entity.""" self._attr_unique_id = unique_id - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_current_option = current_option self._attr_icon = icon self._attr_options = options self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 84758f0c294..81795540d1f 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -25,18 +25,17 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo sensors.""" + """Set up the demo sensor platform.""" async_add_entities( [ DemoSensor( @@ -126,7 +125,7 @@ async def async_setup_platform( ), DemoSensor( unique_id="sensor_10", - name=None, + device_name="Thermostat", state="eco", device_class=SensorDeviceClass.ENUM, state_class=None, @@ -139,24 +138,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str | None, + device_name: str | None, state: StateType, device_class: SensorDeviceClass, state_class: SensorStateClass | None, @@ -167,10 +158,6 @@ class DemoSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - if name is not None: - self._attr_name = name - else: - self._attr_has_entity_name = True self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class @@ -180,7 +167,7 @@ class DemoSensor(SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) if battery: @@ -196,7 +183,7 @@ class DemoSumSensor(RestoreSensor): def __init__( self, unique_id: str, - name: str, + device_name: str, five_minute_increase: float, device_class: SensorDeviceClass, state_class: SensorStateClass | None, @@ -207,7 +194,6 @@ class DemoSumSensor(RestoreSensor): """Initialize the sensor.""" self.entity_id = f"{SENSOR_DOMAIN}.{suggested_entity_id}" self._attr_device_class = device_class - self._attr_name = name self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = 0 self._attr_state_class = state_class @@ -216,7 +202,7 @@ class DemoSumSensor(RestoreSensor): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) if battery: diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 9ee3f0bb8ad..f14eed467db 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -55,7 +55,7 @@ def async_setup_legacy( providers = hass.data[DATA_PROVIDERS] = {} async def async_setup_platform(p_type, p_config=None, discovery_info=None): - """Set up a TTS platform.""" + """Set up an STT platform.""" if p_config is None: p_config = {} diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index d99a50bd286..c529789b596 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -1,11 +1,12 @@ """The tests for calendar recorder.""" from datetime import timedelta +from unittest.mock import patch import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +20,16 @@ async def setup_homeassistant(): """Override the fixture in calendar.conftest.""" +@pytest.fixture(autouse=True) +async def calendar_only() -> None: + """Enable only the calendar platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.CALENDAR], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index 8953158e423..21737323671 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components import camera from homeassistant.components.camera.const import StreamType +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,6 +18,16 @@ async def setup_homeassistant(hass: HomeAssistant): await async_setup_component(hass, "homeassistant", {}) +@pytest.fixture(autouse=True) +async def camera_only() -> None: + """Enable only the camera platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.CAMERA], + ): + yield + + @pytest.fixture(name="mock_camera") async def mock_camera_fixture(hass): """Initialize a demo camera platform.""" diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index b6acf375f2e..150d70d1dba 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -2,6 +2,9 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch + +import pytest from homeassistant.components import climate from homeassistant.components.climate import ( @@ -17,7 +20,7 @@ from homeassistant.components.climate import ( ) from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -26,6 +29,16 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def climate_only() -> None: + """Enable only the climate platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.CLIMATE], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test climate registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index efd9617c491..bf94e36a9b4 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -976,7 +976,7 @@ async def test_get_single( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) assert entry.pref_disable_new_entities is False @@ -993,7 +993,7 @@ async def test_get_single( assert response["success"] assert response["result"]["config_entry"] == { "disabled_by": None, - "domain": "demo", + "domain": "test", "entry_id": entry.entry_id, "pref_disable_new_entities": False, "pref_disable_polling": False, diff --git a/tests/components/demo/conftest.py b/tests/components/demo/conftest.py index 6cfd1a33f9c..0e54587ba4e 100644 --- a/tests/components/demo/conftest.py +++ b/tests/components/demo/conftest.py @@ -1,4 +1,6 @@ """demo conftest.""" +from unittest.mock import patch + import pytest from homeassistant.core import HomeAssistant @@ -16,3 +18,13 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: async def setup_homeassistant(hass: HomeAssistant): """Set up the homeassistant integration.""" await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +async def disable_platforms(hass: HomeAssistant) -> None: + """Disable platforms to speed up tests.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [], + ): + yield diff --git a/tests/components/demo/test_button.py b/tests/components/demo/test_button.py index 98728c0ffab..bcaddab433b 100644 --- a/tests/components/demo/test_button.py +++ b/tests/components/demo/test_button.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components.button import DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -13,8 +13,18 @@ from homeassistant.util import dt as dt_util ENTITY_PUSH = "button.push" +@pytest.fixture +async def button_only() -> None: + """Enable only the button platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.BUTTON], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_button(hass: HomeAssistant) -> None: +async def setup_demo_button(hass: HomeAssistant, button_only) -> None: """Initialize setup demo button entity.""" assert await async_setup_component(hass, DOMAIN, {"button": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_camera.py b/tests/components/demo/test_camera.py index 25c7814961b..132e7bdb096 100644 --- a/tests/components/demo/test_camera.py +++ b/tests/components/demo/test_camera.py @@ -14,7 +14,7 @@ from homeassistant.components.camera import ( async_get_image, ) from homeassistant.components.demo import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -22,8 +22,18 @@ from homeassistant.setup import async_setup_component ENTITY_CAMERA = "camera.demo_camera" +@pytest.fixture +async def camera_only() -> None: + """Enable only the button platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.CAMERA], + ): + yield + + @pytest.fixture(autouse=True) -async def demo_camera(hass): +async def demo_camera(hass, camera_only): """Initialize a demo camera platform.""" assert await async_setup_component( hass, CAMERA_DOMAIN, {CAMERA_DOMAIN: {"platform": DOMAIN}} diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 7ce19e2143e..fa87c439a4d 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -1,4 +1,5 @@ """The tests for the demo climate component.""" +from unittest.mock import patch import pytest import voluptuous as vol @@ -40,6 +41,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -50,8 +52,18 @@ ENTITY_ECOBEE = "climate.ecobee" ENTITY_HEATPUMP = "climate.heatpump" +@pytest.fixture +async def climate_only() -> None: + """Enable only the climate platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.CLIMATE], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_climate(hass): +async def setup_demo_climate(hass, climate_only): """Initialize setup demo climate.""" hass.config.units = METRIC_SYSTEM assert await async_setup_component(hass, DOMAIN, {"climate": {"platform": "demo"}}) diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index d6b44b06b9a..3a8fbb02f80 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -1,5 +1,6 @@ """The tests for the Demo cover platform.""" from datetime import timedelta +from unittest.mock import patch import pytest @@ -27,6 +28,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -39,7 +41,17 @@ ENTITY_COVER = "cover.living_room_window" @pytest.fixture -async def setup_comp(hass): +async def cover_only() -> None: + """Enable only the climate platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.COVER], + ): + yield + + +@pytest.fixture +async def setup_comp(hass, cover_only): """Set up demo cover component.""" with assert_setup_component(1, DOMAIN): await async_setup_component(hass, DOMAIN, CONFIG) diff --git a/tests/components/demo/test_date.py b/tests/components/demo/test_date.py index c42ba06667e..d0208c8e6dd 100644 --- a/tests/components/demo/test_date.py +++ b/tests/components/demo/test_date.py @@ -1,16 +1,28 @@ """The tests for the demo date component.""" +from unittest.mock import patch + import pytest from homeassistant.components.date import ATTR_DATE, DOMAIN, SERVICE_SET_VALUE -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component ENTITY_DATE = "date.date" +@pytest.fixture +async def date_only() -> None: + """Enable only the date platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.DATE], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_date(hass: HomeAssistant) -> None: +async def setup_demo_date(hass: HomeAssistant, date_only) -> None: """Initialize setup demo date.""" assert await async_setup_component(hass, DOMAIN, {"date": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_datetime.py b/tests/components/demo/test_datetime.py index 90019f46af5..41ed6969df3 100644 --- a/tests/components/demo/test_datetime.py +++ b/tests/components/demo/test_datetime.py @@ -1,16 +1,28 @@ """The tests for the demo datetime component.""" +from unittest.mock import patch + import pytest from homeassistant.components.datetime import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component ENTITY_DATETIME = "datetime.date_and_time" +@pytest.fixture +async def datetime_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.DATETIME], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_datetime(hass: HomeAssistant) -> None: +async def setup_demo_datetime(hass: HomeAssistant, datetime_only) -> None: """Initialize setup demo datetime.""" assert await async_setup_component(hass, DOMAIN, {"datetime": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 773b94b3482..9c1b84313f6 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -32,7 +32,7 @@ PERCENTAGE_MODEL_FANS = ["fan.percentage_full_fan", "fan.percentage_limited_fan" @pytest.fixture(autouse=True) -async def setup_comp(hass): +async def setup_comp(hass, disable_platforms): """Initialize components.""" assert await async_setup_component(hass, fan.DOMAIN, {"fan": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_geo_location.py b/tests/components/demo/test_geo_location.py index 431db116f36..8cd176934bc 100644 --- a/tests/components/demo/test_geo_location.py +++ b/tests/components/demo/test_geo_location.py @@ -21,7 +21,7 @@ from tests.common import assert_setup_component, async_fire_time_changed CONFIG = {geo_location.DOMAIN: [{"platform": "demo"}]} -async def test_setup_platform(hass: HomeAssistant) -> None: +async def test_setup_platform(hass: HomeAssistant, disable_platforms) -> None: """Test setup of demo platform via configuration.""" utcnow = dt_util.utcnow() # Patching 'utcnow' to gain more control over the timed update. diff --git a/tests/components/demo/test_humidifier.py b/tests/components/demo/test_humidifier.py index 565c0ebcad1..d0b1e15ea61 100644 --- a/tests/components/demo/test_humidifier.py +++ b/tests/components/demo/test_humidifier.py @@ -30,7 +30,7 @@ ENTITY_HUMIDIFIER = "humidifier.humidifier" @pytest.fixture(autouse=True) -async def setup_demo_humidifier(hass): +async def setup_demo_humidifier(hass, disable_platforms): """Initialize setup demo humidifier.""" assert await async_setup_component( hass, DOMAIN, {"humidifier": {"platform": "demo"}} diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py index d5a3b7434b2..90fa26885dc 100644 --- a/tests/components/demo/test_light.py +++ b/tests/components/demo/test_light.py @@ -1,4 +1,6 @@ """The tests for the demo light component.""" +from unittest.mock import patch + import pytest from homeassistant.components.demo import DOMAIN @@ -16,15 +18,25 @@ from homeassistant.components.light import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component ENTITY_LIGHT = "light.bed_light" +@pytest.fixture +async def light_only() -> None: + """Enable only the light platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.LIGHT], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass): +async def setup_comp(hass, light_only): """Set up demo component.""" assert await async_setup_component( hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": DOMAIN}} diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index d859eaeef94..377f9f2d765 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -28,7 +28,7 @@ OPENABLE_LOCK = "lock.openable_lock" @pytest.fixture(autouse=True) -async def setup_comp(hass): +async def setup_comp(hass, disable_platforms): """Set up demo component.""" assert await async_setup_component( hass, LOCK_DOMAIN, {LOCK_DOMAIN: {"platform": DOMAIN}} diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index c9ea785678a..1681cdb9101 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -23,6 +23,11 @@ from tests.typing import ClientSessionGenerator TEST_ENTITY_ID = "media_player.walkman" +@pytest.fixture(autouse=True) +def autouse_disable_platforms(disable_platforms): + """Auto use the disable_platforms fixture.""" + + @pytest.fixture(name="mock_media_seek") def media_player_media_seek_fixture(): """Mock demo YouTube player media seek.""" diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 40bc7db2cc3..96ffcdec96b 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -16,6 +16,11 @@ from tests.common import assert_setup_component, async_capture_events CONFIG = {notify.DOMAIN: {"platform": "demo"}} +@pytest.fixture(autouse=True) +def autouse_disable_platforms(disable_platforms): + """Auto use the disable_platforms fixture.""" + + @pytest.fixture def events(hass): """Fixture that catches notify events.""" diff --git a/tests/components/demo/test_number.py b/tests/components/demo/test_number.py index b87c6690cf5..f444b2f4831 100644 --- a/tests/components/demo/test_number.py +++ b/tests/components/demo/test_number.py @@ -1,4 +1,5 @@ """The tests for the demo number component.""" +from unittest.mock import patch import pytest import voluptuous as vol @@ -12,7 +13,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -22,8 +23,18 @@ ENTITY_LARGE_RANGE = "number.large_range" ENTITY_SMALL_RANGE = "number.small_range" +@pytest.fixture +async def number_only() -> None: + """Enable only the number platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.NUMBER], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_number(hass): +async def setup_demo_number(hass, number_only): """Initialize setup demo Number entity.""" assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_remote.py b/tests/components/demo/test_remote.py index 943d1fb3886..d1e5b4007ca 100644 --- a/tests/components/demo/test_remote.py +++ b/tests/components/demo/test_remote.py @@ -18,7 +18,7 @@ SERVICE_SEND_COMMAND = "send_command" @pytest.fixture(autouse=True) -async def setup_component(hass): +async def setup_component(hass, disable_platforms): """Initialize components.""" assert await async_setup_component( hass, remote.DOMAIN, {"remote": {"platform": "demo"}} diff --git a/tests/components/demo/test_select.py b/tests/components/demo/test_select.py index 06b8beac5fe..a4fff2a231e 100644 --- a/tests/components/demo/test_select.py +++ b/tests/components/demo/test_select.py @@ -1,4 +1,5 @@ """The tests for the demo select component.""" +from unittest.mock import patch import pytest @@ -8,15 +9,25 @@ from homeassistant.components.select import ( DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component ENTITY_SPEED = "select.speed" +@pytest.fixture +async def select_only() -> None: + """Enable only the select platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.SELECT], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_select(hass: HomeAssistant) -> None: +async def setup_demo_select(hass: HomeAssistant, select_only) -> None: """Initialize setup demo select entity.""" assert await async_setup_component(hass, DOMAIN, {"select": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_sensor.py b/tests/components/demo/test_sensor.py index 71c212694c4..0fbe8f3fa7f 100644 --- a/tests/components/demo/test_sensor.py +++ b/tests/components/demo/test_sensor.py @@ -1,5 +1,6 @@ """The tests for the demo sensor component.""" from datetime import timedelta +from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -7,12 +8,23 @@ import pytest from homeassistant import core as ha from homeassistant.components.demo import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import mock_restore_cache_with_extra_data +@pytest.fixture(autouse=True) +async def sensor_only() -> None: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.SENSOR], + ): + yield + + @pytest.mark.parametrize(("entity_id", "delta"), (("sensor.total_energy_kwh", 0.5),)) async def test_energy_sensor( hass: HomeAssistant, entity_id, delta, freezer: FrozenDateTimeFactory diff --git a/tests/components/demo/test_siren.py b/tests/components/demo/test_siren.py index 21c464c7325..8051aaf5b20 100644 --- a/tests/components/demo/test_siren.py +++ b/tests/components/demo/test_siren.py @@ -25,7 +25,7 @@ ENTITY_SIREN_WITH_ALL_FEATURES = "siren.siren_with_all_features" @pytest.fixture(autouse=True) -async def setup_demo_siren(hass): +async def setup_demo_siren(hass, disable_platforms): """Initialize setup demo siren.""" assert await async_setup_component(hass, DOMAIN, {"siren": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_switch.py b/tests/components/demo/test_switch.py index 30c07b49573..74fa8082d5f 100644 --- a/tests/components/demo/test_switch.py +++ b/tests/components/demo/test_switch.py @@ -15,7 +15,7 @@ SWITCH_ENTITY_IDS = ["switch.decorative_lights", "switch.ac"] @pytest.fixture(autouse=True) -async def setup_comp(hass): +async def setup_comp(hass, disable_platforms): """Set up demo component.""" assert await async_setup_component( hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": DOMAIN}} diff --git a/tests/components/demo/test_text.py b/tests/components/demo/test_text.py index bfeca29f1dd..17680d6f606 100644 --- a/tests/components/demo/test_text.py +++ b/tests/components/demo/test_text.py @@ -17,7 +17,7 @@ ENTITY_TEXT = "text.text" @pytest.fixture(autouse=True) -async def setup_demo_text(hass): +async def setup_demo_text(hass, disable_platforms): """Initialize setup demo text.""" assert await async_setup_component(hass, DOMAIN, {"text": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_time.py b/tests/components/demo/test_time.py index 555cfe3ffc9..abd0faef884 100644 --- a/tests/components/demo/test_time.py +++ b/tests/components/demo/test_time.py @@ -10,7 +10,7 @@ ENTITY_TIME = "time.time" @pytest.fixture(autouse=True) -async def setup_demo_datetime(hass: HomeAssistant) -> None: +async def setup_demo_datetime(hass: HomeAssistant, disable_platforms) -> None: """Initialize setup demo time.""" assert await async_setup_component(hass, DOMAIN, {"time": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_update.py b/tests/components/demo/test_update.py index c06483b7bdc..f53cceafd23 100644 --- a/tests/components/demo/test_update.py +++ b/tests/components/demo/test_update.py @@ -27,7 +27,7 @@ from homeassistant.setup import async_setup_component @pytest.fixture(autouse=True) -async def setup_demo_update(hass: HomeAssistant) -> None: +async def setup_demo_update(hass: HomeAssistant, disable_platforms) -> None: """Initialize setup demo update entity.""" assert await async_setup_component(hass, DOMAIN, {"update": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 38bb9418091..bc003c6e27b 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -52,7 +52,7 @@ ENTITY_VACUUM_STATE = f"{DOMAIN}.{DEMO_VACUUM_STATE}".lower() @pytest.fixture(autouse=True) -async def setup_demo_vacuum(hass): +async def setup_demo_vacuum(hass, disable_platforms): """Initialize setup demo vacuum.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index bbe28b8a5e5..9e45b4e39bf 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -14,7 +14,7 @@ ENTITY_WATER_HEATER_CELSIUS = "water_heater.demo_water_heater_celsius" @pytest.fixture(autouse=True) -async def setup_comp(hass): +async def setup_comp(hass, disable_platforms): """Set up demo component.""" hass.config.units = US_CUSTOMARY_SYSTEM assert await async_setup_component( diff --git a/tests/components/demo/test_weather.py b/tests/components/demo/test_weather.py index 92005491cef..b2b789a084f 100644 --- a/tests/components/demo/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -20,7 +20,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM -async def test_attributes(hass: HomeAssistant) -> None: +async def test_attributes(hass: HomeAssistant, disable_platforms) -> None: """Test weather attributes.""" assert await async_setup_component( hass, weather.DOMAIN, {"weather": {"platform": "demo"}} diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 247a507bb69..ac94467bd59 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -135,8 +135,25 @@ async def base_setup(hass): ) +@pytest.fixture(autouse=True) +async def wanted_platforms_only() -> None: + """Enable only the wanted demo platforms.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [ + const.Platform.CLIMATE, + const.Platform.COVER, + const.Platform.FAN, + const.Platform.HUMIDIFIER, + const.Platform.LIGHT, + const.Platform.MEDIA_PLAYER, + ], + ): + yield + + @pytest.fixture -async def demo_setup(hass): +async def demo_setup(hass, wanted_platforms_only): """Fixture to setup demo platforms.""" # We need to do this to get access to homeassistant/turn_(on,off) setups = [ @@ -144,12 +161,7 @@ async def demo_setup(hass): setup.async_setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}} ), - *[ - setup.async_setup_component( - hass, comp.DOMAIN, {comp.DOMAIN: [{"platform": "demo"}]} - ) - for comp in (light, climate, humidifier, media_player, fan, cover) - ], + setup.async_setup_component(hass, "demo", {}), setup.async_setup_component( hass, script.DOMAIN, diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index f60a06cb4c5..d75e1e18511 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -2,12 +2,15 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch + +import pytest from homeassistant.components import fan from homeassistant.components.fan import ATTR_PRESET_MODES from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -16,6 +19,16 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def fan_only() -> None: + """Enable only the fan platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.FAN], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test fan registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py index 5f033319c44..fde7e99025f 100644 --- a/tests/components/google_assistant/test_diagnostics.py +++ b/tests/components/google_assistant/test_diagnostics.py @@ -1,8 +1,11 @@ """Test diagnostics.""" -from unittest.mock import ANY +from unittest.mock import ANY, patch + +import pytest from homeassistant import setup from homeassistant.components import google_assistant as ga, switch +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -12,6 +15,16 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True) +async def switch_only() -> None: + """Enable only the switch platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.SWITCH], + ): + yield + + async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index a6c572c24f0..ffcafde5502 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,26 +1,22 @@ """The tests for the Google Assistant component.""" from http import HTTPStatus import json +from unittest.mock import patch from aiohttp.hdrs import AUTHORIZATION import pytest from homeassistant import const, core, setup from homeassistant.components import ( - alarm_control_panel, - climate, - cover, - fan, google_assistant as ga, humidifier, light, - lock, media_player, - switch, ) from homeassistant.const import ( CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory, + Platform, UnitOfTemperature, ) from homeassistant.helpers import entity_registry as er @@ -65,6 +61,26 @@ def assistant_client(event_loop, hass, hass_client_no_auth): return loop.run_until_complete(hass_client_no_auth()) +@pytest.fixture(autouse=True) +async def wanted_platforms_only() -> None: + """Enable only the wanted demo platforms.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [ + Platform.ALARM_CONTROL_PANEL, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.LOCK, + Platform.MEDIA_PLAYER, + Platform.SWITCH, + ], + ): + yield + + @pytest.fixture def hass_fixture(event_loop, hass): """Set up a Home Assistant instance for these tests.""" @@ -73,55 +89,7 @@ def hass_fixture(event_loop, hass): # We need to do this to get access to homeassistant/turn_(on,off) loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {})) - loop.run_until_complete( - setup.async_setup_component( - hass, light.DOMAIN, {"light": [{"platform": "demo"}]} - ) - ) - loop.run_until_complete( - setup.async_setup_component( - hass, switch.DOMAIN, {"switch": [{"platform": "demo"}]} - ) - ) - loop.run_until_complete( - setup.async_setup_component( - hass, cover.DOMAIN, {"cover": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, media_player.DOMAIN, {"media_player": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component(hass, fan.DOMAIN, {"fan": [{"platform": "demo"}]}) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, humidifier.DOMAIN, {"humidifier": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component(hass, lock.DOMAIN, {"lock": [{"platform": "demo"}]}) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, - alarm_control_panel.DOMAIN, - {"alarm_control_panel": [{"platform": "demo"}]}, - ) - ) + loop.run_until_complete(setup.async_setup_component(hass, "demo", {})) return hass diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index f9ea356216f..c27ea76c00a 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -22,7 +22,12 @@ from homeassistant.components.google_assistant import ( trait, ) from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature, __version__ +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + Platform, + UnitOfTemperature, + __version__, +) from homeassistant.core import EVENT_CALL_SERVICE, HomeAssistant, State from homeassistant.helpers import ( area_registry as ar, @@ -39,6 +44,16 @@ from tests.common import async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" +@pytest.fixture +async def light_only() -> None: + """Enable only the light platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.LIGHT], + ): + yield + + @pytest.fixture def registries( entity_registry: er.EntityRegistry, @@ -128,6 +143,8 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: ) light.hass = hass light.entity_id = "light.demo_light" + light._attr_device_info = None + light._attr_name = "Demo Light" light.async_write_ha_state() # This should not show up in the sync request @@ -268,6 +285,8 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> ) light.hass = hass light.entity_id = entity.entity_id + light._attr_device_info = None + light._attr_name = "Demo Light" light.async_write_ha_state() config = MockConfig(should_expose=lambda _: True, entity_config={}) @@ -360,6 +379,8 @@ async def test_query_message(hass: HomeAssistant) -> None: ) light.hass = hass light.entity_id = "light.demo_light" + light._attr_device_info = None + light._attr_name = "Demo Light" light.async_write_ha_state() light2 = DemoLight( @@ -367,11 +388,15 @@ async def test_query_message(hass: HomeAssistant) -> None: ) light2.hass = hass light2.entity_id = "light.another_light" + light2._attr_device_info = None + light2._attr_name = "Another Light" light2.async_write_ha_state() light3 = DemoLight(None, "Color temp Light", state=True, ct=400, brightness=200) light3.hass = hass light3.entity_id = "light.color_temp_light" + light3._attr_device_info = None + light3._attr_name = "Color temp Light" light3.async_write_ha_state() events = async_capture_events(hass, EVENT_QUERY_RECEIVED) @@ -448,7 +473,7 @@ async def test_query_message(hass: HomeAssistant) -> None: [(False, True, 20, 0.2), (True, ANY, ANY, ANY)], ) async def test_execute( - hass: HomeAssistant, report_state, on, brightness, value + hass: HomeAssistant, light_only, report_state, on, brightness, value ) -> None: """Test an execute command.""" await async_setup_component(hass, "homeassistant", {}) @@ -630,7 +655,7 @@ async def test_execute( ("report_state", "on", "brightness", "value"), [(False, False, ANY, ANY)] ) async def test_execute_times_out( - hass: HomeAssistant, report_state, on, brightness, value + hass: HomeAssistant, light_only, report_state, on, brightness, value ) -> None: """Test an execute command which times out.""" orig_execute_limit = sh.EXECUTE_LIMIT @@ -933,6 +958,8 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: light.hass = hass light.entity_id = "light.demo_light" light._available = False + light._attr_device_info = None + light._attr_name = "Demo Light" light.async_write_ha_state() events = async_capture_events(hass, EVENT_SYNC_RECEIVED) @@ -1074,6 +1101,8 @@ async def test_device_class_binary_sensor( ) sensor.hass = hass sensor.entity_id = "binary_sensor.demo_sensor" + sensor._attr_device_info = None + sensor._attr_name = "Demo Sensor" sensor.async_write_ha_state() result = await sh.async_handle_message( @@ -1125,6 +1154,8 @@ async def test_device_class_cover( sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) sensor.hass = hass sensor.entity_id = "cover.demo_sensor" + sensor._attr_device_info = None + sensor._attr_name = "Demo Sensor" sensor.async_write_ha_state() result = await sh.async_handle_message( @@ -1456,6 +1487,8 @@ async def test_sync_message_recovery( ) light.hass = hass light.entity_id = "light.demo_light" + light._attr_device_info = None + light._attr_name = "Demo Light" light.async_write_ha_state() hass.states.async_set( diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index 2c85dac0bd4..edf691b6099 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -2,6 +2,9 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch + +import pytest from homeassistant.components import light from homeassistant.components.light import ( @@ -14,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,6 +26,16 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def light_only() -> None: + """Enable only the light platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.LIGHT], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test light registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index 98922d7d0a4..a04c7e85119 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -2,6 +2,9 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch + +import pytest from homeassistant.components import media_player from homeassistant.components.media_player import ( @@ -13,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -22,6 +25,16 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def media_player_only() -> None: + """Enable only the media_player platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.MEDIA_PLAYER], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test media_player registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index d996a67f93b..635354b1176 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -2,12 +2,15 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch + +import pytest from homeassistant.components import number from homeassistant.components.number import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -16,6 +19,16 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def number_only() -> None: + """Enable only the number platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.NUMBER], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test number registered attributes to be excluded.""" assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index 903d24d39bb..53911578d53 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -2,12 +2,15 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch + +import pytest from homeassistant.components import select from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.select import ATTR_OPTIONS -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -16,6 +19,16 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def select_only() -> None: + """Enable only the select platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.SELECT], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test select registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/text/test_recorder.py b/tests/components/text/test_recorder.py index 54134ee501a..da9ec810da9 100644 --- a/tests/components/text/test_recorder.py +++ b/tests/components/text/test_recorder.py @@ -2,12 +2,15 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch + +import pytest from homeassistant.components import text from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.text import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -16,6 +19,16 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def text_only() -> None: + """Enable only the text platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.TEXT], + ): + yield + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test siren registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/tts/test_notify.py b/tests/components/tts/test_notify.py index be528459a70..22ab151b864 100644 --- a/tests/components/tts/test_notify.py +++ b/tests/components/tts/test_notify.py @@ -1,7 +1,9 @@ """The tests for the TTS component.""" +from unittest.mock import patch + import pytest -from homeassistant.components import media_player, notify, tts +from homeassistant.components import notify, tts from homeassistant.components.media_player import ( DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, @@ -24,6 +26,16 @@ async def internal_url_mock(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +async def disable_platforms() -> None: + """Disable demo platforms.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [], + ): + yield + + async def test_setup_legacy_platform(hass: HomeAssistant) -> None: """Set up the tts notify platform .""" config = { @@ -62,7 +74,6 @@ async def test_setup_legacy_service(hass: HomeAssistant) -> None: config = { tts.DOMAIN: {"platform": "demo"}, - media_player.DOMAIN: {"platform": "demo"}, notify.DOMAIN: { "platform": "tts", "name": "tts_test", From de62082605752413fa3b233e052e57c25087fd35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niclas=20K=C3=BChnapfel?= Date: Wed, 14 Jun 2023 16:52:43 +0200 Subject: [PATCH 250/857] Update solax to 0.3.2 (#94545) * solax: update to 0.3.2 * Update solax dependencies --- homeassistant/components/solax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 5ce87c82532..d3f677fa894 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", "loggers": ["solax"], - "requirements": ["solax==0.3.0"] + "requirements": ["solax==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16404f70007..eac5083a293 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2409,7 +2409,7 @@ solaredge-local==0.2.3 solaredge==0.0.2 # homeassistant.components.solax -solax==0.3.0 +solax==0.3.2 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d2878d4214..249c3b284f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1751,7 +1751,7 @@ soco==0.29.1 solaredge==0.0.2 # homeassistant.components.solax -solax==0.3.0 +solax==0.3.2 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 From a0c023d5cb6bcc24bf6d9021d99657ffbe5aea12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Jun 2023 08:47:18 -1000 Subject: [PATCH 251/857] Reduce overhead to lookup items in the entity and device registry (#94568) --- homeassistant/helpers/device_registry.py | 10 ++++++++-- homeassistant/helpers/entity_registry.py | 10 ++++++++-- tests/common.py | 2 ++ tests/components/homekit/test_homekit.py | 19 +++++++++++++++++-- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 29e64639722..7df01fc8fd2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -292,6 +292,7 @@ class DeviceRegistry: devices: DeviceRegistryItems[DeviceEntry] deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] + _device_data: dict[str, DeviceEntry] def __init__(self, hass: HomeAssistant) -> None: """Initialize the device registry.""" @@ -306,8 +307,12 @@ class DeviceRegistry: @callback def async_get(self, device_id: str) -> DeviceEntry | None: - """Get device.""" - return self.devices.get(device_id) + """Get device. + + We retrieve the DeviceEntry from the underlying dict to avoid + the overhead of the UserDict __getitem__. + """ + return self._device_data.get(device_id) @callback def async_get_device( @@ -641,6 +646,7 @@ class DeviceRegistry: self.devices = devices self.deleted_devices = deleted_devices + self._device_data = devices.data @callback def async_schedule_save(self) -> None: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b6fc84b9627..15f05e2bd42 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -425,6 +425,7 @@ class EntityRegistry: """Class to hold a registry of entities.""" entities: EntityRegistryItems + _entities_data: dict[str, RegistryEntry] def __init__(self, hass: HomeAssistant) -> None: """Initialize the registry.""" @@ -470,8 +471,12 @@ class EntityRegistry: @callback def async_get(self, entity_id: str) -> RegistryEntry | None: - """Get EntityEntry for an entity_id.""" - return self.entities.get(entity_id) + """Get EntityEntry for an entity_id. + + We retrieve the RegistryEntry from the underlying dict to avoid + the overhead of the UserDict __getitem__. + """ + return self._entities_data.get(entity_id) @callback def async_get_entity_id( @@ -991,6 +996,7 @@ class EntityRegistry: ) self.entities = entities + self._entities_data = entities.data @callback def async_schedule_save(self) -> None: diff --git a/tests/common.py b/tests/common.py index ca164fcaaf8..38b7cf79b75 100644 --- a/tests/common.py +++ b/tests/common.py @@ -509,6 +509,7 @@ def mock_registry( if mock_entries is None: mock_entries = {} registry.entities = er.EntityRegistryItems() + registry._entities_data = registry.entities.data for key, entry in mock_entries.items(): registry.entities[key] = entry @@ -554,6 +555,7 @@ def mock_device_registry( """ registry = dr.DeviceRegistry(hass) registry.devices = dr.DeviceRegistryItems() + registry._device_data = registry.devices.data if mock_entries is None: mock_entries = {} for key, entry in mock_entries.items(): diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 0b74763c6a7..690bb7fef37 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1644,7 +1644,6 @@ async def test_homekit_ignored_missing_devices( light = entity_registry.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id ) - before_removal = entity_registry.entities.copy() # Delete the device to make sure we fallback # to using the platform device_registry.async_remove_device(device_entry.id) @@ -1652,7 +1651,23 @@ async def test_homekit_ignored_missing_devices( await asyncio.sleep(0) await asyncio.sleep(0) # Restore the registry - entity_registry.entities = before_removal + entity_registry.async_get_or_create( + "binary_sensor", + "powerwall", + "battery_charging", + device_id=device_entry.id, + original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + ) + entity_registry.async_get_or_create( + "sensor", + "powerwall", + "battery", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.BATTERY, + ) + light = entity_registry.async_get_or_create( + "light", "powerwall", "demo", device_id=device_entry.id + ) hass.states.async_set(light.entity_id, STATE_ON) hass.states.async_set("light.two", STATE_ON) From 3424e927cb18035a2e03af134ecf41fed6a3f324 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 20:49:23 +0200 Subject: [PATCH 252/857] Set has_entity_name in ws66i (#94608) --- homeassistant/components/ws66i/media_player.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index 1101c0c9fbc..d10ac6f2d29 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -44,6 +44,8 @@ async def async_setup_entry( class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity): """Representation of a WS66i amplifier zone.""" + _attr_has_entity_name = True + def __init__( self, device: WS66i, @@ -62,7 +64,7 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity self._status: ZoneStatus = coordinator.data[data_idx] self._attr_source_list = ws66i_data.sources.name_list self._attr_unique_id = f"{entry_id}_{self._zone_id}" - self._attr_name = f"Zone {self._zone_id}" + self._attr_name = None self._attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET @@ -73,7 +75,7 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(self.unique_id))}, - name=self.name, + name=f"Zone {self._zone_id}", manufacturer="Soundavo", model="WS66i 6-Zone Amplifier", ) From c5fccffbb3c7ccd8cc058370ae956530bfcdd373 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:02:00 +0200 Subject: [PATCH 253/857] Set has_entity_name in sharkiq (#94606) --- homeassistant/components/sharkiq/vacuum.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 5eafbc1e1e2..6ac6428b4ad 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -68,6 +68,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum """Shark IQ vacuum entity.""" _attr_fan_speed_list = list(FAN_SPEEDS_MAP) + _attr_has_entity_name = True _attr_supported_features = ( VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED @@ -86,7 +87,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum """Create a new SharkVacuumEntity.""" super().__init__(coordinator) self.sharkiq = sharkiq - self._attr_name = sharkiq.name + self._attr_name = None self._attr_unique_id = sharkiq.serial_number self._serial_number = sharkiq.serial_number @@ -122,7 +123,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum identifiers={(DOMAIN, self._serial_number)}, manufacturer=SHARK, model=self.model, - name=self.name, + name=self.sharkiq.name, sw_version=self.sharkiq.get_property_value( Properties.ROBOT_FIRMWARE_VERSION ), From f68ed8b7a0c2d5c8d33ac733181499b3ce236a73 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:03:07 +0200 Subject: [PATCH 254/857] Always setup demo platforms with device support from config entry (#94601) * Always setup demo platforms with device support from config entry * Update tests depending on the demo integration --- homeassistant/components/demo/switch.py | 24 ++++--------- homeassistant/components/demo/text.py | 33 ++++++------------ homeassistant/components/demo/time.py | 22 +++--------- homeassistant/components/demo/update.py | 34 ++++++------------- tests/components/demo/test_switch.py | 16 +++++++-- tests/components/demo/test_text.py | 21 ++++++++++-- tests/components/demo/test_time.py | 16 +++++++-- tests/components/demo/test_update.py | 13 ++++++- .../google_assistant/test_smart_home.py | 2 ++ 9 files changed, 94 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 2ad400ff3f7..080488642e7 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -5,22 +5,19 @@ from typing import Any from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the demo switches.""" + """Set up the demo switch platform.""" async_add_entities( [ DemoSwitch("switch1", "Decorative Lights", True, None, True), @@ -36,24 +33,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoSwitch(SwitchEntity): """Representation of a demo switch.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: bool, icon: str | None, assumed: bool, @@ -64,11 +53,10 @@ class DemoSwitch(SwitchEntity): self._attr_device_class = device_class self._attr_icon = icon self._attr_is_on = state - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=self.name, + name=device_name, ) def turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index efce1af5c37..ff50e508354 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -3,40 +3,37 @@ from __future__ import annotations from homeassistant.components.text import TextEntity, TextMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the demo Text entity.""" + """Set up the Demo text platform.""" async_add_entities( [ DemoText( unique_id="text", - name="Text", + device_name="Text", icon=None, native_value="Hello world", ), DemoText( unique_id="password", - name="Password", + device_name="Password", icon="mdi:text", native_value="Hello world", mode=TextMode.PASSWORD, ), DemoText( unique_id="text_1_to_5_char", - name="Text with 1 to 5 characters", + device_name="Text with 1 to 5 characters", icon="mdi:text", native_value="Hello", native_min=1, @@ -44,7 +41,7 @@ async def async_setup_platform( ), DemoText( unique_id="text_lowercase", - name="Text with only lower case characters", + device_name="Text with only lower case characters", icon="mdi:text", native_value="world", pattern=r"[a-z]+", @@ -53,24 +50,16 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoText(TextEntity): """Representation of a demo text entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, icon: str | None, native_value: str | None, mode: TextMode = TextMode.TEXT, @@ -80,7 +69,7 @@ class DemoText(TextEntity): ) -> None: """Initialize the Demo text entity.""" self._attr_unique_id = unique_id - self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_name = None self._attr_native_value = native_value self._attr_icon = icon self._attr_mode = mode @@ -92,7 +81,7 @@ class DemoText(TextEntity): self._attr_pattern = pattern self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) async def async_set_value(self, value: str) -> None: diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index aafd425a024..d5e34779927 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -5,43 +5,32 @@ from datetime import time from homeassistant.components.time import TimeEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo time entity.""" - async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + """Set up the demo time platform.""" + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) class DemoTime(TimeEntity): """Representation of a Demo time entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: time, icon: str, assumed_state: bool, @@ -49,12 +38,11 @@ class DemoTime(TimeEntity): """Initialize the Demo time entity.""" self._attr_assumed_state = assumed_state self._attr_icon = icon - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_value = state self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, name=self.name + identifiers={(DOMAIN, unique_id)}, name=device_name ) async def async_set_value(self, value: time) -> None: diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index 15e67ffa0a8..f89f5a160e2 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -10,29 +10,26 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN FAKE_INSTALL_SLEEP_TIME = 0.5 -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up demo update entities.""" + """Set up demo update platform.""" async_add_entities( [ DemoUpdate( unique_id="update_no_install", - name="Demo Update No Install", + device_name="Demo Update No Install", title="Awesomesoft Inc.", installed_version="1.0.0", latest_version="1.0.1", @@ -42,14 +39,14 @@ async def async_setup_platform( ), DemoUpdate( unique_id="update_2_date", - name="Demo No Update", + device_name="Demo No Update", title="AdGuard Home", installed_version="1.0.0", latest_version="1.0.0", ), DemoUpdate( unique_id="update_addon", - name="Demo add-on", + device_name="Demo add-on", title="AdGuard Home", installed_version="1.0.0", latest_version="1.0.1", @@ -58,7 +55,7 @@ async def async_setup_platform( ), DemoUpdate( unique_id="update_light_bulb", - name="Demo Living Room Bulb Update", + device_name="Demo Living Room Bulb Update", title="Philips Lamps Firmware", installed_version="1.93.3", latest_version="1.94.2", @@ -68,7 +65,7 @@ async def async_setup_platform( ), DemoUpdate( unique_id="update_support_progress", - name="Demo Update with Progress", + device_name="Demo Update with Progress", title="Philips Lamps Firmware", installed_version="1.93.3", latest_version="1.94.2", @@ -82,15 +79,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - async def _fake_install() -> None: """Fake install an update.""" await asyncio.sleep(FAKE_INSTALL_SLEEP_TIME) @@ -99,13 +87,14 @@ async def _fake_install() -> None: class DemoUpdate(UpdateEntity): """Representation of a demo update entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, *, unique_id: str, - name: str, + device_name: str, title: str | None, installed_version: str | None, latest_version: str | None, @@ -120,14 +109,13 @@ class DemoUpdate(UpdateEntity): self._attr_installed_version = installed_version self._attr_device_class = device_class self._attr_latest_version = latest_version - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_release_summary = release_summary self._attr_release_url = release_url self._attr_title = title self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) if support_install: self._attr_supported_features |= ( diff --git a/tests/components/demo/test_switch.py b/tests/components/demo/test_switch.py index 74fa8082d5f..95963ba0cbd 100644 --- a/tests/components/demo/test_switch.py +++ b/tests/components/demo/test_switch.py @@ -1,4 +1,6 @@ """The tests for the demo switch component.""" +from unittest.mock import patch + import pytest from homeassistant.components.demo import DOMAIN @@ -7,15 +9,25 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component SWITCH_ENTITY_IDS = ["switch.decorative_lights", "switch.ac"] +@pytest.fixture +async def switch_only() -> None: + """Enable only the switch platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.SWITCH], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass, disable_platforms): +async def setup_comp(hass, switch_only): """Set up demo component.""" assert await async_setup_component( hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": DOMAIN}} diff --git a/tests/components/demo/test_text.py b/tests/components/demo/test_text.py index 17680d6f606..2b8d8d122a3 100644 --- a/tests/components/demo/test_text.py +++ b/tests/components/demo/test_text.py @@ -1,4 +1,6 @@ """The tests for the demo text component.""" +from unittest.mock import patch + import pytest from homeassistant.components.text import ( @@ -9,15 +11,30 @@ from homeassistant.components.text import ( DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, MAX_LENGTH_STATE_STATE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_MODE, + MAX_LENGTH_STATE_STATE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component ENTITY_TEXT = "text.text" +@pytest.fixture +async def text_only() -> None: + """Enable only the text platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.TEXT], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_text(hass, disable_platforms): +async def setup_demo_text(hass, text_only): """Initialize setup demo text.""" assert await async_setup_component(hass, DOMAIN, {"text": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_time.py b/tests/components/demo/test_time.py index abd0faef884..efa62c1436b 100644 --- a/tests/components/demo/test_time.py +++ b/tests/components/demo/test_time.py @@ -1,16 +1,28 @@ """The tests for the demo time component.""" +from unittest.mock import patch + import pytest from homeassistant.components.time import ATTR_TIME, DOMAIN, SERVICE_SET_VALUE -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component ENTITY_TIME = "time.time" +@pytest.fixture +async def time_only() -> None: + """Enable only the time platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.TIME], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_datetime(hass: HomeAssistant, disable_platforms) -> None: +async def setup_demo_datetime(hass: HomeAssistant, time_only) -> None: """Initialize setup demo time.""" assert await async_setup_component(hass, DOMAIN, {"time": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_update.py b/tests/components/demo/test_update.py index f53cceafd23..a645c45019c 100644 --- a/tests/components/demo/test_update.py +++ b/tests/components/demo/test_update.py @@ -20,14 +20,25 @@ from homeassistant.const import ( ATTR_ENTITY_PICTURE, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component +@pytest.fixture +async def update_only() -> None: + """Enable only the update platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.UPDATE], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_update(hass: HomeAssistant, disable_platforms) -> None: +async def setup_demo_update(hass: HomeAssistant, update_only) -> None: """Initialize setup demo update entity.""" assert await async_setup_component(hass, DOMAIN, {"update": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index c27ea76c00a..4fab32ac932 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1054,6 +1054,8 @@ async def test_device_class_switch( ) sensor.hass = hass sensor.entity_id = "switch.demo_sensor" + sensor._attr_device_info = None + sensor._attr_name = "Demo Sensor" sensor.async_write_ha_state() result = await sh.async_handle_message( From 7fbeac9bbe60bd3e22b49c6e090b1bcf20bfc2e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:11:09 +0200 Subject: [PATCH 255/857] Set has_entity_name in webostv (#94607) * Set has_entity_name in webostv * Fix log message --- homeassistant/components/webostv/media_player.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 06e7e202b36..6c168417ba0 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -115,13 +115,15 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Representation of a LG webOS Smart TV.""" _attr_device_class = MediaPlayerDeviceClass.TV + _attr_has_entity_name = True def __init__(self, entry: ConfigEntry, client: WebOsClient) -> None: """Initialize the webos device.""" self._entry = entry self._client = client self._attr_assumed_state = True - self._attr_name = entry.title + self._attr_name = None + self._device_name = entry.title self._attr_unique_id = entry.unique_id self._sources = entry.options.get(CONF_SOURCES) @@ -237,7 +239,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, cast(str, self.unique_id))}, manufacturer="LG", - name=self.name, + name=self._device_name, ) if self._client.system_info is not None or self.state != MediaPlayerState.OFF: @@ -376,7 +378,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" if (source_dict := self._source_list.get(source)) is None: - _LOGGER.warning("Source %s not found for %s", source, self.name) + _LOGGER.warning( + "Source %s not found for %s", source, self._friendly_name_internal() + ) return if source_dict.get("title"): await self._client.launch_app(source_dict["id"]) From 5c3ec8774d3db301128635cdee8cbafb1b86a9fb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 14 Jun 2023 14:26:24 -0500 Subject: [PATCH 256/857] Fix async_timeout deprecation warning (#94594) * Fix async_timeout deprecation warning * Combine async with --- homeassistant/components/wyoming/data.py | 31 ++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 3ef93810b6e..c2d71835c65 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -53,23 +53,24 @@ async def load_wyoming_info( for _ in range(retries + 1): try: - async with AsyncTcpClient(host, port) as client: - with async_timeout.timeout(timeout): - # Describe -> Info - await client.write_event(Describe().event()) - while True: - event = await client.read_event() - if event is None: - raise WyomingError( - "Connection closed unexpectedly", - ) + async with AsyncTcpClient(host, port) as client, async_timeout.timeout( + timeout + ): + # Describe -> Info + await client.write_event(Describe().event()) + while True: + event = await client.read_event() + if event is None: + raise WyomingError( + "Connection closed unexpectedly", + ) - if Info.is_type(event.type): - wyoming_info = Info.from_event(event) - break # while + if Info.is_type(event.type): + wyoming_info = Info.from_event(event) + break # while - if wyoming_info is not None: - break # for + if wyoming_info is not None: + break # for except (asyncio.TimeoutError, OSError, WyomingError): # Sleep and try again await asyncio.sleep(retry_wait) From fc068f878b861b8612353e7bc1ad8a797819b6ed Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:41:32 +0200 Subject: [PATCH 257/857] Minor adjustment in sharkiq, webostv, ws66i (#94611) --- homeassistant/components/sharkiq/vacuum.py | 2 +- homeassistant/components/webostv/media_player.py | 2 +- homeassistant/components/ws66i/media_player.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 6ac6428b4ad..9121811af3c 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -69,6 +69,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum _attr_fan_speed_list = list(FAN_SPEEDS_MAP) _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ( VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED @@ -87,7 +88,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum """Create a new SharkVacuumEntity.""" super().__init__(coordinator) self.sharkiq = sharkiq - self._attr_name = None self._attr_unique_id = sharkiq.serial_number self._serial_number = sharkiq.serial_number diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 6c168417ba0..c2851cb4c6e 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -116,13 +116,13 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): _attr_device_class = MediaPlayerDeviceClass.TV _attr_has_entity_name = True + _attr_name = None def __init__(self, entry: ConfigEntry, client: WebOsClient) -> None: """Initialize the webos device.""" self._entry = entry self._client = client self._attr_assumed_state = True - self._attr_name = None self._device_name = entry.title self._attr_unique_id = entry.unique_id self._sources = entry.options.get(CONF_SOURCES) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index d10ac6f2d29..0bf58c249ae 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -45,6 +45,7 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity """Representation of a WS66i amplifier zone.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -64,7 +65,6 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity self._status: ZoneStatus = coordinator.data[data_idx] self._attr_source_list = ws66i_data.sources.name_list self._attr_unique_id = f"{entry_id}_{self._zone_id}" - self._attr_name = None self._attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET From c48afebbfc235f179e80701a3810b7d7932f338a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:41:53 +0200 Subject: [PATCH 258/857] Set has_entity_name in electrasmart (#94602) Co-authored-by: Franck Nijhof --- homeassistant/components/electrasmart/climate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index a9688939048..c35f3523188 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -109,12 +109,13 @@ class ElectraClimateEntity(ClimateEntity): _attr_min_temp = MIN_TEMP _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = ELECTRA_MODES + _attr_has_entity_name = True + _attr_name = None def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: """Initialize Electra climate entity.""" self._api = api self._electra_ac_device = device - self._attr_name = device.name self._attr_unique_id = device.mac self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE @@ -140,7 +141,7 @@ class ElectraClimateEntity(ClimateEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._electra_ac_device.mac)}, - name=self.name, + name=device.name, model=self._electra_ac_device.model, manufacturer=self._electra_ac_device.manufactor, ) From fa9c31d3dbea894ac31d28576d3a84981df56cfe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:42:51 +0200 Subject: [PATCH 259/857] Set has_entity_name in freedompro (#94603) Co-authored-by: Franck Nijhof --- homeassistant/components/freedompro/binary_sensor.py | 6 ++++-- homeassistant/components/freedompro/climate.py | 5 +++-- homeassistant/components/freedompro/cover.py | 6 ++++-- homeassistant/components/freedompro/fan.py | 6 ++++-- homeassistant/components/freedompro/light.py | 6 ++++-- homeassistant/components/freedompro/lock.py | 6 ++++-- homeassistant/components/freedompro/sensor.py | 6 ++++-- homeassistant/components/freedompro/switch.py | 6 ++++-- 8 files changed, 31 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py index c56d3cb2ad8..e648f35d730 100644 --- a/homeassistant/components/freedompro/binary_sensor.py +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -46,12 +46,14 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], BinarySensorEntity): """Representation of an Freedompro binary_sensor.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, device: dict[str, Any], coordinator: FreedomproDataUpdateCoordinator ) -> None: """Initialize the Freedompro binary_sensor.""" super().__init__(coordinator) - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._type = device["type"] self._attr_device_info = DeviceInfo( @@ -60,7 +62,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], BinarySensorEnt }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 0ec08f0fdd0..058b32f932a 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -60,6 +60,7 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): """Representation of an Freedompro climate.""" + _attr_has_entity_name = True _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -74,7 +75,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): super().__init__(coordinator) self._session = session self._api_key = api_key - self._attr_name = device["name"] + self._attr_name = None self._attr_unique_id = device["uid"] self._characteristics = device["characteristics"] self._attr_device_info = DeviceInfo( @@ -83,7 +84,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_current_temperature = 0 diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index 3839415d31b..03670d06e67 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -48,6 +48,9 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], CoverEntity): """Representation of an Freedompro cover.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, hass: HomeAssistant, @@ -59,7 +62,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], CoverEntity): super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._attr_device_info = DeviceInfo( identifiers={ @@ -67,7 +69,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], CoverEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_current_cover_position = 0 self._attr_is_closed = True diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index 036c6c91471..0a360f637e2 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -35,6 +35,9 @@ async def async_setup_entry( class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntity): """Representation of an Freedompro fan.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, hass: HomeAssistant, @@ -46,7 +49,6 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._characteristics = device["characteristics"] self._attr_device_info = DeviceInfo( @@ -55,7 +57,7 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_is_on = False self._attr_percentage = 0 diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index 7dc573f9225..4221331adf0 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -40,6 +40,9 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LightEntity): """Representation of an Freedompro light.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, hass: HomeAssistant, @@ -51,13 +54,12 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LightEntity): super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device["uid"])}, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_is_on = False self._attr_brightness = 0 diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index d803354c255..099baab7126 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -33,6 +33,9 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LockEntity): """Representation of an Freedompro lock.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, hass: HomeAssistant, @@ -45,7 +48,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LockEntity): self._hass = hass self._session = aiohttp_client.async_get_clientsession(self._hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._type = device["type"] self._characteristics = device["characteristics"] @@ -55,7 +57,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LockEntity): }, manufacturer="Freedompro", model=self._type, - name=self.name, + name=device["name"], ) @callback diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index 286a528013a..f95e0436ab4 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -54,12 +54,14 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SensorEntity): """Representation of an Freedompro sensor.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, device: dict[str, Any], coordinator: FreedomproDataUpdateCoordinator ) -> None: """Initialize the Freedompro sensor.""" super().__init__(coordinator) - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._type = device["type"] self._attr_device_info = DeviceInfo( @@ -68,7 +70,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SensorEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] self._attr_state_class = STATE_CLASS_MAP[device["type"]] diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py index 4a7ed80de1e..d1f8e33765a 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -33,6 +33,9 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SwitchEntity): """Representation of an Freedompro switch.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, hass: HomeAssistant, @@ -44,7 +47,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SwitchEntity): super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._attr_device_info = DeviceInfo( identifiers={ @@ -52,7 +54,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SwitchEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_is_on = False From cc53e4e6c192f1d8c13375a6b0ccb4070b855593 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:44:17 +0200 Subject: [PATCH 260/857] Set has_entity_name in kodi (#94604) Co-authored-by: Franck Nijhof --- homeassistant/components/kodi/media_player.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 86788db6ae6..af4e5700805 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -259,6 +259,8 @@ def cmd( class KodiEntity(MediaPlayerEntity): """Representation of a XBMC/Kodi device.""" + _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.NEXT_TRACK @@ -290,7 +292,11 @@ class KodiEntity(MediaPlayerEntity): self._media_position = None self._connect_error = False - self._attr_name = name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, uid)}, + manufacturer="Kodi", + name=name, + ) def _reset_state(self, players=None): self._players = players @@ -368,15 +374,6 @@ class KodiEntity(MediaPlayerEntity): """Return the unique id of the device.""" return self._unique_id - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Kodi", - name=self.name, - ) - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" From e539344d22e89de65523a7af95fb5ce60b09de8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 14 Jun 2023 21:45:14 +0200 Subject: [PATCH 261/857] Set has_entity_name in mill (#94605) --- homeassistant/components/mill/climate.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 42b759b3cdf..f1487ed59f1 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -91,8 +91,10 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): """Representation of a Mill Thermostat device.""" _attr_fan_modes = [FAN_ON, FAN_OFF] + _attr_has_entity_name = True _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP + _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__( @@ -106,12 +108,11 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): self._id = heater.device_id self._attr_unique_id = heater.device_id - self._attr_name = heater.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, heater.device_id)}, manufacturer=MANUFACTURER, model=f"Generation {heater.generation}", - name=self.name, + name=heater.name, ) if heater.is_gen1: self._attr_hvac_modes = [HVACMode.HEAT] @@ -202,10 +203,12 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): """Representation of a Mill Thermostat device.""" + _attr_has_entity_name = True _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP + _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -213,7 +216,6 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: """Initialize the thermostat.""" super().__init__(coordinator) - self._attr_name = coordinator.mill_data_connection.name if mac := coordinator.mill_data_connection.mac_address: self._attr_unique_id = mac self._attr_device_info = DeviceInfo( From e998320053eede9fa46a22a67a5fe63f0b14ef68 Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Wed, 14 Jun 2023 13:06:55 -0700 Subject: [PATCH 262/857] Fix keyboard_remote for python 3.11 (#94570) * started work to update keyboard_remote to work with python 3.11 * updated function names * all checks pass * fixed asyncio for python 3.11 * cleanup * Update homeassistant/components/keyboard_remote/__init__.py Co-authored-by: Martin Hjelmare * Update __init__.py added: from __future__ import annotations * Fix typing --------- Co-authored-by: Martin Hjelmare --- .../components/keyboard_remote/__init__.py | 81 +++++++++++-------- .../components/keyboard_remote/manifest.json | 2 +- requirements_all.txt | 8 +- 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index f0f1497f940..e3d280c2944 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -1,11 +1,14 @@ """Receive signals from a keyboard and use it as a remote control.""" # pylint: disable=import-error +from __future__ import annotations + import asyncio from contextlib import suppress import logging import os +from typing import Any -import aionotify +from asyncinotify import Inotify, Mask from evdev import InputDevice, categorize, ecodes, list_devices import voluptuous as vol @@ -64,9 +67,9 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the keyboard_remote.""" - config = config[DOMAIN] + domain_config: list[dict[str, Any]] = config[DOMAIN] - remote = KeyboardRemote(hass, config) + remote = KeyboardRemote(hass, domain_config) remote.setup() return True @@ -75,12 +78,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class KeyboardRemote: """Manage device connection/disconnection using inotify to asynchronously monitor.""" - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: list[dict[str, Any]]) -> None: """Create handlers and setup dictionaries to keep track of them.""" self.hass = hass self.handlers_by_name = {} self.handlers_by_descriptor = {} - self.active_handlers_by_descriptor = {} + self.active_handlers_by_descriptor: dict[str, asyncio.Future] = {} + self.inotify = None self.watcher = None self.monitor_task = None @@ -110,16 +114,12 @@ class KeyboardRemote: connected, and start monitoring for device connection/disconnection. """ - # start watching - self.watcher = aionotify.Watcher() - self.watcher.watch( - alias="devinput", - path=DEVINPUT, - flags=aionotify.Flags.CREATE - | aionotify.Flags.ATTRIB - | aionotify.Flags.DELETE, + _LOGGER.debug("Start monitoring") + + self.inotify = Inotify() + self.watcher = self.inotify.add_watch( + DEVINPUT, Mask.CREATE | Mask.ATTRIB | Mask.DELETE ) - await self.watcher.setup(self.hass.loop) # add initial devices (do this AFTER starting watcher in order to # avoid race conditions leading to missing device connections) @@ -134,7 +134,9 @@ class KeyboardRemote: continue self.active_handlers_by_descriptor[descriptor] = handler - initial_start_monitoring.add(handler.async_start_monitoring(dev)) + initial_start_monitoring.add( + asyncio.create_task(handler.async_device_start_monitoring(dev)) + ) if initial_start_monitoring: await asyncio.wait(initial_start_monitoring) @@ -146,6 +148,10 @@ class KeyboardRemote: _LOGGER.debug("Cleanup on shutdown") + if self.inotify and self.watcher: + self.inotify.rm_watch(self.watcher) + self.watcher = None + if self.monitor_task is not None: if not self.monitor_task.done(): self.monitor_task.cancel() @@ -153,11 +159,16 @@ class KeyboardRemote: handler_stop_monitoring = set() for handler in self.active_handlers_by_descriptor.values(): - handler_stop_monitoring.add(handler.async_stop_monitoring()) - + handler_stop_monitoring.add( + asyncio.create_task(handler.async_device_stop_monitoring()) + ) if handler_stop_monitoring: await asyncio.wait(handler_stop_monitoring) + if self.inotify: + self.inotify.close() + self.inotify = None + def get_device_handler(self, descriptor): """Find the correct device handler given a descriptor (path).""" @@ -187,20 +198,21 @@ class KeyboardRemote: async def async_monitor_devices(self): """Monitor asynchronously for device connection/disconnection or permissions changes.""" + _LOGGER.debug("Start monitoring loop") + try: - while True: - event = await self.watcher.get_event() + async for event in self.inotify: descriptor = f"{DEVINPUT}/{event.name}" + _LOGGER.debug("got events for %s: %s", descriptor, event.mask) descriptor_active = descriptor in self.active_handlers_by_descriptor - if (event.flags & aionotify.Flags.DELETE) and descriptor_active: + if (event.mask & Mask.DELETE) and descriptor_active: handler = self.active_handlers_by_descriptor[descriptor] del self.active_handlers_by_descriptor[descriptor] - await handler.async_stop_monitoring() + await handler.async_device_stop_monitoring() elif ( - (event.flags & aionotify.Flags.CREATE) - or (event.flags & aionotify.Flags.ATTRIB) + (event.mask & Mask.CREATE) or (event.mask & Mask.ATTRIB) ) and not descriptor_active: dev, handler = await self.hass.async_add_executor_job( self.get_device_handler, descriptor @@ -208,31 +220,32 @@ class KeyboardRemote: if handler is None: continue self.active_handlers_by_descriptor[descriptor] = handler - await handler.async_start_monitoring(dev) + await handler.async_device_start_monitoring(dev) except asyncio.CancelledError: + _LOGGER.debug("Monitoring canceled") return class DeviceHandler: """Manage input events using evdev with asyncio.""" - def __init__(self, hass, dev_block): + def __init__(self, hass: HomeAssistant, dev_block: dict[str, Any]) -> None: """Fill configuration data.""" self.hass = hass - key_types = dev_block.get(TYPE) + key_types = dev_block[TYPE] self.key_values = set() for key_type in key_types: self.key_values.add(KEY_VALUE[key_type]) - self.emulate_key_hold = dev_block.get(EMULATE_KEY_HOLD) - self.emulate_key_hold_delay = dev_block.get(EMULATE_KEY_HOLD_DELAY) - self.emulate_key_hold_repeat = dev_block.get(EMULATE_KEY_HOLD_REPEAT) + self.emulate_key_hold = dev_block[EMULATE_KEY_HOLD] + self.emulate_key_hold_delay = dev_block[EMULATE_KEY_HOLD_DELAY] + self.emulate_key_hold_repeat = dev_block[EMULATE_KEY_HOLD_REPEAT] self.monitor_task = None self.dev = None - async def async_keyrepeat(self, path, name, code, delay, repeat): + async def async_device_keyrepeat(self, path, name, code, delay, repeat): """Emulate keyboard delay/repeat behaviour by sending key events on a timer.""" await asyncio.sleep(delay) @@ -248,8 +261,9 @@ class KeyboardRemote: ) await asyncio.sleep(repeat) - async def async_start_monitoring(self, dev): + async def async_device_start_monitoring(self, dev): """Start event monitoring task and issue event.""" + _LOGGER.debug("Keyboard async_device_start_monitoring, %s", dev.name) if self.monitor_task is None: self.dev = dev self.monitor_task = self.hass.async_create_task( @@ -261,7 +275,7 @@ class KeyboardRemote: ) _LOGGER.debug("Keyboard (re-)connected, %s", dev.name) - async def async_stop_monitoring(self): + async def async_device_stop_monitoring(self): """Stop event monitoring task and issue event.""" if self.monitor_task is not None: with suppress(OSError): @@ -295,6 +309,7 @@ class KeyboardRemote: _LOGGER.debug("Start device monitoring") await self.hass.async_add_executor_job(dev.grab) async for event in dev.async_read_loop(): + # pylint: disable=no-member if event.type is ecodes.EV_KEY: if event.value in self.key_values: _LOGGER.debug(categorize(event)) @@ -313,7 +328,7 @@ class KeyboardRemote: and self.emulate_key_hold ): repeat_tasks[event.code] = self.hass.async_create_task( - self.async_keyrepeat( + self.async_device_keyrepeat( dev.path, dev.name, event.code, diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index d319ba93ce2..2b298901ca9 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/keyboard_remote", "iot_class": "local_push", "loggers": ["aionotify", "evdev"], - "requirements": ["evdev==1.4.0", "aionotify==0.2.0"] + "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index eac5083a293..8f360bcdf80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -297,9 +297,6 @@ aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 -# homeassistant.components.keyboard_remote -aionotify==0.2.0 - # homeassistant.components.notion aionotion==2023.05.5 @@ -451,6 +448,9 @@ asterisk-mbox==0.5.0 # homeassistant.components.yeelight async-upnp-client==0.33.2 +# homeassistant.components.keyboard_remote +asyncinotify==4.0.2 + # homeassistant.components.supla asyncpysupla==0.0.5 @@ -758,7 +758,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.7 # homeassistant.components.keyboard_remote -# evdev==1.4.0 +# evdev==1.6.1 # homeassistant.components.evohome evohome-async==0.3.15 From b149fffa088ef108f4854b0d58070c563211fbf0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Jun 2023 10:25:26 -1000 Subject: [PATCH 263/857] Bump bluetooth-data-tools to 1.1.0 (#94610) Bume bluetooth-data-tools to 1.1.0 performance improvements https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.0.0...v1.1.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 25bd2651c6f..1faa546744f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.2.0", - "bluetooth-data-tools==1.0.0", + "bluetooth-data-tools==1.1.0", "dbus-fast==1.86.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 64e4b2dcce2..f2b98387f94 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==14.0.0", - "bluetooth-data-tools==1.0.0", + "bluetooth-data-tools==1.1.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 7107a365d72..7936bd64efd 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble/", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.0.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.1.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 37257e0a604..f0fc0981065 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble/", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.0.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.1.0", "led-ble==1.0.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 48c947f62f3..894b98d3806 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.0.2 bleak==0.20.2 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.2.0 -bluetooth-data-tools==1.0.0 +bluetooth-data-tools==1.1.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==40.0.2 diff --git a/requirements_all.txt b/requirements_all.txt index 8f360bcdf80..55a1d57cbab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -540,7 +540,7 @@ bluetooth-auto-recovery==1.2.0 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.0.0 +bluetooth-data-tools==1.1.0 # homeassistant.components.bond bond-async==0.1.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 249c3b284f1..53a314f7d05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ bluetooth-auto-recovery==1.2.0 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.0.0 +bluetooth-data-tools==1.1.0 # homeassistant.components.bond bond-async==0.1.23 From a79e37c2408aab6ebf1a673600ef085415f77be8 Mon Sep 17 00:00:00 2001 From: disforw Date: Wed, 14 Jun 2023 16:42:49 -0400 Subject: [PATCH 264/857] Add coordinator to QNAP (#94413) * Create coordinator.py * Update sensor.py * Update sensor.py * Update sensor.py * Update homeassistant/components/qnap/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add import * Update coordinator.py * Update coordinator.py * Add platformnotready * Walrus * Update sensor.py * Undo walres CI didnt like it * Update coordinator.py black fix * Update sensor.py fix black * Update sensor.py fix ruff * Update coordinator.py fix lint * Update sensor.py fix ruff * Update homeassistant/components/qnap/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update sensor.py * Update coordinator.py * Update coordinator.py * Update sensor.py * Update homeassistant/components/qnap/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update sensor.py --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/qnap/coordinator.py | 59 +++++++++ homeassistant/components/qnap/sensor.py | 132 +++++++------------ 2 files changed, 104 insertions(+), 87 deletions(-) create mode 100644 homeassistant/components/qnap/coordinator.py diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py new file mode 100644 index 00000000000..bcf0820615d --- /dev/null +++ b/homeassistant/components/qnap/coordinator.py @@ -0,0 +1,59 @@ +"""Data coordinator for the qnap integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from qnapstats import QNAPStats + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_TIMEOUT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +UPDATE_INTERVAL = timedelta(minutes=1) + +_LOGGER = logging.getLogger(__name__) + + +class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Custom coordinator for the qnap integration.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize the qnap coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + protocol = "https" if config[CONF_SSL] else "http" + self._api = QNAPStats( + f"{protocol}://{config.get(CONF_HOST)}", + config.get(CONF_PORT), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + verify_ssl=config.get(CONF_VERIFY_SSL), + timeout=config.get(CONF_TIMEOUT), + ) + + def _sync_update(self) -> dict[str, dict[str, Any]]: + """Get the latest data from the Qnap API.""" + return { + "system_stats": self._api.get_system_stats(), + "system_health": self._api.get_system_health(), + "smart_drive_health": self._api.get_smart_disk_health(), + "volumes": self._api.get_volumes(), + "bandwidth": self._api.get_bandwidth(), + } + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Get the latest data from the Qnap API.""" + return await self.hass.async_add_executor_job(self._sync_update) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index ca81b2763fa..85b9167243f 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,10 +1,6 @@ """Support for QNAP NAS Sensors.""" -from __future__ import annotations - -from datetime import timedelta import logging -from qnapstats import QNAPStats import voluptuous as vol from homeassistant.components.sensor import ( @@ -33,9 +29,10 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_PORT, DEFAULT_TIMEOUT +from .coordinator import QnapCoordinator _LOGGER = logging.getLogger(__name__) @@ -59,8 +56,6 @@ CONF_DRIVES = "drives" CONF_NICS = "nics" CONF_VOLUMES = "volumes" -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) - NOTIFICATION_ID = "qnap_notification" NOTIFICATION_TITLE = "QNAP Sensor Setup" @@ -201,18 +196,16 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the QNAP NAS sensor.""" - api = QNAPStatsAPI(config) - api.update() - - # QNAP is not available - if not api.data: + coordinator = QnapCoordinator(hass, config) + await coordinator.async_refresh() + if not coordinator.last_update_success: raise PlatformNotReady monitored_conditions = config[CONF_MONITORED_CONDITIONS] @@ -221,21 +214,21 @@ def setup_platform( # Basic sensors sensors.extend( [ - QNAPSystemSensor(api, description) + QNAPSystemSensor(coordinator, description) for description in _SYSTEM_MON_COND if description.key in monitored_conditions ] ) sensors.extend( [ - QNAPCPUSensor(api, description) + QNAPCPUSensor(coordinator, description) for description in _CPU_MON_COND if description.key in monitored_conditions ] ) sensors.extend( [ - QNAPMemorySensor(api, description) + QNAPMemorySensor(coordinator, description) for description in _MEMORY_MON_COND if description.key in monitored_conditions ] @@ -244,8 +237,8 @@ def setup_platform( # Network sensors sensors.extend( [ - QNAPNetworkSensor(api, description, nic) - for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]) + QNAPNetworkSensor(coordinator, description, nic) + for nic in config.get(CONF_NICS, coordinator.data["system_stats"]["nics"]) for description in _NETWORK_MON_COND if description.key in monitored_conditions ] @@ -254,8 +247,8 @@ def setup_platform( # Drive sensors sensors.extend( [ - QNAPDriveSensor(api, description, drive) - for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]) + QNAPDriveSensor(coordinator, description, drive) + for drive in config.get(CONF_DRIVES, coordinator.data["smart_drive_health"]) for description in _DRIVE_MON_COND if description.key in monitored_conditions ] @@ -264,8 +257,8 @@ def setup_platform( # Volume sensors sensors.extend( [ - QNAPVolumeSensor(api, description, volume) - for volume in config.get(CONF_VOLUMES, api.data["volumes"]) + QNAPVolumeSensor(coordinator, description, volume) + for volume in config.get(CONF_VOLUMES, coordinator.data["volumes"]) for description in _VOLUME_MON_COND if description.key in monitored_conditions ] @@ -284,62 +277,27 @@ def round_nicely(number): return round(number) -class QNAPStatsAPI: - """Class to interface with the API.""" - - def __init__(self, config): - """Initialize the API wrapper.""" - - protocol = "https" if config[CONF_SSL] else "http" - self._api = QNAPStats( - f"{protocol}://{config.get(CONF_HOST)}", - config.get(CONF_PORT), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - verify_ssl=config.get(CONF_VERIFY_SSL), - timeout=config.get(CONF_TIMEOUT), - ) - - self.data = {} - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update API information and store locally.""" - try: - self.data["system_stats"] = self._api.get_system_stats() - self.data["system_health"] = self._api.get_system_health() - self.data["smart_drive_health"] = self._api.get_smart_disk_health() - self.data["volumes"] = self._api.get_volumes() - self.data["bandwidth"] = self._api.get_bandwidth() - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to fetch QNAP stats from the NAS") - - -class QNAPSensor(SensorEntity): +class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): """Base class for a QNAP sensor.""" def __init__( - self, api, description: SensorEntityDescription, monitor_device=None + self, + coordinator: QnapCoordinator, + description: SensorEntityDescription, + monitor_device: str | None = None, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description self.monitor_device = monitor_device - self._api = api + self.device_name = self.coordinator.data["system_stats"]["system"]["name"] @property def name(self): """Return the name of the sensor, if any.""" - server_name = self._api.data["system_stats"]["system"]["name"] - if self.monitor_device is not None: - return ( - f"{server_name} {self.entity_description.name} ({self.monitor_device})" - ) - return f"{server_name} {self.entity_description.name}" - - def update(self) -> None: - """Get the latest data for the states.""" - self._api.update() + return f"{self.device_name} {self.entity_description.name} ({self.monitor_device})" + return f"{self.device_name} {self.entity_description.name}" class QNAPCPUSensor(QNAPSensor): @@ -349,9 +307,9 @@ class QNAPCPUSensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "cpu_temp": - return self._api.data["system_stats"]["cpu"]["temp_c"] + return self.coordinator.data["system_stats"]["cpu"]["temp_c"] if self.entity_description.key == "cpu_usage": - return self._api.data["system_stats"]["cpu"]["usage_percent"] + return self.coordinator.data["system_stats"]["cpu"]["usage_percent"] class QNAPMemorySensor(QNAPSensor): @@ -360,11 +318,11 @@ class QNAPMemorySensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - free = float(self._api.data["system_stats"]["memory"]["free"]) / 1024 + free = float(self.coordinator.data["system_stats"]["memory"]["free"]) / 1024 if self.entity_description.key == "memory_free": return round_nicely(free) - total = float(self._api.data["system_stats"]["memory"]["total"]) / 1024 + total = float(self.coordinator.data["system_stats"]["memory"]["total"]) / 1024 used = total - free if self.entity_description.key == "memory_used": @@ -376,8 +334,8 @@ class QNAPMemorySensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["system_stats"]["memory"] + if self.coordinator.data: + data = self.coordinator.data["system_stats"]["memory"] size = round_nicely(float(data["total"]) / 1024) return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"} @@ -389,10 +347,10 @@ class QNAPNetworkSensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "network_link_status": - nic = self._api.data["system_stats"]["nics"][self.monitor_device] + nic = self.coordinator.data["system_stats"]["nics"][self.monitor_device] return nic["link_status"] - data = self._api.data["bandwidth"][self.monitor_device] + data = self.coordinator.data["bandwidth"][self.monitor_device] if self.entity_description.key == "network_tx": return round_nicely(data["tx"] / 1024 / 1024) @@ -402,8 +360,8 @@ class QNAPNetworkSensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["system_stats"]["nics"][self.monitor_device] + if self.coordinator.data: + data = self.coordinator.data["system_stats"]["nics"][self.monitor_device] return { ATTR_IP: data["ip"], ATTR_MASK: data["mask"], @@ -422,16 +380,16 @@ class QNAPSystemSensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "status": - return self._api.data["system_health"] + return self.coordinator.data["system_health"] if self.entity_description.key == "system_temp": - return int(self._api.data["system_stats"]["system"]["temp_c"]) + return int(self.coordinator.data["system_stats"]["system"]["temp_c"]) @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["system_stats"] + if self.coordinator.data: + data = self.coordinator.data["system_stats"] days = int(data["uptime"]["days"]) hours = int(data["uptime"]["hours"]) minutes = int(data["uptime"]["minutes"]) @@ -450,7 +408,7 @@ class QNAPDriveSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - data = self._api.data["smart_drive_health"][self.monitor_device] + data = self.coordinator.data["smart_drive_health"][self.monitor_device] if self.entity_description.key == "drive_smart_status": return data["health"] @@ -461,7 +419,7 @@ class QNAPDriveSensor(QNAPSensor): @property def name(self): """Return the name of the sensor, if any.""" - server_name = self._api.data["system_stats"]["system"]["name"] + server_name = self.coordinator.data["system_stats"]["system"]["name"] return ( f"{server_name} {self.entity_description.name} (Drive" @@ -471,8 +429,8 @@ class QNAPDriveSensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["smart_drive_health"][self.monitor_device] + if self.coordinator.data: + data = self.coordinator.data["smart_drive_health"][self.monitor_device] return { ATTR_DRIVE: data["drive_number"], ATTR_MODEL: data["model"], @@ -487,7 +445,7 @@ class QNAPVolumeSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - data = self._api.data["volumes"][self.monitor_device] + data = self.coordinator.data["volumes"][self.monitor_device] free_gb = int(data["free_size"]) / 1024 / 1024 / 1024 if self.entity_description.key == "volume_size_free": @@ -505,8 +463,8 @@ class QNAPVolumeSensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["volumes"][self.monitor_device] + if self.coordinator.data: + data = self.coordinator.data["volumes"][self.monitor_device] total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 return { From 7b3f100efb456d700e0b1d183060b21918f4f8d8 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 14 Jun 2023 21:00:21 +0000 Subject: [PATCH 265/857] Catch InvalidAuthError in `shutdown()` method for Shelly gen2 devices (#94563) * Catch InvalidAuthError in shutdown() method * Add test * Revert unwanted change in tests --- .../components/shelly/coordinator.py | 5 +- tests/components/shelly/test_coordinator.py | 55 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 85207ee4475..6d7b3496880 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -535,7 +535,10 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): async def shutdown(self) -> None: """Shutdown the coordinator.""" if self.device.connected: - await async_stop_scanner(self.device) + try: + await async_stop_scanner(self.device) + except InvalidAuthError: + self.entry.async_start_reauth(self.hass) await self.device.shutdown() await self._async_disconnected() diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 2f267a208ca..9039893999d 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -1,6 +1,6 @@ """Tests for Shelly coordinator.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError @@ -335,6 +335,59 @@ async def test_rpc_reload_on_cfg_change( assert hass.states.get("switch.test_switch_0") is None +async def test_rpc_reload_with_invalid_auth( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC when InvalidAuthError is raising during config entry reload.""" + with patch( + "homeassistant.components.shelly.coordinator.async_stop_scanner", + side_effect=[None, InvalidAuthError, None], + ): + entry = await init_integration(hass, 2) + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "data": [], + "event": "config_changed", + "id": 1, + "ts": 1668522399.2, + }, + { + "data": [], + "id": 2, + "ts": 1668522399.2, + }, + ], + "ts": 1668522399.2, + }, + ) + + await hass.async_block_till_done() + + # Move time to generate reconnect + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) + ) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + async def test_rpc_click_event( hass: HomeAssistant, mock_rpc_device, events, monkeypatch ) -> None: From e0ae7a31fe1cc4eec71147c38b3f042d3afaf8ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Jun 2023 15:31:52 -1000 Subject: [PATCH 266/857] Remove _raw from zeroconf properties (#94615) * Remove _raw from zeroconf properties This was added in #31059 but it appears it was never used. To preserve backwards compatibility, properties are still decoded but utf-8 errors are replaced instead of dropped * Remove _raw from zeroconf properties This was added in #31059 but it appears it was never used. To preserve backwards compatibility, properties are still decoded but utf-8 errors are replaced instead of dropped --- homeassistant/components/zeroconf/__init__.py | 29 ++++++------------- tests/components/zeroconf/test_init.py | 12 ++++---- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index f12752dc5c3..927f0b6db3a 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -581,26 +581,9 @@ def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str: def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" - properties: dict[str, Any] = {"_raw": {}} - - for key, value in service.properties.items(): - # See https://ietf.org/rfc/rfc6763.html#section-6.4 and - # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings - # for property keys and values - try: - key = key.decode("ascii") - except UnicodeDecodeError: - _LOGGER.debug( - "Ignoring invalid key provided by [%s]: %s", service.name, key - ) - continue - - properties["_raw"][key] = value - - with suppress(UnicodeDecodeError): - if isinstance(value, bytes): - properties[key] = value.decode("utf-8") - + # See https://ietf.org/rfc/rfc6763.html#section-6.4 and + # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings + # for property keys and values if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None host: str | None = None @@ -610,6 +593,12 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: break if not host: return None + properties: dict[str, Any] = { + k.decode("ascii", "replace"): None + if v is None + else v.decode("utf-8", "replace") + for k, v in service.properties.items() + } assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index fea6b27e208..bd39c00df98 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -910,13 +910,11 @@ async def test_info_from_service_non_utf8(hass: HomeAssistant) -> None: info = zeroconf.info_from_service( get_service_info_mock(service_type, f"test.{service_type}") ) - raw_info = info.properties.pop("_raw", False) - assert raw_info - assert len(raw_info) == len(PROPERTIES) - 1 - assert NON_ASCII_KEY not in raw_info - assert len(info.properties) <= len(raw_info) - assert "non-utf8-value" not in info.properties - assert raw_info["non-utf8-value"] is NON_UTF8_VALUE + assert NON_ASCII_KEY.decode("ascii", "replace") in info.properties + assert "non-utf8-value" in info.properties + assert info.properties["non-utf8-value"] == NON_UTF8_VALUE.decode( + "utf-8", "replace" + ) async def test_info_from_service_with_addresses(hass: HomeAssistant) -> None: From 584967a35af09c73f7c64cbf61b42d5cb02432ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Jun 2023 15:33:05 -1000 Subject: [PATCH 267/857] Avoid double call to self.suggested_unit_of_measurement in sensor unit_of_measurement (#94582) --- homeassistant/components/sensor/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index f21f57d9d36..c796ad55421 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -443,8 +443,10 @@ class SensorEntity(Entity): return self._sensor_option_unit_of_measurement # Second priority, for non registered entities: unit suggested by integration - if not self.unique_id and self.suggested_unit_of_measurement: - return self.suggested_unit_of_measurement + if not self.unique_id and ( + suggested_unit_of_measurement := self.suggested_unit_of_measurement + ): + return suggested_unit_of_measurement # Third priority: Legacy temperature conversion, which applies # to both registered and non registered entities From 22dfa8797fef9862ed6fde3b3b4fe810ae23fe3f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 14 Jun 2023 21:42:31 -0400 Subject: [PATCH 268/857] Bump ZHA dependencies (#93989) * Make `find_entity_id` synchronous * Remove `tries` * Use new `attribute_updated` event signature * Validate attributes before creating entities * Avoid swallowing exceptions when opening covers * Bump ZHA dependencies * Add a matcher for Sinope water leak sensors using a non-standard ZCL attribute * Ensure handler matching is strict, not multi * Add type annotations for newly-updated functions --- homeassistant/components/zha/binary_sensor.py | 9 ++ .../zha/core/cluster_handlers/__init__.py | 45 ++----- .../zha/core/cluster_handlers/closures.py | 6 +- .../zha/core/cluster_handlers/general.py | 26 ++-- .../zha/core/cluster_handlers/hvac.py | 6 +- .../cluster_handlers/manufacturerspecific.py | 6 +- .../zha/core/cluster_handlers/security.py | 2 +- homeassistant/components/zha/cover.py | 8 +- homeassistant/components/zha/manifest.json | 4 +- homeassistant/components/zha/number.py | 1 + homeassistant/components/zha/select.py | 1 + homeassistant/components/zha/sensor.py | 15 ++- homeassistant/components/zha/switch.py | 1 + requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/zha/common.py | 2 +- .../zha/test_alarm_control_panel.py | 2 +- tests/components/zha/test_binary_sensor.py | 8 +- tests/components/zha/test_button.py | 24 ++-- tests/components/zha/test_climate.py | 54 ++++----- tests/components/zha/test_cover.py | 112 +++++++++--------- tests/components/zha/test_device_action.py | 2 - tests/components/zha/test_device_tracker.py | 2 +- tests/components/zha/test_discover.py | 41 ------- tests/components/zha/test_fan.py | 12 +- tests/components/zha/test_light.py | 58 ++------- tests/components/zha/test_lock.py | 28 ++--- tests/components/zha/test_number.py | 6 +- tests/components/zha/test_select.py | 12 +- tests/components/zha/test_sensor.py | 4 +- tests/components/zha/test_siren.py | 2 +- tests/components/zha/test_switch.py | 22 ++-- 32 files changed, 221 insertions(+), 308 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 1c29f619719..48fbf1f0bb2 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -236,6 +236,15 @@ class IASZone(BinarySensor): ) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ZONE, models={"WL4200", "WL4200S"}) +class SinopeLeakStatus(BinarySensor): + """Sinope water leak sensor.""" + + SENSOR_ATTR = "leak_status" + _attr_name = "Moisture" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + + @MULTI_MATCH( cluster_handler_names="tuya_manufacturer", manufacturers={ diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 7863b043455..dcf8f2a525e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio from enum import Enum -from functools import partialmethod, wraps +from functools import partialmethod import logging from typing import TYPE_CHECKING, Any, TypedDict import zigpy.exceptions +import zigpy.util import zigpy.zcl from zigpy.zcl.foundation import ( CommandSchema, @@ -45,6 +46,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +retry_request = zigpy.util.retryable_request(tries=3) + class AttrReportConfig(TypedDict, total=True): """Configuration to report for the attributes.""" @@ -73,35 +76,6 @@ def parse_and_log_command(cluster_handler, tsn, command_id, args): return name -def decorate_command(cluster_handler, command): - """Wrap a cluster command to make it safe.""" - - @wraps(command) - async def wrapper(*args, **kwds): - try: - result = await command(*args, **kwds) - cluster_handler.debug( - "executed '%s' command with args: '%s' kwargs: '%s' result: %s", - command.__name__, - args, - kwds, - result, - ) - return result - - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: - cluster_handler.debug( - "command failed: '%s' args: '%s' kwargs '%s' exception: '%s'", - command.__name__, - args, - kwds, - str(ex), - ) - return ex - - return wrapper - - class ClusterHandlerStatus(Enum): """Status of a cluster handler.""" @@ -119,7 +93,7 @@ class ClusterHandler(LogMixin): # Dict of attributes to read on cluster handler initialization. # Dict keys -- attribute ID or names, with bool value indicating whether a cached # attribute read is acceptable. - ZCL_INIT_ATTRS: dict[int | str, bool] = {} + ZCL_INIT_ATTRS: dict[str, bool] = {} def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize ClusterHandler.""" @@ -396,7 +370,7 @@ class ClusterHandler(LogMixin): """Handle commands received to this cluster.""" @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", @@ -462,7 +436,7 @@ class ClusterHandler(LogMixin): async def _get_attributes( self, raise_exceptions: bool, - attributes: list[int | str], + attributes: list[str], from_cache: bool = True, only_cache: bool = True, ) -> dict[int | str, Any]: @@ -510,7 +484,8 @@ class ClusterHandler(LogMixin): if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)): command = getattr(self._cluster, name) command.__name__ = name - return decorate_command(self, command) + + return retry_request(command) return self.__getattribute__(name) @@ -568,7 +543,7 @@ class ClientClusterHandler(ClusterHandler): """ClusterHandler for Zigbee client (output) clusters.""" @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle an attribute updated on this cluster.""" try: diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 0c1ca20ae96..4262a16800d 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,4 +1,6 @@ """Closures cluster handlers module for Zigbee Home Automation.""" +from typing import Any + from zigpy.zcl.clusters import closures from homeassistant.core import callback @@ -48,7 +50,7 @@ class DoorLockClusterHandler(ClusterHandler): ) @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from lock cluster.""" attr_name = self._get_attribute_name(attrid) self.debug( @@ -144,7 +146,7 @@ class WindowCovering(ClusterHandler): ) @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from window_covering cluster.""" attr_name = self._get_attribute_name(attrid) self.debug( diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 8f47eda6e71..622c9e4340e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -297,7 +297,7 @@ class LevelControlClusterHandler(ClusterHandler): ) @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" self.debug("received attribute: %s update with value: %s", attrid, value) if attrid == self.CURRENT_LEVEL: @@ -358,7 +358,7 @@ class OnOffClusterHandler(ClusterHandler): super().__init__(cluster, endpoint) self._off_listener = None - if self.cluster.endpoint.model in ( + if self.cluster.endpoint.model not in ( "TS011F", "TS0121", "TS0001", @@ -366,13 +366,19 @@ class OnOffClusterHandler(ClusterHandler): "TS0003", "TS0004", ): - self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name - self.ZCL_INIT_ATTRS.copy() - ) - self.ZCL_INIT_ATTRS["backlight_mode"] = True - self.ZCL_INIT_ATTRS["power_on_state"] = True - if self.cluster.endpoint.model == "TS011F": - self.ZCL_INIT_ATTRS["child_lock"] = True + return + + try: + self.cluster.find_attribute("backlight_mode") + except KeyError: + return + + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS["backlight_mode"] = True + self.ZCL_INIT_ATTRS["power_on_state"] = True + + if self.cluster.endpoint.model == "TS011F": + self.ZCL_INIT_ATTRS["child_lock"] = True @classmethod def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: @@ -438,7 +444,7 @@ class OnOffClusterHandler(ClusterHandler): self.cluster.update_attribute(self.ON_OFF, t.Bool.false) @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" if attrid == self.ON_OFF: self.async_send_signal( diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 8fd28a1dba7..cbc56f5acc5 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -67,7 +67,7 @@ class FanClusterHandler(ClusterHandler): await self.get_attribute_value("fan_mode", from_cache=False) @callback - def attribute_updated(self, attrid: int, value: Any) -> None: + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from fan cluster.""" attr_name = self._get_attribute_name(attrid) self.debug( @@ -109,7 +109,7 @@ class ThermostatClusterHandler(ClusterHandler): AttrReportConfig(attr="pi_cooling_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), AttrReportConfig(attr="pi_heating_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), ) - ZCL_INIT_ATTRS: dict[int | str, bool] = { + ZCL_INIT_ATTRS: dict[str, bool] = { "abs_min_heat_setpoint_limit": True, "abs_max_heat_setpoint_limit": True, "abs_min_cool_setpoint_limit": True, @@ -234,7 +234,7 @@ class ThermostatClusterHandler(ClusterHandler): return self.cluster.get("unoccupied_heating_setpoint") @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update cluster.""" attr_name = self._get_attribute_name(attrid) self.debug( diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 9bdf49aadc1..e46031cce14 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -198,7 +198,7 @@ class SmartThingsAcceleration(ClusterHandler): ) @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" try: attr_name = self._cluster.attributes[attrid].name @@ -229,7 +229,7 @@ class InovelliNotificationClusterHandler(ClientClusterHandler): """Inovelli Notification cluster handler.""" @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle an attribute updated on this cluster.""" @callback @@ -363,7 +363,7 @@ class IkeaAirPurifierClusterHandler(ClusterHandler): await self.get_attribute_value("fan_mode", from_cache=False) @callback - def attribute_updated(self, attrid: int, value: Any) -> None: + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from fan cluster.""" attr_name = self._get_attribute_name(attrid) self.debug( diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index 56b925671e3..28e2d863662 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -388,7 +388,7 @@ class IASZoneClusterHandler(ClusterHandler): self.debug("finished IASZoneClusterHandler configuration") @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" if attrid == IasZone.attributes_by_name["zone_status"].id: self.async_send_signal( diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index fce37904126..4d76ea27897 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -318,16 +318,12 @@ class KeenVent(Shade): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" position = self._position or 100 - tasks = [ + await asyncio.gather( self._level_cluster_handler.move_to_level_with_on_off( position * 255 / 100, 1 ), self._on_off_cluster_handler.on(), - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - if any(isinstance(result, Exception) for result in results): - self.debug("couldn't open cover") - return + ) self._is_open = True self._position = position diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6e93fca6042..ae5718e108b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,9 +23,9 @@ "bellows==0.35.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.100", + "zha-quirks==0.0.101", "zigpy-deconz==0.21.0", - "zigpy==0.55.0", + "zigpy==0.56.0", "zigpy-xbee==0.18.0", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.1" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index d24d0c56668..baba8f7fd5b 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -401,6 +401,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): cluster_handler = cluster_handlers[0] if ( cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes + or cls._zcl_attribute not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._zcl_attribute) is None ): _LOGGER.debug( diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 2453f40af44..27b71484f3e 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -176,6 +176,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): cluster_handler = cluster_handlers[0] if ( cls._select_attr in cluster_handler.cluster.unsupported_attributes + or cls._select_attr not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._select_attr) is None ): _LOGGER.debug( diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 918458a32ad..d13dd871865 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -148,7 +148,10 @@ class Sensor(ZhaEntity, SensorEntity): Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if cls.SENSOR_ATTR in cluster_handler.cluster.unsupported_attributes: + if ( + cls.SENSOR_ATTR in cluster_handler.cluster.unsupported_attributes + or cls.SENSOR_ATTR not in cluster_handler.cluster.attributes_by_name + ): return None return cls(unique_id, zha_device, cluster_handlers, **kwargs) @@ -275,8 +278,14 @@ class ElectricalMeasurement(Sensor): attrs["measurement_type"] = self._cluster_handler.measurement_type max_attr_name = f"{self.SENSOR_ATTR}_max" - if (max_v := self._cluster_handler.cluster.get(max_attr_name)) is not None: - attrs[max_attr_name] = str(self.formatter(max_v)) + + try: + max_v = self._cluster_handler.cluster.get(max_attr_name) + except KeyError: + pass + else: + if max_v is not None: + attrs[max_attr_name] = str(self.formatter(max_v)) return attrs diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 99db68760a8..451d96a122b 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -192,6 +192,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): cluster_handler = cluster_handlers[0] if ( cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes + or cls._zcl_attribute not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._zcl_attribute) is None ): _LOGGER.debug( diff --git a/requirements_all.txt b/requirements_all.txt index 55a1d57cbab..a86202c8bad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2742,7 +2742,7 @@ zeroconf==0.66.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.100 +zha-quirks==0.0.101 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2763,7 +2763,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.1 # homeassistant.components.zha -zigpy==0.55.0 +zigpy==0.56.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53a314f7d05..4e27cb04102 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ zeroconf==0.66.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.100 +zha-quirks==0.0.101 # homeassistant.components.zha zigpy-deconz==0.21.0 @@ -2021,7 +2021,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.1 # homeassistant.components.zha -zigpy==0.55.0 +zigpy==0.56.0 # homeassistant.components.zwave_js zwave-js-server-python==0.49.0 diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index d3f3bf9b654..79c319398f0 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -133,7 +133,7 @@ async def send_attributes_report(hass, cluster: zigpy.zcl.Cluster, attributes: d await hass.async_block_till_done() -async def find_entity_id(domain, zha_device, hass, qualifier=None): +def find_entity_id(domain, zha_device, hass, qualifier=None): """Find the entity id under the testing. This is used to get the entity id in order to get the state from the state diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index 319301cf7dc..49ad1b81e3b 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -65,7 +65,7 @@ async def test_alarm_control_panel( zha_device = await zha_device_joined_restored(zigpy_device) cluster = zigpy_device.endpoints.get(1).ias_ace - entity_id = await find_entity_id(Platform.ALARM_CONTROL_PANEL, zha_device, hass) + entity_id = find_entity_id(Platform.ALARM_CONTROL_PANEL, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED await async_enable_traffic(hass, [zha_device], enabled=False) diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 2a30e053376..1b4f5b56924 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -116,7 +116,7 @@ async def test_binary_sensor( """Test ZHA binary_sensor platform.""" zigpy_device = zigpy_device_mock(device) zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -192,7 +192,7 @@ async def test_binary_sensor_migration_not_migrated( zigpy_device = zigpy_device_mock(DEVICE_IAS) zha_device = await zha_device_restored(zigpy_device) - entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == restored_state @@ -222,7 +222,7 @@ async def test_binary_sensor_migration_already_migrated( update_attribute_cache(cluster) zha_device = await zha_device_restored(zigpy_device) - entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache @@ -251,7 +251,7 @@ async def test_onoff_binary_sensor_restore_state( zigpy_device = zigpy_device_mock(DEVICE_ONOFF) zha_device = await zha_device_restored(zigpy_device) - entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == restored_state diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index e0a825cc874..f3ee3d6dca7 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -10,6 +10,7 @@ from zhaquirks.const import ( OUTPUT_CLUSTERS, PROFILE_ID, ) +from zhaquirks.tuya.ts0601_valve import ParksideTuyaValveManufCluster from zigpy.const import SIG_EP_PROFILE from zigpy.exceptions import ZigbeeException import zigpy.profiles.zha as zha @@ -49,6 +50,7 @@ def button_platform_only(): Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ), ): yield @@ -107,13 +109,21 @@ async def tuya_water_valve(hass, zigpy_device_mock, zha_device_joined_restored): zigpy_device = zigpy_device_mock( { 1: { - SIG_EP_INPUT: [general.Basic.cluster_id], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, - } + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, + INPUT_CLUSTERS: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + general.OnOff.cluster_id, + ParksideTuyaValveManufCluster.cluster_id, + ], + OUTPUT_CLUSTERS: [general.Time.cluster_id, general.Ota.cluster_id], + }, }, manufacturer="_TZE200_htnnfasr", - quirk=FrostLockQuirk, + model="TS0601", ) zha_device = await zha_device_joined_restored(zigpy_device) @@ -127,7 +137,7 @@ async def test_button(hass: HomeAssistant, contact_sensor) -> None: entity_registry = er.async_get(hass) zha_device, cluster = contact_sensor assert cluster is not None - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = find_entity_id(DOMAIN, zha_device, hass) assert entity_id is not None state = hass.states.get(entity_id) @@ -167,7 +177,7 @@ async def test_frost_unlock(hass: HomeAssistant, tuya_water_valve) -> None: entity_registry = er.async_get(hass) zha_device, cluster = tuya_water_valve assert cluster is not None - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = find_entity_id(DOMAIN, zha_device, hass, qualifier="frost_lock_reset") assert entity_id is not None state = hass.states.get(entity_id) diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 79777dc0c7b..fd8bcaa1085 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -281,7 +281,7 @@ async def test_climate_local_temperature(hass: HomeAssistant, device_climate) -> """Test local temperature.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -297,8 +297,8 @@ async def test_climate_hvac_action_running_state( """Test hvac action via running state.""" thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) - sensor_entity_id = await find_entity_id( + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) + sensor_entity_id = find_entity_id( Platform.SENSOR, device_climate_sinope, hass, "hvac" ) @@ -362,8 +362,8 @@ async def test_climate_hvac_action_running_state_zen( """Test Zen hvac action via running state.""" thrm_cluster = device_climate_zen.device.endpoints[1].thermostat - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_zen, hass) - sensor_entity_id = await find_entity_id(Platform.SENSOR, device_climate_zen, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_zen, hass) + sensor_entity_id = find_entity_id(Platform.SENSOR, device_climate_zen, hass) state = hass.states.get(entity_id) assert ATTR_HVAC_ACTION not in state.attributes @@ -449,7 +449,7 @@ async def test_climate_hvac_action_pi_demand( """Test hvac action based on pi_heating/cooling_demand attrs.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert ATTR_HVAC_ACTION not in state.attributes @@ -498,7 +498,7 @@ async def test_hvac_mode( """Test HVAC mode.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert state.state == HVACMode.OFF @@ -538,7 +538,7 @@ async def test_hvac_modes( device_climate = await device_climate_mock( CLIMATE, {"ctrl_sequence_of_oper": seq_of_op} ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert set(state.attributes[ATTR_HVAC_MODES]) == modes @@ -569,7 +569,7 @@ async def test_target_temperature( manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) if preset: await hass.services.async_call( CLIMATE_DOMAIN, @@ -605,7 +605,7 @@ async def test_target_temperature_high( manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) if preset: await hass.services.async_call( CLIMATE_DOMAIN, @@ -641,7 +641,7 @@ async def test_target_temperature_low( manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) if preset: await hass.services.async_call( CLIMATE_DOMAIN, @@ -671,7 +671,7 @@ async def test_set_hvac_mode( """Test setting hvac mode.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert state.state == HVACMode.OFF @@ -712,7 +712,7 @@ async def test_set_hvac_mode( async def test_preset_setting(hass: HomeAssistant, device_climate_sinope) -> None: """Test preset setting.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -792,7 +792,7 @@ async def test_preset_setting_invalid( ) -> None: """Test invalid preset setting.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -813,7 +813,7 @@ async def test_preset_setting_invalid( async def test_set_temperature_hvac_mode(hass: HomeAssistant, device_climate) -> None: """Test setting HVAC mode in temperature service call.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -855,7 +855,7 @@ async def test_set_temperature_heat_cool( manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -941,7 +941,7 @@ async def test_set_temperature_heat(hass: HomeAssistant, device_climate_mock) -> manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1020,7 +1020,7 @@ async def test_set_temperature_cool(hass: HomeAssistant, device_climate_mock) -> manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1105,7 +1105,7 @@ async def test_set_temperature_wrong_mode( }, manuf=MANUF_SINOPE, ) - entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1128,7 +1128,7 @@ async def test_set_temperature_wrong_mode( async def test_occupancy_reset(hass: HomeAssistant, device_climate_sinope) -> None: """Test away preset reset.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1155,7 +1155,7 @@ async def test_occupancy_reset(hass: HomeAssistant, device_climate_sinope) -> No async def test_fan_mode(hass: HomeAssistant, device_climate_fan) -> None: """Test fan mode.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_fan, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass) thrm_cluster = device_climate_fan.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1186,7 +1186,7 @@ async def test_set_fan_mode_not_supported( ) -> None: """Test fan setting unsupported mode.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_fan, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass) fan_cluster = device_climate_fan.device.endpoints[1].fan await hass.services.async_call( @@ -1201,7 +1201,7 @@ async def test_set_fan_mode_not_supported( async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None: """Test fan mode setting.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_fan, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass) fan_cluster = device_climate_fan.device.endpoints[1].fan state = hass.states.get(entity_id) @@ -1230,7 +1230,7 @@ async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None: async def test_set_moes_preset(hass: HomeAssistant, device_climate_moes) -> None: """Test setting preset for moes trv.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_moes, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_moes, hass) thrm_cluster = device_climate_moes.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1347,7 +1347,7 @@ async def test_set_moes_operation_mode( ) -> None: """Test setting preset for moes trv.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_moes, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_moes, hass) thrm_cluster = device_climate_moes.device.endpoints[1].thermostat await send_attributes_report(hass, thrm_cluster, {"operation_preset": 0}) @@ -1391,7 +1391,7 @@ async def test_set_zonnsmart_preset( ) -> None: """Test setting preset from homeassistant for zonnsmart trv.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_zonnsmart, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_zonnsmart, hass) thrm_cluster = device_climate_zonnsmart.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1460,7 +1460,7 @@ async def test_set_zonnsmart_operation_mode( ) -> None: """Test setting preset from trv for zonnsmart trv.""" - entity_id = await find_entity_id(Platform.CLIMATE, device_climate_zonnsmart, hass) + entity_id = find_entity_id(Platform.CLIMATE, device_climate_zonnsmart, hass) thrm_cluster = device_climate_zonnsmart.device.endpoints[1].thermostat await send_attributes_report(hass, thrm_cluster, {"operation_preset": 0}) diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index b9d64dda317..d1003418487 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -1,6 +1,6 @@ """Test ZHA cover.""" import asyncio -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest import zigpy.profiles.zha @@ -36,7 +36,7 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import async_capture_events, mock_coro, mock_restore_cache +from tests.common import async_capture_events, mock_restore_cache @pytest.fixture(autouse=True) @@ -132,7 +132,7 @@ async def test_cover( assert cluster.read_attributes.call_count == 1 assert "current_position_lift_percentage" in cluster.read_attributes.call_args[0][0] - entity_id = await find_entity_id(Platform.COVER, zha_device, hass) + entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -152,9 +152,7 @@ async def test_cover( assert hass.states.get(entity_id).state == STATE_OPEN # close from UI - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([0x1, zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) @@ -165,9 +163,7 @@ async def test_cover( assert cluster.request.call_args[1]["expect_reply"] is True # open from UI - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([0x0, zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) @@ -178,9 +174,7 @@ async def test_cover( assert cluster.request.call_args[1]["expect_reply"] is True # set position UI - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([0x5, zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, @@ -195,9 +189,7 @@ async def test_cover( assert cluster.request.call_args[1]["expect_reply"] is True # stop from UI - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([0x2, zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True ) @@ -223,7 +215,7 @@ async def test_shade( cluster_on_off = zigpy_shade_device.endpoints.get(1).on_off cluster_level = zigpy_shade_device.endpoints.get(1).level - entity_id = await find_entity_id(Platform.COVER, zha_device, hass) + entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -244,17 +236,19 @@ async def test_shade( # close from UI command fails with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True - ) - assert cluster_on_off.request.call_count == 1 + with pytest.raises(asyncio.TimeoutError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster_on_off.request.call_count == 3 assert cluster_on_off.request.call_args[0][0] is False assert cluster_on_off.request.call_args[0][1] == 0x0000 assert hass.states.get(entity_id).state == STATE_OPEN - with patch( - "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x1, zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) @@ -267,18 +261,20 @@ async def test_shade( assert ATTR_CURRENT_POSITION not in hass.states.get(entity_id).attributes await send_attributes_report(hass, cluster_level, {0: 0}) with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True - ) - assert cluster_on_off.request.call_count == 1 + with pytest.raises(asyncio.TimeoutError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster_on_off.request.call_count == 3 assert cluster_on_off.request.call_args[0][0] is False assert cluster_on_off.request.call_args[0][1] == 0x0001 assert hass.states.get(entity_id).state == STATE_CLOSED # open from UI succeeds - with patch( - "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x0, zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) @@ -289,22 +285,21 @@ async def test_shade( # set position UI command fails with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {"entity_id": entity_id, "position": 47}, - blocking=True, - ) - assert cluster_level.request.call_count == 1 + with pytest.raises(asyncio.TimeoutError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, "position": 47}, + blocking=True, + ) + assert cluster_level.request.call_count == 3 assert cluster_level.request.call_args[0][0] is False assert cluster_level.request.call_args[0][1] == 0x0004 assert int(cluster_level.request.call_args[0][3] * 100 / 255) == 47 assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 0 # set position UI success - with patch( - "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x5, zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, @@ -331,13 +326,14 @@ async def test_shade( # test cover stop with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_STOP_COVER, - {"entity_id": entity_id}, - blocking=True, - ) - assert cluster_level.request.call_count == 1 + with pytest.raises(asyncio.TimeoutError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster_level.request.call_count == 3 assert cluster_level.request.call_args[0][0] is False assert cluster_level.request.call_args[0][1] in (0x0003, 0x0007) @@ -361,7 +357,7 @@ async def test_restore_state( hass.state = CoreState.starting zha_device = await zha_device_restored(zigpy_shade_device) - entity_id = await find_entity_id(Platform.COVER, zha_device, hass) + entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None # test that the cover was created and that it is unavailable @@ -379,7 +375,7 @@ async def test_keen_vent( cluster_on_off = zigpy_keen_vent.endpoints.get(1).on_off cluster_level = zigpy_keen_vent.endpoints.get(1).level - entity_id = await find_entity_id(Platform.COVER, zha_device, hass) + entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -396,21 +392,25 @@ async def test_keen_vent( # open from UI command fails p1 = patch.object(cluster_on_off, "request", side_effect=asyncio.TimeoutError) - p2 = patch.object(cluster_level, "request", AsyncMock(return_value=[4, 0])) + p2 = patch.object(cluster_level, "request", return_value=[4, 0]) with p1, p2: - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True - ) - assert cluster_on_off.request.call_count == 1 + with pytest.raises(asyncio.TimeoutError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster_on_off.request.call_count == 3 assert cluster_on_off.request.call_args[0][0] is False assert cluster_on_off.request.call_args[0][1] == 0x0001 assert cluster_level.request.call_count == 1 assert hass.states.get(entity_id).state == STATE_CLOSED # open from UI command success - p1 = patch.object(cluster_on_off, "request", AsyncMock(return_value=[1, 0])) - p2 = patch.object(cluster_level, "request", AsyncMock(return_value=[4, 0])) + p1 = patch.object(cluster_on_off, "request", return_value=[1, 0]) + p2 = patch.object(cluster_level, "request", return_value=[4, 0]) with p1, p2: await hass.services.async_call( diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index f1ab44f69eb..0dda0b56e23 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -328,7 +328,6 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=1, tsn=None, ) in cluster.request.call_args_list @@ -345,7 +344,6 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=1, tsn=None, ) in cluster.request.call_args_list diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 70f76ae54f3..aa3c6b7d146 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -70,7 +70,7 @@ async def test_device_tracker( zha_device = await zha_device_joined_restored(zigpy_device_dt) cluster = zigpy_device_dt.endpoints.get(1).power - entity_id = await find_entity_id(Platform.DEVICE_TRACKER, zha_device, hass) + entity_id = find_entity_id(Platform.DEVICE_TRACKER, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_NOT_HOME diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 236a3c4ad86..e0785601b4f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -15,20 +15,12 @@ import zigpy.zcl.clusters.general import zigpy.zcl.clusters.security import zigpy.zcl.foundation as zcl_f -import homeassistant.components.zha.binary_sensor import homeassistant.components.zha.core.cluster_handlers as cluster_handlers import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice import homeassistant.components.zha.core.discovery as disc from homeassistant.components.zha.core.endpoint import Endpoint import homeassistant.components.zha.core.registries as zha_regs -import homeassistant.components.zha.cover -import homeassistant.components.zha.device_tracker -import homeassistant.components.zha.fan -import homeassistant.components.zha.light -import homeassistant.components.zha.lock -import homeassistant.components.zha.sensor -import homeassistant.components.zha.switch from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er @@ -131,7 +123,6 @@ async def test_devices( ), expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) ] @@ -493,35 +484,3 @@ async def test_group_probe_cleanup_called( await config_entry.async_unload(hass_disable_services) await hass_disable_services.async_block_till_done() disc.GROUP_PROBE.cleanup.assert_called() - - -@patch( - "zigpy.zcl.clusters.general.Identify.request", - new=AsyncMock(return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]), -) -@patch( - "homeassistant.components.zha.entity.ZhaEntity.entity_registry_enabled_default", - new=Mock(return_value=True), -) -async def test_cluster_handler_with_empty_ep_attribute_cluster( - hass_disable_services, - zigpy_device_mock, - zha_device_joined_restored, -) -> None: - """Test device discovery for cluster which does not have em_attribute.""" - entity_registry = homeassistant.helpers.entity_registry.async_get( - hass_disable_services - ) - - zigpy_device = zigpy_device_mock( - {1: {SIG_EP_INPUT: [0x042E], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, - "00:11:22:33:44:55:66:77", - "test manufacturer", - "test model", - patch_cluster=False, - ) - zha_dev = await zha_device_joined_restored(zigpy_device) - ha_entity_id = entity_registry.async_get_entity_id( - "sensor", "zha", f"{zha_dev.ieee}-1-1070" - ) - assert ha_entity_id is not None diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 5ad8966c547..f93467ed3e1 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -167,7 +167,7 @@ async def test_fan( zha_device = await zha_device_joined_restored(zigpy_device) cluster = zigpy_device.endpoints.get(1).fan - entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -475,7 +475,7 @@ async def test_fan_init( cluster.PLUGGED_ATTR_READS = plug_read zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == expected_state assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage @@ -493,7 +493,7 @@ async def test_fan_update_entity( cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 @@ -562,7 +562,7 @@ async def test_fan_ikea( """Test ZHA fan Ikea platform.""" zha_device = await zha_device_joined_restored(zigpy_device_ikea) cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier - entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -656,7 +656,7 @@ async def test_fan_ikea_init( cluster.PLUGGED_ATTR_READS = ikea_plug_read zha_device = await zha_device_joined_restored(zigpy_device_ikea) - entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == ikea_expected_state assert ( @@ -676,7 +676,7 @@ async def test_fan_ikea_update_entity( cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} zha_device = await zha_device_joined_restored(zigpy_device_ikea) - entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index c4751f7e7f6..3abfd0e4f9c 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -264,7 +264,7 @@ async def test_light_refresh( on_off_cluster = zigpy_device.endpoints[1].on_off on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(Platform.LIGHT, zha_device, hass) + entity_id = find_entity_id(Platform.LIGHT, zha_device, hass) # allow traffic to flow through the gateway and device await async_enable_traffic(hass, [zha_device]) @@ -326,7 +326,7 @@ async def test_light( # create zigpy devices zigpy_device = zigpy_device_mock(device) zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(Platform.LIGHT, zha_device, hass) + entity_id = find_entity_id(Platform.LIGHT, zha_device, hass) assert entity_id is not None @@ -446,7 +446,7 @@ async def test_light_initialization( with patch_zha_config("light", config_override): zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(Platform.LIGHT, zha_device, hass) + entity_id = find_entity_id(Platform.LIGHT, zha_device, hass) assert entity_id is not None @@ -495,9 +495,9 @@ async def test_transitions( assert member.group == zha_group assert member.endpoint is not None - device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass) - device_2_entity_id = await find_entity_id(Platform.LIGHT, device_light_2, hass) - eWeLink_light_entity_id = await find_entity_id(Platform.LIGHT, eWeLink_light, hass) + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1, hass) + device_2_entity_id = find_entity_id(Platform.LIGHT, device_light_2, hass) + eWeLink_light_entity_id = find_entity_id(Platform.LIGHT, eWeLink_light, hass) assert device_1_entity_id != device_2_entity_id group_entity_id = async_find_group_entity_id(hass, Platform.LIGHT, zha_group) @@ -553,7 +553,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -589,7 +588,6 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -600,7 +598,6 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -637,7 +634,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -674,7 +670,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -685,7 +680,6 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -696,7 +690,6 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -758,7 +751,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -769,7 +761,6 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -780,7 +771,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -838,7 +828,6 @@ async def test_transitions( dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -850,7 +839,6 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -910,7 +898,6 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -968,7 +955,6 @@ async def test_transitions( transition_time=1, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev2_cluster_color.request.call_args == call( @@ -979,7 +965,6 @@ async def test_transitions( transition_time=1, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev2_cluster_level.request.call_args_list[1] == call( @@ -990,7 +975,6 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1055,7 +1039,6 @@ async def test_transitions( transition_time=10, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert group_level_cluster_handler.request.call_args == call( @@ -1066,7 +1049,6 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1121,7 +1103,6 @@ async def test_transitions( transition_time=20, # transition time expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1151,7 +1132,6 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1184,7 +1164,6 @@ async def test_transitions( eWeLink_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1195,7 +1174,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1222,7 +1200,7 @@ async def test_transitions( async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: """Test turning on the light and sending color commands before on/level commands for supporting lights.""" - device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass) + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1, hass) dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off dev1_cluster_level = device_light_1.device.endpoints[1].level dev1_cluster_color = device_light_1.device.endpoints[1].light_color @@ -1261,7 +1239,6 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1272,7 +1249,6 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1319,7 +1295,6 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1330,7 +1305,6 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -1341,7 +1315,6 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1388,7 +1361,6 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id): cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1411,7 +1383,6 @@ async def async_test_off_from_hass(hass, cluster, entity_id): cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1439,7 +1410,6 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1463,7 +1433,6 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert level_cluster.request.call_args == call( @@ -1474,7 +1443,6 @@ async def async_test_level_on_off_from_hass( transition_time=100, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1499,7 +1467,6 @@ async def async_test_level_on_off_from_hass( transition_time=int(expected_default_transition), expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1542,7 +1509,6 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash): effect_variant=general.Identify.EffectVariant.Default, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -1593,9 +1559,9 @@ async def test_zha_group_light_entity( assert member.group == zha_group assert member.endpoint is not None - device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass) - device_2_entity_id = await find_entity_id(Platform.LIGHT, device_light_2, hass) - device_3_entity_id = await find_entity_id(Platform.LIGHT, device_light_3, hass) + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1, hass) + device_2_entity_id = find_entity_id(Platform.LIGHT, device_light_2, hass) + device_3_entity_id = find_entity_id(Platform.LIGHT, device_light_3, hass) assert ( device_1_entity_id != device_2_entity_id @@ -1833,8 +1799,8 @@ async def test_group_member_assume_state( assert member.group == zha_group assert member.endpoint is not None - device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass) - device_2_entity_id = await find_entity_id(Platform.LIGHT, device_light_2, hass) + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1, hass) + device_2_entity_id = find_entity_id(Platform.LIGHT, device_light_2, hass) assert device_1_entity_id != device_2_entity_id diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index a3ce29bd630..2f1ecb6983d 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -19,8 +19,6 @@ from homeassistant.core import HomeAssistant from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import mock_coro - LOCK_DOOR = 0 UNLOCK_DOOR = 1 SET_PIN_CODE = 5 @@ -64,7 +62,7 @@ async def test_lock(hass: HomeAssistant, lock) -> None: """Test ZHA lock platform.""" zha_device, cluster = lock - entity_id = await find_entity_id(Platform.LOCK, zha_device, hass) + entity_id = find_entity_id(Platform.LOCK, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_UNLOCKED @@ -107,9 +105,7 @@ async def test_lock(hass: HomeAssistant, lock) -> None: async def async_lock(hass, cluster, entity_id): """Test lock functionality from hass.""" - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): # lock via UI await hass.services.async_call( LOCK_DOMAIN, "lock", {"entity_id": entity_id}, blocking=True @@ -121,9 +117,7 @@ async def async_lock(hass, cluster, entity_id): async def async_unlock(hass, cluster, entity_id): """Test lock functionality from hass.""" - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): # lock via UI await hass.services.async_call( LOCK_DOMAIN, "unlock", {"entity_id": entity_id}, blocking=True @@ -135,9 +129,7 @@ async def async_unlock(hass, cluster, entity_id): async def async_set_user_code(hass, cluster, entity_id): """Test set lock code functionality from hass.""" - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): # set lock code via service call await hass.services.async_call( "zha", @@ -158,9 +150,7 @@ async def async_set_user_code(hass, cluster, entity_id): async def async_clear_user_code(hass, cluster, entity_id): """Test clear lock code functionality from hass.""" - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): # set lock code via service call await hass.services.async_call( "zha", @@ -179,9 +169,7 @@ async def async_clear_user_code(hass, cluster, entity_id): async def async_enable_user_code(hass, cluster, entity_id): """Test enable lock code functionality from hass.""" - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): # set lock code via service call await hass.services.async_call( "zha", @@ -201,9 +189,7 @@ async def async_enable_user_code(hass, cluster, entity_id): async def async_disable_user_code(hass, cluster, entity_id): """Test disable lock code functionality from hass.""" - with patch( - "zigpy.zcl.Cluster.request", return_value=mock_coro([zcl_f.Status.SUCCESS]) - ): + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): # set lock code via service call await hass.services.async_call( "zha", diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 584a7318383..60aa355af5f 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -114,7 +114,7 @@ async def test_number( assert "engineering_units" in attr_reads assert "application_type" in attr_reads - entity_id = await find_entity_id(Platform.NUMBER, zha_device, hass) + entity_id = find_entity_id(Platform.NUMBER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -211,7 +211,7 @@ async def test_level_control_number( } zha_device = await zha_device_joined(light) - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.NUMBER, zha_device, hass, @@ -344,7 +344,7 @@ async def test_color_number( } zha_device = await zha_device_joined(light) - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.NUMBER, zha_device, hass, diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index fb1930e3f99..4a9d54c9063 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -127,7 +127,7 @@ async def test_select(hass: HomeAssistant, siren) -> None: entity_registry = er.async_get(hass) zha_device, cluster = siren assert cluster is not None - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.SELECT, zha_device, hass, @@ -194,7 +194,7 @@ async def test_select_restore_state( zha_device = await zha_device_restored(zigpy_device) cluster = zigpy_device.endpoints[1].ias_wd assert cluster is not None - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.SELECT, zha_device, hass, @@ -219,7 +219,7 @@ async def test_on_off_select_new_join( } zha_device = await zha_device_joined(light) select_name = "start_up_behavior" - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.SELECT, zha_device, hass, @@ -304,7 +304,7 @@ async def test_on_off_select_restored( ) select_name = "start_up_behavior" - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.SELECT, zha_device, hass, @@ -331,7 +331,7 @@ async def test_on_off_select_unsupported( on_off_cluster.add_unsupported_attribute("start_up_on_off") zha_device = await zha_device_joined_restored(light) select_name = general.OnOff.StartUpOnOff.__name__ - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.SELECT, zha_device, hass, @@ -400,7 +400,7 @@ async def test_on_off_select_attribute_report( zha_device = await zha_device_restored(zigpy_device_aqara_sensor) cluster = zigpy_device_aqara_sensor.endpoints.get(1).opple_cluster - entity_id = await find_entity_id(Platform.SELECT, zha_device, hass) + entity_id = find_entity_id(Platform.SELECT, zha_device, hass) assert entity_id is not None # allow traffic to flow through the gateway and device diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 7d821ced4a0..b9d9511a6d1 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -569,7 +569,7 @@ async def test_temp_uom( ) cluster = zigpy_device.endpoints[1].temperature zha_device = await zha_device_restored(zigpy_device) - entity_id = await find_entity_id(Platform.SENSOR, zha_device, hass) + entity_id = find_entity_id(Platform.SENSOR, zha_device, hass) if not restore: await async_enable_traffic(hass, [zha_device], enabled=False) @@ -613,7 +613,7 @@ async def test_electrical_measurement_init( ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] zha_device = await zha_device_joined(zigpy_device) - entity_id = await find_entity_id( + entity_id = find_entity_id( Platform.SENSOR, zha_device, hass, qualifier="active_power" ) diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index a6b3059f093..7346f1e5bcb 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -69,7 +69,7 @@ async def test_siren(hass: HomeAssistant, siren) -> None: zha_device, cluster = siren assert cluster is not None - entity_id = await find_entity_id(Platform.SIREN, zha_device, hass) + entity_id = find_entity_id(Platform.SIREN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 9f98acb9359..bee7ec409ca 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -34,8 +34,6 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import mock_coro - ON = 1 OFF = 0 IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -138,7 +136,7 @@ async def test_switch( zha_device = await zha_device_joined_restored(zigpy_device) cluster = zigpy_device.endpoints.get(1).on_off - entity_id = await find_entity_id(Platform.SWITCH, zha_device, hass) + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -163,7 +161,7 @@ async def test_switch( # turn on from HA with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): # turn on via UI await hass.services.async_call( @@ -176,14 +174,13 @@ async def test_switch( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) # turn off from HA with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]), + return_value=[0x01, zcl_f.Status.SUCCESS], ): # turn off via UI await hass.services.async_call( @@ -196,7 +193,6 @@ async def test_switch( cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) @@ -313,7 +309,7 @@ async def test_zha_group_switch_entity( # turn on from HA with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): # turn on via UI await hass.services.async_call( @@ -326,7 +322,6 @@ async def test_zha_group_switch_entity( group_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert hass.states.get(entity_id).state == STATE_ON @@ -334,7 +329,7 @@ async def test_zha_group_switch_entity( # turn off from HA with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]), + return_value=[0x01, zcl_f.Status.SUCCESS], ): # turn off via UI await hass.services.async_call( @@ -347,7 +342,6 @@ async def test_zha_group_switch_entity( group_cluster_on_off.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=1, tsn=None, ) assert hass.states.get(entity_id).state == STATE_OFF @@ -386,7 +380,7 @@ async def test_switch_configurable( zha_device = await zha_device_joined_restored(zigpy_device_tuya) cluster = zigpy_device_tuya.endpoints.get(1).tuya_manufacturer - entity_id = await find_entity_id(Platform.SWITCH, zha_device, hass) + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -411,7 +405,7 @@ async def test_switch_configurable( # turn on from HA with patch( "zigpy.zcl.Cluster.write_attributes", - return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]), + return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS], ): # turn on via UI await hass.services.async_call( @@ -425,7 +419,7 @@ async def test_switch_configurable( # turn off from HA with patch( "zigpy.zcl.Cluster.write_attributes", - return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]), + return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS], ): # turn off via UI await hass.services.async_call( From 2a5ffa9a5b7a44ba92b34f819ea6b6fbeffd7c26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Jun 2023 15:47:00 -1000 Subject: [PATCH 269/857] Fix timestamps for bluetooth scanners that bundle advertisements (#94511) #94138 added support for raw/bundled advertisements. We should use the same monotonic time for all advertisements in the bundle if not time is passed, or calculate the timestamp and pass it if its known --- homeassistant/components/bluetooth/__init__.py | 3 ++- homeassistant/components/bluetooth/base_scanner.py | 8 ++++---- homeassistant/components/esphome/bluetooth/scanner.py | 5 ++++- homeassistant/components/ruuvi_gateway/bluetooth.py | 3 +++ homeassistant/components/shelly/bluetooth/scanner.py | 3 ++- tests/components/bluetooth/test_api.py | 10 ++++++++++ tests/components/bluetooth/test_base_scanner.py | 6 ++++++ tests/components/bluetooth/test_diagnostics.py | 7 ++++++- tests/components/bluetooth/test_manager.py | 3 +++ tests/components/bluetooth/test_wrappers.py | 2 ++ 10 files changed, 42 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 8d3bc0ae5e2..bf4dbf81f01 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -76,7 +76,7 @@ from .models import ( BluetoothScanningMode, HaBluetoothConnector, ) -from .scanner import HaScanner, ScannerStartError +from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError from .storage import BluetoothStorage if TYPE_CHECKING: @@ -108,6 +108,7 @@ __all__ = [ "HaBluetoothConnector", "SOURCE_LOCAL", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", + "MONOTONIC_TIME", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 8f7750fe322..e8de285138e 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -299,10 +299,10 @@ class BaseHaRemoteScanner(BaseHaScanner): manufacturer_data: dict[int, bytes], tx_power: int | None, details: dict[Any, Any], + advertisement_monotonic_time: float, ) -> None: """Call the registered callback.""" - now = MONOTONIC_TIME() - self._last_detection = now + self._last_detection = advertisement_monotonic_time if prev_discovery := self._discovered_device_advertisement_datas.get(address): # Merge the new data with the old data # to function the same as BlueZ which @@ -365,7 +365,7 @@ class BaseHaRemoteScanner(BaseHaScanner): device, advertisement_data, ) - self._discovered_device_timestamps[address] = now + self._discovered_device_timestamps[address] = advertisement_monotonic_time self._new_info_callback( BluetoothServiceInfoBleak( name=local_name or address, @@ -378,7 +378,7 @@ class BaseHaRemoteScanner(BaseHaScanner): device=device, advertisement=advertisement_data, connectable=self.connectable, - time=now, + time=advertisement_monotonic_time, ) ) diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 85ab991df4e..5013a288dcf 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -4,7 +4,7 @@ from __future__ import annotations from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement from bluetooth_data_tools import int_to_bluetooth_address, parse_advertisement_data -from homeassistant.components.bluetooth import BaseHaRemoteScanner +from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner from homeassistant.core import callback @@ -24,6 +24,7 @@ class ESPHomeScanner(BaseHaRemoteScanner): adv.manufacturer_data, None, {"address_type": adv.address_type}, + MONOTONIC_TIME(), ) @callback @@ -31,6 +32,7 @@ class ESPHomeScanner(BaseHaRemoteScanner): self, advertisements: list[BluetoothLERawAdvertisement] ) -> None: """Call the registered callback.""" + now = MONOTONIC_TIME() for adv in advertisements: parsed = parse_advertisement_data((adv.data,)) self._async_on_advertisement( @@ -42,4 +44,5 @@ class ESPHomeScanner(BaseHaRemoteScanner): parsed.manufacturer_data, None, {"address_type": adv.address_type}, + now, ) diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 4dd973155a9..47a9bbfdde0 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -9,6 +9,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + MONOTONIC_TIME, BaseHaRemoteScanner, async_get_advertisement_callback, async_register_scanner, @@ -47,6 +48,7 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): @callback def _async_handle_new_data(self) -> None: now = time.time() + monotonic_now = MONOTONIC_TIME() for tag_data in self.coordinator.data: data_age_seconds = now - tag_data.timestamp # Both are Unix time if data_age_seconds > FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: @@ -62,6 +64,7 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): manufacturer_data=anno.manufacturer_data, tx_power=anno.tx_power, details={}, + advertisement_monotonic_time=monotonic_now - data_age_seconds, ) @callback diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py index 5b302e0da62..7c0dc3c792a 100644 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ b/homeassistant/components/shelly/bluetooth/scanner.py @@ -6,7 +6,7 @@ from typing import Any from aioshelly.ble import parse_ble_scan_result_event from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import BaseHaRemoteScanner +from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner from homeassistant.core import callback from ..const import LOGGER @@ -44,4 +44,5 @@ class ShellyBLEScanner(BaseHaRemoteScanner): parsed.manufacturer_data, parsed.tx_power, {}, + MONOTONIC_TIME(), ) diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 77d802264e1..63b60c8f487 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -1,8 +1,12 @@ """Tests for the Bluetooth integration API.""" +import time + from bleak.backends.scanner import AdvertisementData, BLEDevice +import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, @@ -31,6 +35,11 @@ async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> assert async_scanner_by_source(hass, "hci2") is None +async def test_monotonic_time() -> None: + """Test monotonic time.""" + assert MONOTONIC_TIME() == pytest.approx(time.monotonic(), abs=0.1) + + async def test_async_scanner_devices_by_address_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: @@ -51,6 +60,7 @@ async def test_async_scanner_devices_by_address_connectable( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) new_info_callback = manager.scanner_adv_received diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 8817acad468..5662bc6324b 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -12,6 +12,7 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, BaseHaRemoteScanner, HaBluetoothConnector, storage, @@ -84,6 +85,7 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) new_info_callback = manager.scanner_adv_received @@ -158,6 +160,7 @@ async def test_remote_scanner_expires_connectable( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) new_info_callback = manager.scanner_adv_received @@ -232,6 +235,7 @@ async def test_remote_scanner_expires_non_connectable( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) new_info_callback = manager.scanner_adv_received @@ -329,6 +333,7 @@ async def test_base_scanner_connecting_behavior( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) new_info_callback = manager.scanner_adv_received @@ -452,6 +457,7 @@ async def test_device_with_ten_minute_advertising_interval( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) new_info_callback = manager.scanner_adv_received diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 7ffd3f00131..765e2a9a612 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -5,7 +5,11 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS from homeassistant.components import bluetooth -from homeassistant.components.bluetooth import BaseHaRemoteScanner, HaBluetoothConnector +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + BaseHaRemoteScanner, + HaBluetoothConnector, +) from homeassistant.core import HomeAssistant from . import ( @@ -450,6 +454,7 @@ async def test_diagnostics_remote_adapter( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) with patch( diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 85da27b027e..67b0b594249 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, BluetoothScanningMode, @@ -711,6 +712,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) new_info_callback = async_get_advertisement_callback(hass) @@ -883,6 +885,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) def clear_all_devices(self) -> None: diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index e1656b39c18..de646f8ef9c 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -10,6 +10,7 @@ from bleak.backends.scanner import AdvertisementData import pytest from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothServiceInfoBleak, HaBluetoothConnector, @@ -59,6 +60,7 @@ class FakeScanner(BaseHaRemoteScanner): advertisement_data.manufacturer_data, advertisement_data.tx_power, device.details | {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), ) From 61d260e5fe68cb30048aae3c5f45c2cbcae2a3e1 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 15 Jun 2023 02:47:50 +0100 Subject: [PATCH 270/857] Add CPU Power to System Bridge (#80781) * Add CPU Power to System Bridge * Rename * Update homeassistant/components/system_bridge/sensor.py Co-authored-by: Aarni Koskela * Fix unit * Add suggested_display_precision=2 --------- Co-authored-by: Aarni Koskela --- .../components/system_bridge/manifest.json | 2 +- .../components/system_bridge/sensor.py | 50 +++++++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 7462966ae39..c0f89c16339 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.4.8"], + "requirements": ["systembridgeconnector==3.4.9"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index ede94863af4..9290ebeacd5 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -60,6 +60,23 @@ def battery_time_remaining(data: SystemBridgeCoordinatorData) -> datetime | None return None +def cpu_power_package(data: SystemBridgeCoordinatorData) -> float | None: + """Return the CPU package power.""" + if data.cpu.power_package is not None: + return data.cpu.power_package + return None + + +def cpu_power_per_cpu( + data: SystemBridgeCoordinatorData, + cpu: int, +) -> float | None: + """Return CPU power per CPU.""" + if (value := getattr(data.cpu, f"power_per_cpu_{cpu}", None)) is not None: + return value + return None + + def cpu_speed(data: SystemBridgeCoordinatorData) -> float | None: """Return the CPU speed.""" if data.cpu.frequency_current is not None: @@ -133,6 +150,15 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( data.system.boot_time, tz=timezone.utc ), ), + SystemBridgeSensorEntityDescription( + key="cpu_power_package", + name="CPU Package Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + icon="mdi:chip", + value=cpu_power_package, + ), SystemBridgeSensorEntityDescription( key="cpu_speed", name="CPU speed", @@ -502,8 +528,7 @@ async def async_setup_entry( ] for index in range(coordinator.data.cpu.count): - entities = [ - *entities, + entities.append( SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( @@ -516,8 +541,25 @@ async def async_setup_entry( value=lambda data, k=index: getattr(data.cpu, f"usage_{k}", None), ), entry.data[CONF_PORT], - ), - ] + ) + ) + if hasattr(coordinator.data.cpu, f"power_per_cpu_{index}"): + entities.append( + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"cpu_power_core_{index}", + name=f"CPU Core {index} Power", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + icon="mdi:chip", + value=lambda data, k=index: cpu_power_per_cpu(data, k), + ), + entry.data[CONF_PORT], + ) + ) async_add_entities(entities) diff --git a/requirements_all.txt b/requirements_all.txt index a86202c8bad..ddf6bcdd98f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2478,7 +2478,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.8 +systembridgeconnector==3.4.9 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e27cb04102..7372d3f459c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1808,7 +1808,7 @@ sunwatcher==0.2.1 surepy==0.8.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.8 +systembridgeconnector==3.4.9 # homeassistant.components.tailscale tailscale==0.2.0 From 580b09d0f268402b858926dc5a1ca19c18d74f2a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 15 Jun 2023 09:15:25 +0200 Subject: [PATCH 271/857] Refactor XML parsing in rest (#94268) * Refactor XML parsing in rest * Adjust caplog check * Adjust * Rename * Simplify --- homeassistant/components/rest/const.py | 7 +++++++ homeassistant/components/rest/data.py | 25 +++++++++++++++++++++++++ homeassistant/components/rest/sensor.py | 24 +----------------------- tests/components/rest/test_sensor.py | 4 ++-- 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 0bf0ea9743d..8fb08f766fa 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -26,3 +26,10 @@ REST = "rest" REST_DATA = "rest_data" METHODS = ["POST", "GET"] + +XML_MIME_TYPES = ( + "application/rss+xml", + "application/xhtml+xml", + "application/xml", + "text/xml", +) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 95086f68d70..1f331651165 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -3,14 +3,19 @@ from __future__ import annotations import logging import ssl +from xml.parsers.expat import ExpatError import httpx +import xmltodict from homeassistant.core import HomeAssistant from homeassistant.helpers import template from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.json import json_dumps from homeassistant.util.ssl import SSLCipherList +from .const import XML_MIME_TYPES + DEFAULT_TIMEOUT = 10 _LOGGER = logging.getLogger(__name__) @@ -59,6 +64,26 @@ class RestData: """Set url.""" self._resource = url + def data_without_xml(self) -> str | None: + """If the data is an XML string, convert it to a JSON string.""" + _LOGGER.debug("Data fetched from resource: %s", self.data) + if ( + (value := self.data) is not None + # If the http request failed, headers will be None + and (headers := self.headers) is not None + and (content_type := headers.get("content-type")) + and content_type.startswith(XML_MIME_TYPES) + ): + try: + value = json_dumps(xmltodict.parse(value)) + except ExpatError: + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON" + ) + else: + _LOGGER.debug("JSON converted from XML: %s", self.data) + return value + async def async_update(self, log_errors: bool = True) -> None: """Get the latest data from REST service with provided method.""" if not self._async_client: diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 6fc0b69d1fd..18d0b6c7e76 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -3,11 +3,9 @@ from __future__ import annotations import logging import ssl -from xml.parsers.expat import ExpatError from jsonpath import jsonpath import voluptuous as vol -import xmltodict from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -26,7 +24,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template_entity import TemplateSensor from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -127,26 +124,7 @@ class RestSensor(RestEntity, TemplateSensor): def _update_from_rest_data(self) -> None: """Update state from the rest data.""" - value = self.rest.data - _LOGGER.debug("Data fetched from resource: %s", value) - if self.rest.headers is not None: - # If the http request failed, headers will be None - content_type = self.rest.headers.get("content-type") - - if content_type and ( - content_type.startswith("text/xml") - or content_type.startswith("application/xml") - or content_type.startswith("application/xhtml+xml") - or content_type.startswith("application/rss+xml") - ): - try: - value = json_dumps(xmltodict.parse(value)) - _LOGGER.debug("JSON converted from XML: %s", value) - except ExpatError: - _LOGGER.warning( - "REST xml result could not be parsed and converted to JSON" - ) - _LOGGER.debug("Erroneous XML: %s", value) + value = self.rest.data_without_xml() if self._json_attrs: self._attr_extra_state_attributes = {} diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index fd595ef07a6..a7674937ab8 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -899,7 +899,7 @@ async def test_update_with_xml_convert_bad_xml( state = hass.states.get("sensor.foo") assert state.state == STATE_UNKNOWN - assert "Erroneous XML" in caplog.text + assert "REST xml result could not be parsed" in caplog.text assert "Empty reply" in caplog.text @@ -936,7 +936,7 @@ async def test_update_with_failed_get( state = hass.states.get("sensor.foo") assert state.state == STATE_UNKNOWN - assert "Erroneous XML" in caplog.text + assert "REST xml result could not be parsed" in caplog.text assert "Empty reply" in caplog.text From d369d679c717a1929571fbccc26a14a56259c3e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Jun 2023 21:16:39 -1000 Subject: [PATCH 272/857] Fix ESPHome entries reloading after startup when dashboard is in use (#94362) --- homeassistant/components/esphome/__init__.py | 11 +- homeassistant/components/esphome/dashboard.py | 171 +++++++++++++----- tests/components/esphome/test_dashboard.py | 81 +++++++++ 3 files changed, 220 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 41b1b780a1a..5e113aff86f 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -56,10 +56,11 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType from .bluetooth import async_connect_scanner from .const import DOMAIN -from .dashboard import async_get_dashboard +from .dashboard import async_get_dashboard, async_setup as async_setup_dashboard from .domain_data import DomainData # Import config flow so that it's added to the registry @@ -79,6 +80,8 @@ PROJECT_URLS = { } DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + @callback def _async_check_firmware_version( @@ -135,6 +138,12 @@ def _async_check_using_api_password( ) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the esphome component.""" + await async_setup_dashboard(hass) + return True + + async def async_setup_entry( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry ) -> bool: diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index a8332f8d040..35e9cf74555 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import Any import aiohttp from awesomeversion import AwesomeVersion @@ -11,62 +12,148 @@ from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -KEY_DASHBOARD = "esphome_dashboard" +_LOGGER = logging.getLogger(__name__) + + +KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" + +STORAGE_KEY = "esphome.dashboard" +STORAGE_VERSION = 1 + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the ESPHome dashboard.""" + # Try to restore the dashboard manager from storage + # to avoid reloading every ESPHome config entry after + # Home Assistant starts and the dashboard is discovered. + await async_get_or_create_dashboard_manager(hass) + + +@singleton(KEY_DASHBOARD_MANAGER) +async def async_get_or_create_dashboard_manager( + hass: HomeAssistant, +) -> ESPHomeDashboardManager: + """Get the dashboard manager or create it.""" + manager = ESPHomeDashboardManager(hass) + await manager.async_setup() + return manager + + +class ESPHomeDashboardManager: + """Class to manage the dashboard and restore it from storage.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the dashboard manager.""" + self._hass = hass + self._store: Store[dict[str, Any]] = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, Any] | None = None + self._current_dashboard: ESPHomeDashboard | None = None + self._cancel_shutdown: CALLBACK_TYPE | None = None + + async def async_setup(self) -> None: + """Restore the dashboard from storage.""" + self._data = await self._store.async_load() + if (data := self._data) and (info := data.get("info")): + await self.async_set_dashboard_info( + info["addon_slug"], info["host"], info["port"] + ) + + @callback + def async_get(self) -> ESPHomeDashboard | None: + """Get the current dashboard.""" + return self._current_dashboard + + async def async_set_dashboard_info( + self, addon_slug: str, host: str, port: int + ) -> None: + """Set the dashboard info.""" + url = f"http://{host}:{port}" + hass = self._hass + + if cur_dashboard := self._current_dashboard: + if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url: + # Do nothing if we already have this data. + return + # Clear and make way for new dashboard + await cur_dashboard.async_shutdown() + if self._cancel_shutdown is not None: + self._cancel_shutdown() + self._cancel_shutdown = None + self._current_dashboard = None + + dashboard = ESPHomeDashboard( + hass, addon_slug, url, async_get_clientsession(hass) + ) + await dashboard.async_request_refresh() + if not cur_dashboard and not dashboard.last_update_success: + # If there was no previous dashboard and the new one is not available, + # we skip setup and wait for discovery. + _LOGGER.error( + "Dashboard unavailable; skipping setup: %s", dashboard.last_exception + ) + return + + self._current_dashboard = dashboard + + async def on_hass_stop(_: Event) -> None: + await dashboard.async_shutdown() + + self._cancel_shutdown = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, on_hass_stop + ) + + new_data = {"info": {"addon_slug": addon_slug, "host": host, "port": port}} + if self._data != new_data: + await self._store.async_save(new_data) + + reloads = [ + hass.config_entries.async_reload(entry.entry_id) + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + # Re-auth flows will check the dashboard for encryption key when the form is requested + # but we only trigger reauth if the dashboard is available. + if dashboard.last_update_success: + reauths = [ + hass.config_entries.flow.async_configure(flow["flow_id"]) + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == DOMAIN + and flow["context"]["source"] == SOURCE_REAUTH + ] + else: + reauths = [] + _LOGGER.error( + "Dashboard unavailable; skipping reauth: %s", dashboard.last_exception + ) + + _LOGGER.debug( + "Reloading %d and re-authenticating %d", len(reloads), len(reauths) + ) + if reloads or reauths: + await asyncio.gather(*reloads, *reauths) @callback def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: """Get an instance of the dashboard if set.""" - return hass.data.get(KEY_DASHBOARD) + manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER) + return manager.async_get() if manager else None async def async_set_dashboard_info( hass: HomeAssistant, addon_slug: str, host: str, port: int ) -> None: """Set the dashboard info.""" - url = f"http://{host}:{port}" - - if cur_dashboard := async_get_dashboard(hass): - if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url: - # Do nothing if we already have this data. - return - # Clear and make way for new dashboard - await cur_dashboard.async_shutdown() - del hass.data[KEY_DASHBOARD] - - dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass)) - try: - await dashboard.async_request_refresh() - except UpdateFailed as err: - logging.getLogger(__name__).error("Ignoring dashboard info: %s", err) - return - - hass.data[KEY_DASHBOARD] = dashboard - - async def on_hass_stop(_: Event) -> None: - await dashboard.async_shutdown() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) - - reloads = [ - hass.config_entries.async_reload(entry.entry_id) - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - # Re-auth flows will check the dashboard for encryption key when the form is requested - reauths = [ - hass.config_entries.flow.async_configure(flow["flow_id"]) - for flow in hass.config_entries.flow.async_progress() - if flow["handler"] == DOMAIN and flow["context"]["source"] == SOURCE_REAUTH - ] - if reloads or reauths: - await asyncio.gather(*reloads, *reauths) + manager = await async_get_or_create_dashboard_manager(hass) + await manager.async_set_dashboard_info(addon_slug, host, port) class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): @@ -82,7 +169,7 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): """Initialize.""" super().__init__( hass, - logging.getLogger(__name__), + _LOGGER, name="ESPHome Dashboard", update_interval=timedelta(minutes=5), ) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 580e741e03f..d16bf7c4d00 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -1,4 +1,5 @@ """Test ESPHome dashboard features.""" +import asyncio from unittest.mock import patch from aioesphomeapi import DeviceInfo, InvalidAuthAPIError @@ -10,6 +11,86 @@ from homeassistant.data_entry_flow import FlowResultType from . import VALID_NOISE_PSK +from tests.common import MockConfigEntry + + +async def test_dashboard_storage( + hass: HomeAssistant, init_integration, mock_dashboard, hass_storage +) -> None: + """Test dashboard storage.""" + assert hass_storage[dashboard.STORAGE_KEY]["data"] == { + "info": {"addon_slug": "mock-slug", "host": "mock-host", "port": 1234} + } + await dashboard.async_set_dashboard_info(hass, "test-slug", "new-host", 6052) + assert hass_storage[dashboard.STORAGE_KEY]["data"] == { + "info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052} + } + + +async def test_restore_dashboard_storage( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage +) -> MockConfigEntry: + """Restore dashboard url and slug from storage.""" + hass_storage[dashboard.STORAGE_KEY] = { + "version": dashboard.STORAGE_VERSION, + "minor_version": dashboard.STORAGE_VERSION, + "key": dashboard.STORAGE_KEY, + "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, + } + with patch.object( + dashboard, "async_get_or_create_dashboard_manager" + ) as mock_get_or_create: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_get_or_create.call_count == 1 + + +async def test_setup_dashboard_fails( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage +) -> MockConfigEntry: + """Test that nothing is stored on failed dashboard setup when there was no dashboard before.""" + with patch.object( + dashboard.ESPHomeDashboardAPI, "get_devices", side_effect=asyncio.TimeoutError + ) as mock_get_devices: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) + assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_get_devices.call_count == 1 + + assert dashboard.STORAGE_KEY not in hass_storage + + +async def test_setup_dashboard_fails_when_already_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage +) -> MockConfigEntry: + """Test failed dashboard setup still reloads entries if one existed before.""" + with patch.object(dashboard.ESPHomeDashboardAPI, "get_devices") as mock_get_devices: + await dashboard.async_set_dashboard_info( + hass, "test-slug", "working-host", 6052 + ) + await hass.async_block_till_done() + + assert mock_get_devices.call_count == 1 + assert dashboard.STORAGE_KEY in hass_storage + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch.object( + dashboard.ESPHomeDashboardAPI, "get_devices", side_effect=asyncio.TimeoutError + ) as mock_get_devices, patch( + "homeassistant.components.esphome.async_setup_entry", return_value=True + ) as mock_setup: + await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_get_devices.call_count == 1 + # We still setup, and reload, but we do not do the reauths + assert dashboard.STORAGE_KEY in hass_storage + assert len(mock_setup.mock_calls) == 1 + async def test_new_info_reload_config_entries( hass: HomeAssistant, init_integration, mock_dashboard From 334dacc322e0d3914146f304cb1f64641c428865 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 15 Jun 2023 11:09:53 +0200 Subject: [PATCH 273/857] Change Entity.name default to UNDEFINED (#94574) * Change Entity.name default to UNDEFINED * Update typing * Update Pylint plugin * Update TTS test --- homeassistant/components/adax/climate.py | 7 +- homeassistant/components/amcrest/helpers.py | 4 +- .../components/automation/__init__.py | 7 +- homeassistant/components/cups/sensor.py | 8 +- homeassistant/components/directv/entity.py | 7 +- .../components/iaqualink/__init__.py | 6 +- .../components/kaleidescape/entity.py | 6 +- homeassistant/components/plex/media_player.py | 7 +- homeassistant/components/roon/media_player.py | 7 +- homeassistant/components/samsungtv/entity.py | 6 +- homeassistant/components/tts/__init__.py | 4 +- homeassistant/components/zha/number.py | 3 +- homeassistant/helpers/entity.py | 85 +++++++-- homeassistant/helpers/entity_platform.py | 18 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/components/tts/test_init.py | 3 +- tests/helpers/test_entity.py | 164 +++++++++++++++--- 17 files changed, 277 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index cc15872dafa..0db6a3615f6 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -1,7 +1,7 @@ """Support for Adax wifi-enabled home heaters.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from adax import Adax from adax_local import Adax as AdaxLocal @@ -79,7 +79,10 @@ class AdaxDevice(ClimateEntity): self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, heater_data["id"])}, - name=self.name, + # Instead of setting the device name to the entity name, adax + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), manufacturer="Adax", ) diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index ff1a283769d..306c24a94ac 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from homeassistant.helpers.typing import UndefinedType + from .const import DOMAIN @@ -14,7 +16,7 @@ def service_signal(service: str, *args: str) -> str: def log_update_error( logger: logging.Logger, action: str, - name: str | None, + name: str | UndefinedType | None, entity_type: str, error: Exception, level: int = logging.ERROR, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 600cc6013e4..7220842db91 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -330,7 +330,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): trace_config: ConfigType, ) -> None: """Initialize an automation entity.""" - self._attr_name = name + self._name = name self._trigger_config = trigger_config self._async_detach_triggers: CALLBACK_TYPE | None = None self._cond_func = cond_func @@ -348,6 +348,11 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._trace_config = trace_config self._attr_unique_id = automation_id + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + @property def extra_state_attributes(self) -> dict[str, Any]: """Return the entity state attributes.""" diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index c642eb9112e..dd5366dee6a 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -115,10 +115,15 @@ class CupsSensor(SensorEntity): def __init__(self, data: CupsData, printer_name: str) -> None: """Initialize the CUPS sensor.""" self.data = data - self._attr_name = printer_name + self._name = printer_name self._printer: dict[str, Any] | None = None self._attr_available = False + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + @property def native_value(self): """Return the state of the sensor.""" @@ -149,7 +154,6 @@ class CupsSensor(SensorEntity): def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() - assert self.name is not None assert self.data.printers is not None self._printer = self.data.printers.get(self.name) self._attr_available = self.data.available diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index 08b24a50a75..9d1fd68b742 100644 --- a/homeassistant/components/directv/entity.py +++ b/homeassistant/components/directv/entity.py @@ -1,6 +1,8 @@ """Base DirecTV Entity.""" from __future__ import annotations +from typing import cast + from directv import DIRECTV from homeassistant.helpers.entity import DeviceInfo, Entity @@ -24,7 +26,10 @@ class DIRECTVEntity(Entity): return DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer=self.dtv.device.info.brand, - name=self.name, + # Instead of setting the device name to the entity name, directv + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), sw_version=self.dtv.device.info.version, via_device=(DOMAIN, self.dtv.device.info.receiver_id), ) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index e0b381d2362..5735e1ab421 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar, cast import httpx from iaqualink.client import AqualinkClient @@ -243,6 +243,8 @@ class AqualinkEntity(Entity): identifiers={(DOMAIN, self.unique_id)}, manufacturer=self.dev.manufacturer, model=self.dev.model, - name=self.name, + # Instead of setting the device name to the entity name, iaqualink + # should be updated to set has_entity_name = True + name=cast(str | None, self.name), via_device=(DOMAIN, self.dev.system.serial), ) diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index 9a5e62bca94..cab55c20c02 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo, Entity @@ -28,7 +28,9 @@ class KaleidescapeEntity(Entity): self._attr_name = f"{KALEIDESCAPE_NAME} {device.system.friendly_name}" self._attr_device_info = DeviceInfo( identifiers={(KALEIDESCAPE_DOMAIN, self._device.serial_number)}, - name=self.name, + # Instead of setting the device name to the entity name, kaleidescape + # should be updated to set has_entity_name = True + name=cast(str | None, self.name), model=self._device.system.type, manufacturer=KALEIDESCAPE_NAME, sw_version=f"{self._device.system.kos_version}", diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index be572679605..6585c011c2d 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar, cast import plexapi.exceptions import requests.exceptions @@ -535,7 +535,10 @@ class PlexMediaPlayer(MediaPlayerEntity): identifiers={(DOMAIN, self.machine_identifier)}, manufacturer=self.device_platform or "Plex", model=self.device_product or self.device_make, - name=self.name, + # Instead of setting the device name to the entity name, plex + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), sw_version=self.device_version, via_device=(DOMAIN, self.plex_server.machine_identifier), ) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 3bcafe4ba9a..6d096ea8b1a 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from roonapi import split_media_path import voluptuous as vol @@ -159,7 +159,10 @@ class RoonDevice(MediaPlayerEntity): dev_model = self.player_data["source_controls"][0].get("display_name") return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, - name=self.name, + # Instead of setting the device name to the entity name, roon + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, via_device=(DOMAIN, self._server.roon_id), diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 418feecbf94..4d5ea3d5fab 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -1,6 +1,8 @@ """Base SamsungTV Entity.""" from __future__ import annotations +from typing import cast + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME from homeassistant.helpers import device_registry as dr @@ -20,7 +22,9 @@ class SamsungTVEntity(Entity): self._attr_name = config_entry.data.get(CONF_NAME) self._attr_unique_id = config_entry.unique_id self._attr_device_info = DeviceInfo( - name=self.name, + # Instead of setting the device name to the entity name, samsungtv + # should be updated to set has_entity_name = True + name=cast(str | None, self.name), manufacturer=config_entry.data.get(CONF_MANUFACTURER), model=config_entry.data.get(CONF_MODEL), ) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 254abbd0d63..8ee1b67020a 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -44,7 +44,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType from homeassistant.util import dt as dt_util, language as language_util from .const import ( @@ -610,7 +610,7 @@ class SpeechManager: async def get_tts_data() -> str: """Handle data available.""" - if engine_instance.name is None: + if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") if isinstance(engine_instance, Provider): diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index baba8f7fd5b..29d6cafe3c8 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -15,6 +15,7 @@ from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemp from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UndefinedType from .core import discovery from .core.const import ( @@ -334,7 +335,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): return super().native_step @property - def name(self) -> str | None: + def name(self) -> str | UndefinedType | None: """Return the name of the number entity.""" description = self._analog_output_cluster_handler.description if description is not None and len(description) > 0: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index cb947ac7604..41fe362ece3 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -258,6 +258,9 @@ class Entity(ABC): # it should be using async_write_ha_state. _async_update_ha_state_reported = False + # If we reported this entity is implicitly using device name + _implicit_device_name_reported = False + # If we reported this entity was added without its platform set _no_platform_reported = False @@ -319,6 +322,53 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id + @property + def use_device_name(self) -> bool: + """Return if this entity does not have its own name. + + Should be True if the entity represents the single main feature of a device. + """ + + def report_implicit_device_name() -> None: + """Report entities which use implicit device name.""" + if self._implicit_device_name_reported: + return + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is implicitly using device name by not setting its " + "name. Instead, the name should be set to None, please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + self._implicit_device_name_reported = True + + if hasattr(self, "_attr_name"): + return not self._attr_name + + if name_translation_key := self._name_translation_key(): + if name_translation_key in self.platform.platform_translations: + return False + + if hasattr(self, "entity_description"): + if not (name := self.entity_description.name): + return True + if name is UNDEFINED: + # Backwards compatibility with leaving EntityDescription.name unassigned + # for device name. + # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 + report_implicit_device_name() + return True + return False + if self.name is UNDEFINED: + # Backwards compatibility with not overriding name property for device name. + # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 + report_implicit_device_name() + return True + return not self.name + @property def has_entity_name(self) -> bool: """Return if the name of the entity is describing only the entity itself.""" @@ -344,16 +394,23 @@ class Entity(ABC): """Return True if an unnamed entity should be named by its device class.""" return False + def _name_translation_key(self) -> str | None: + """Return translation key for entity name.""" + if self.translation_key is None: + return None + return ( + f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + f".{self.translation_key}.name" + ) + @property - def name(self) -> str | None: + def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" if hasattr(self, "_attr_name"): return self._attr_name - if self.translation_key is not None and self.has_entity_name: - name_translation_key = ( - f"component.{self.platform.platform_name}.entity.{self.platform.domain}" - f".{self.translation_key}.name" - ) + if self.has_entity_name and ( + name_translation_key := self._name_translation_key() + ): if name_translation_key in self.platform.platform_translations: name: str = self.platform.platform_translations[name_translation_key] return name @@ -361,15 +418,13 @@ class Entity(ABC): description_name = self.entity_description.name if description_name is UNDEFINED and self._default_to_device_class_name(): return self._device_class_name() - if description_name is not UNDEFINED: - return description_name - return None + return description_name # The entity has no name set by _attr_name, translation_key or entity_description # Check if the entity should be named by its device class if self._default_to_device_class_name(): return self._device_class_name() - return None + return UNDEFINED @property def state(self) -> StateType: @@ -653,16 +708,20 @@ class Entity(ABC): If has_entity_name is False, this returns self.name If has_entity_name is True, this returns device.name + self.name """ + name = self.name + if name is UNDEFINED: + name = None + if not self.has_entity_name or not self.registry_entry: - return self.name + return name device_registry = dr.async_get(self.hass) if not (device_id := self.registry_entry.device_id) or not ( device_entry := device_registry.async_get(device_id) ): - return self.name + return name - if not (name := self.name): + if self.use_device_name: return device_entry.name_by_user or device_entry.name return f"{device_entry.name_by_user or device_entry.name} {name}" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ddc741b7d35..46cc46eb96f 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -45,7 +45,7 @@ from .device_registry import DeviceRegistry from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later, async_track_time_interval from .issue_registry import IssueSeverity, async_create_issue -from .typing import ConfigType, DiscoveryInfoType +from .typing import UNDEFINED, ConfigType, DiscoveryInfoType if TYPE_CHECKING: from .entity import Entity @@ -552,6 +552,10 @@ class EntityPlatform: suggested_object_id: str | None = None generate_new_entity_id = False + entity_name = entity.name + if entity_name is UNDEFINED: + entity_name = None + # Get entity_id from unique ID registration if entity.unique_id is not None: registered_entity_id = entity_registry.async_get_entity_id( @@ -645,12 +649,12 @@ class EntityPlatform: else: if device and entity.has_entity_name: device_name = device.name_by_user or device.name - if not entity.name: + if entity.use_device_name: suggested_object_id = device_name else: - suggested_object_id = f"{device_name} {entity.name}" + suggested_object_id = f"{device_name} {entity_name}" if not suggested_object_id: - suggested_object_id = entity.name + suggested_object_id = entity_name if self.entity_namespace is not None: suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" @@ -678,7 +682,7 @@ class EntityPlatform: known_object_ids=self.entities.keys(), original_device_class=entity.device_class, original_icon=entity.icon, - original_name=entity.name, + original_name=entity_name, suggested_object_id=suggested_object_id, supported_features=entity.supported_features, translation_key=entity.translation_key, @@ -705,7 +709,7 @@ class EntityPlatform: # Generate entity ID if entity.entity_id is None or generate_new_entity_id: suggested_object_id = ( - suggested_object_id or entity.name or DEVICE_DEFAULT_NAME + suggested_object_id or entity_name or DEVICE_DEFAULT_NAME ) if self.entity_namespace is not None: @@ -732,7 +736,7 @@ class EntityPlatform: self.logger.debug( "Not adding entity %s because it's disabled", entry.name - or entity.name + or entity_name or f'"{self.platform_name} {entity.unique_id}"', ) entity.add_to_platform_abort() diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 1d7bac65c19..0320ff2d4f4 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -573,7 +573,7 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ ), TypeHintMatch( function_name="name", - return_type=["str", None], + return_type=["str", "UndefinedType", None], ), TypeHintMatch( function_name="state", diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 0d110f25b50..cedc4c7cae9 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -22,6 +22,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.network import normalize_url @@ -68,7 +69,7 @@ async def test_default_entity_attributes() -> None: entity = DefaultEntity() assert entity.hass is None - assert entity.name is None + assert entity.name is UNDEFINED assert entity.default_language == DEFAULT_LANG assert entity.supported_languages == SUPPORT_LANGUAGES assert entity.supported_options is None diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index f1133e30483..1168f4b40f8 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity, entity_registry as er +from homeassistant.helpers.typing import UNDEFINED from tests.common import ( MockConfigEntry, @@ -948,39 +949,24 @@ async def test_entity_description_fallback() -> None: assert getattr(ent, field.name) == getattr(ent_with_description, field.name) -@pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name"), - ( - (False, "Entity Blu", "Entity Blu"), - (False, None, None), - (True, "Entity Blu", "Device Bla Entity Blu"), - (True, None, "Device Bla"), - ), -) -async def test_friendly_name( +async def _test_friendly_name( hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + ent: entity.Entity, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, + warn_implicit_name: bool, ) -> None: - """Test entity_id is influenced by entity name.""" + """Test friendly name.""" + + expected_warning = ( + f"Entity {ent.entity_id} ({type(ent)}) is implicitly using device name" + ) async def async_setup_entry(hass, config_entry, async_add_entities): """Mock setup entry method.""" - async_add_entities( - [ - MockEntity( - unique_id="qwer", - device_info={ - "identifiers": {("hue", "1234")}, - "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, - "name": "Device Bla", - }, - has_entity_name=has_entity_name, - name=entity_name, - ), - ] - ) + async_add_entities([ent]) return True platform = MockPlatform(async_setup_entry=async_setup_entry) @@ -995,6 +981,132 @@ async def test_friendly_name( assert len(hass.states.async_entity_ids()) == 1 state = hass.states.async_all()[0] assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name + assert (expected_warning in caplog.text) is warn_implicit_name + + +@pytest.mark.parametrize( + ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ( + (False, "Entity Blu", "Entity Blu", False), + (False, None, None, False), + (True, "Entity Blu", "Device Bla Entity Blu", False), + (True, None, "Device Bla", False), + ), +) +async def test_friendly_name_attr( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + has_entity_name: bool, + entity_name: str | None, + expected_friendly_name: str | None, + warn_implicit_name: bool, +) -> None: + """Test friendly name when the entity uses _attr_*.""" + + ent = MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + ) + ent._attr_has_entity_name = has_entity_name + ent._attr_name = entity_name + await _test_friendly_name( + hass, + caplog, + ent, + has_entity_name, + entity_name, + expected_friendly_name, + warn_implicit_name, + ) + + +@pytest.mark.parametrize( + ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ( + (False, "Entity Blu", "Entity Blu", False), + (False, None, None, False), + (False, UNDEFINED, None, False), + (True, "Entity Blu", "Device Bla Entity Blu", False), + (True, None, "Device Bla", False), + (True, UNDEFINED, "Device Bla", True), + ), +) +async def test_friendly_name_description( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + has_entity_name: bool, + entity_name: str | None, + expected_friendly_name: str | None, + warn_implicit_name: bool, +) -> None: + """Test friendly name when the entity has an entity description.""" + + ent = MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + ) + ent.entity_description = entity.EntityDescription( + "test", has_entity_name=has_entity_name, name=entity_name + ) + await _test_friendly_name( + hass, + caplog, + ent, + has_entity_name, + entity_name, + expected_friendly_name, + warn_implicit_name, + ) + + +@pytest.mark.parametrize( + ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ( + (False, "Entity Blu", "Entity Blu", False), + (False, None, None, False), + (False, UNDEFINED, None, False), + (True, "Entity Blu", "Device Bla Entity Blu", False), + (True, None, "Device Bla", False), + (True, UNDEFINED, "Device Bla", True), + ), +) +async def test_friendly_name_property( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + has_entity_name: bool, + entity_name: str | None, + expected_friendly_name: str | None, + warn_implicit_name: bool, +) -> None: + """Test friendly name when the entity has overridden the name property.""" + + ent = MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ) + await _test_friendly_name( + hass, + caplog, + ent, + has_entity_name, + entity_name, + expected_friendly_name, + warn_implicit_name, + ) @pytest.mark.parametrize( @@ -1028,7 +1140,7 @@ async def test_friendly_name_updated( expected_friendly_name2: str, expected_friendly_name3: str, ) -> None: - """Test entity_id is influenced by entity name.""" + """Test friendly name is updated when device or entity registry updates.""" async def async_setup_entry(hass, config_entry, async_add_entities): """Mock setup entry method.""" From 64d914d56da15921b1e3332bca0d983e76488d19 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 15 Jun 2023 12:30:52 +0200 Subject: [PATCH 274/857] Bump minimum typing_extensions to 4.6.3 (#94587) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 894b98d3806..027d74a21e7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ PyYAML==6.0 requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.15 -typing_extensions>=4.5.0,<5.0 +typing_extensions>=4.6.3,<5.0 ulid-transform==0.7.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/pyproject.toml b/pyproject.toml index 247b63d688a..ee351493323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "python-slugify==4.0.1", "PyYAML==6.0", "requests==2.31.0", - "typing_extensions>=4.5.0,<5.0", + "typing_extensions>=4.6.3,<5.0", "ulid-transform==0.7.2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", diff --git a/requirements.txt b/requirements.txt index 6d5c40777c1..bf21c2d8643 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ pip>=21.0,<23.2 python-slugify==4.0.1 PyYAML==6.0 requests==2.31.0 -typing_extensions>=4.5.0,<5.0 +typing_extensions>=4.6.3,<5.0 ulid-transform==0.7.2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 From 6c4fe9fc3bbfd80533c7697855e77502835affdf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:04:38 +0200 Subject: [PATCH 275/857] Fix HAVCMode typing in Intellifire (#94633) --- homeassistant/components/intellifire/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 649234d7568..5d305db8feb 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -66,7 +66,7 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): self.last_temp = coordinator.data.thermostat_setpoint_c @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return current hvac mode.""" if self.coordinator.read_api.data.thermostat_on: return HVACMode.HEAT From 886dea59c3cef0a5029870eb031aa552416593a0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:05:08 +0200 Subject: [PATCH 276/857] Fix HAVCMode typing in Tuya (#94631) --- homeassistant/components/tuya/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 564bfab8b14..bcb97327006 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -205,7 +205,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): self._attr_target_temperature_step = self._set_temperature.step_scaled # Determine HVAC modes - self._attr_hvac_modes: list[str] = [] + self._attr_hvac_modes: list[HVACMode] = [] self._hvac_to_tuya = {} if enum_type := self.find_dpcode( DPCode.MODE, dptype=DPType.ENUM, prefer_function=True From 1d3a7512d83d7ca8151d2721cd725c47aa44fe73 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:06:06 +0200 Subject: [PATCH 277/857] Fix HAVCMode typing in Overkiz (#94632) --- ...lectrical_heater_with_adjustable_temperature_setpoint.py | 4 ++-- .../climate_entities/atlantic_electrical_towel_dryer.py | 6 +++--- .../climate_entities/atlantic_pass_apc_heating_zone.py | 6 +++--- .../climate_entities/atlantic_pass_apc_zone_control.py | 6 +++--- .../components/overkiz/climate_entities/somfy_thermostat.py | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index d79d2fca686..5807ccecd74 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -45,7 +45,7 @@ OVERKIZ_TO_PRESET_MODE: dict[str, str] = { PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} # Map Overkiz HVAC modes to Home Assistant HVAC modes -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.ON: HVACMode.HEAT, OverkizCommandParam.OFF: HVACMode.OFF, OverkizCommandParam.AUTO: HVACMode.AUTO, @@ -83,7 +83,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" states = self.device.states if (state := states[OverkizState.CORE_OPERATING_MODE]) and state.value_as_str: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py index c8e4920a113..0c378d088c5 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -20,7 +20,7 @@ from ..entity import OverkizEntity PRESET_DRYING = "drying" -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog OverkizCommandParam.STANDBY: HVACMode.OFF, @@ -62,7 +62,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" if OverkizState.CORE_OPERATING_MODE in self.device.states: return OVERKIZ_TO_HVAC_MODE[ @@ -71,7 +71,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): return HVACMode.OFF - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self.executor.async_execute_command( OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index b6835d93ebb..7722269a48b 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -21,7 +21,7 @@ from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.AUTO: HVACMode.AUTO, OverkizCommandParam.ECO: HVACMode.AUTO, OverkizCommandParam.MANU: HVACMode.HEAT, @@ -101,7 +101,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): return None @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return OVERKIZ_TO_HVAC_MODE[ cast(str, self.executor.select_state(OverkizState.IO_PASS_APC_HEATING_MODE)) @@ -135,7 +135,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): OverkizCommand.REFRESH_PASS_APC_HEATING_PROFILE ) - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self.async_set_heating_mode(HVAC_MODE_TO_OVERKIZ[hvac_mode]) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index 33c1f0c4a2a..74f7637b997 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -8,7 +8,7 @@ from homeassistant.const import UnitOfTemperature from ..entity import OverkizEntity -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.HEATING: HVACMode.HEAT, OverkizCommandParam.DRYING: HVACMode.DRY, OverkizCommandParam.COOLING: HVACMode.COOL, @@ -25,7 +25,7 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return OVERKIZ_TO_HVAC_MODE[ cast( @@ -33,7 +33,7 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): ) ] - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self.executor.async_execute_command( OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index aaae64e0454..7409b5307cf 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -74,7 +74,7 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return OVERKIZ_TO_HVAC_MODES[ cast( From e49f1b002f5f669713ba8ef358d7f83936108fe7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:07:08 +0200 Subject: [PATCH 278/857] Fix HAVCMode typing in Fibaro (#94641) --- homeassistant/components/fibaro/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index f4b1cd0c1f5..a56056ade03 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -263,7 +263,7 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): return device.mode @property - def hvac_mode(self) -> HVACMode | str | None: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool, idle.""" fibaro_operation_mode = self.fibaro_op_mode if isinstance(fibaro_operation_mode, str): From b1abe6812b6e974e8c6aa16ea7fe7b38a4d0ffd4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:07:41 +0200 Subject: [PATCH 279/857] Fix HAVCMode typing in Honeywell Total Connect Comfort (#94636) --- homeassistant/components/evohome/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index d4ee8f3d5df..3bee1d6062e 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -131,7 +131,7 @@ class EvoClimateEntity(EvoDevice, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def hvac_modes(self) -> list[str]: + def hvac_modes(self) -> list[HVACMode]: """Return a list of available hvac operation modes.""" return list(HA_HVAC_TO_TCS) @@ -191,7 +191,7 @@ class EvoZone(EvoChild, EvoClimateEntity): ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Zone.""" if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return HVACMode.AUTO @@ -356,7 +356,7 @@ class EvoController(EvoClimateEntity): ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" tcs_mode = self._evo_tcs.systemModeStatus["mode"] return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT From e899cb3caa8cc5e46e86be2873da1a377d412d9a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:08:38 +0200 Subject: [PATCH 280/857] Fix HAVCMode typing in Genius Hub (#94640) --- homeassistant/components/geniushub/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index c2b32582cef..87c8b851ea9 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -66,12 +66,12 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): return "mdi:radiator" @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return GH_HVAC_TO_HA.get(self._zone.data["mode"], HVACMode.HEAT) @property - def hvac_modes(self) -> list[str]: + def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" return list(HA_HVAC_TO_GH) From 6f106e650517428ecfb88a150d043c58bfa8c62d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:11:18 +0200 Subject: [PATCH 281/857] Fix HAVCMode typing in AVM FRITZ!SmartHome (#94642) --- homeassistant/components/fritzbox/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 31cdac47ec2..7c846789637 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -101,7 +101,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): await self.coordinator.async_refresh() @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return the current operation mode.""" if self.data.target_temperature in ( OFF_REPORT_SET_TEMPERATURE, From 908f3386e71295a3b102db369439bb7beadbe969 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:12:11 +0200 Subject: [PATCH 282/857] Fix HAVCMode typing in ESPHome (#94630) --- homeassistant/components/esphome/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index e40df234d58..b01e89ec2c8 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -152,7 +152,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti return PRECISION_TENTHS @property - def hvac_modes(self) -> list[str]: + def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" return [ _CLIMATE_MODES.from_esphome(mode) @@ -217,13 +217,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @property @esphome_state_property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" return _CLIMATE_MODES.from_esphome(self._state.mode) @property @esphome_state_property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction | None: """Return current action.""" # HA has no support feature field for hvac_action if not self._static_info.supports_action: From 998a45879e2d7057115b075d5d12659d9a70f6e2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:13:20 +0200 Subject: [PATCH 283/857] Use HAVCMode enum in BSB-Lan climate (#94638) --- homeassistant/components/bsblan/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index cbc6dd00471..47afdf1539b 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -23,6 +23,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util.enum import try_parse_enum from . import HomeAssistantBSBLANData from .const import ATTR_TARGET_TEMPERATURE, DOMAIN, LOGGER @@ -112,12 +113,11 @@ class BSBLANClimate( return float(self.coordinator.data.target_temperature.value) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" if self.coordinator.data.hvac_mode.value == PRESET_ECO: return HVACMode.AUTO - - return self.coordinator.data.hvac_mode.value + return try_parse_enum(HVACMode, self.coordinator.data.hvac_mode.value) @property def preset_mode(self) -> str | None: From 204833b745c0ef99945bce93b0980c861f5bdeb1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:13:59 +0200 Subject: [PATCH 284/857] Fix HAVCMode typing in Rheem EcoNet (#94637) --- homeassistant/components/econet/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index cf950a3c38c..7233d135f2e 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -151,7 +151,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return self.op_list @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool, mode. Needs to be one of HVAC_MODE_*. From d78429ad956f846d922c7065274e24d961c8b02d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 14:14:25 +0200 Subject: [PATCH 285/857] Use HAVCMode enum in Atag climate (#94634) --- homeassistant/components/atag/climate.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index ce52bd4fd65..00897f127d9 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.enum import try_parse_enum from . import DOMAIN, AtagEntity @@ -52,11 +53,9 @@ class AtagThermostat(AtagEntity, ClimateEntity): self._attr_temperature_unit = coordinator.data.climate.temp_unit @property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" - if self.coordinator.data.climate.hvac_mode in HVAC_MODES: - return self.coordinator.data.climate.hvac_mode - return None + return try_parse_enum(HVACMode, self.coordinator.data.climate.hvac_mode) @property def hvac_action(self) -> str | None: From a7955e445832ee7b63cd76fc85e8d29f92be6ee0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 15:27:49 +0200 Subject: [PATCH 286/857] Fix HAVCMode typing in Elk-M1 Control (#94639) --- homeassistant/components/elkm1/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 570c8567403..d0094a5b37b 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -85,7 +85,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: """Initialize climate entity.""" super().__init__(element, elk, elk_data) - self._state: str | None = None + self._state: HVACMode | None = None @property def temperature_unit(self) -> str: @@ -130,7 +130,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): return self._element.humidity @property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" return self._state From 562f0d3c527e11727ac763e562488c35ae69602b Mon Sep 17 00:00:00 2001 From: Kim Frellsen Date: Thu, 15 Jun 2023 15:34:14 +0200 Subject: [PATCH 287/857] Fortios device tracker updates (#92331) Co-authored-by: Martin Hjelmare Co-authored-by: Erik Montnemery --- .../components/fortios/device_tracker.py | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 95a418ae40f..d941375c8a3 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -43,7 +43,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> FortiOSDeviceScanner fgt = FortiOSAPI() try: - fgt.tokenlogin(host, token, verify_ssl) + fgt.tokenlogin(host, token, verify_ssl, None, 12, "root") except ConnectionError as ex: _LOGGER.error("ConnectionError to FortiOS API: %s", ex) return None @@ -77,7 +77,12 @@ class FortiOSDeviceScanner(DeviceScanner): def update(self): """Update clients from the device.""" - clients_json = self._fgt.monitor("user/device/query", "") + clients_json = self._fgt.monitor( + "user/device/query", + "", + parameters={"filter": "format=master_mac|hostname|is_online"}, + ) + self._clients_json = clients_json self._clients = [] @@ -85,8 +90,12 @@ class FortiOSDeviceScanner(DeviceScanner): if clients_json: try: for client in clients_json["results"]: - if client["is_online"]: - self._clients.append(client["mac"].upper()) + if ( + "is_online" in client + and "master_mac" in client + and client["is_online"] + ): + self._clients.append(client["master_mac"].upper()) except KeyError as kex: _LOGGER.error("Key not found in clients: %s", kex) @@ -106,17 +115,10 @@ class FortiOSDeviceScanner(DeviceScanner): return None for client in data["results"]: - if client["mac"] == device: - try: + if "master_mac" in client and client["master_mac"] == device: + if "hostname" in client: name = client["hostname"] - _LOGGER.debug("Getting device name=%s", name) - return name - except KeyError as kex: - _LOGGER.debug( - "No hostname found for %s in client data: %s", - device, - kex, - ) - return device.replace(":", "_") - + else: + name = client["master_mac"].replace(":", "_") + return name return None From 7e5a9ea6c73f55be76e5c345324b082f1098aa7b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 18:42:46 +0200 Subject: [PATCH 288/857] Fix HAVCAction typing in Overkiz (#94660) --- .../climate_entities/valve_heating_temperature_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py index fdaf0d61f1f..3d883738de2 100644 --- a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -73,7 +73,7 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): ) @property - def hvac_action(self) -> str: + def hvac_action(self) -> HVACAction: """Return the current running hvac operation.""" return OVERKIZ_TO_HVAC_ACTION[ cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)) From 68f87fe42acdc49a499b7591a9a0dc595041b0d8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 18:43:11 +0200 Subject: [PATCH 289/857] Fix HAVCAction typing in Genius Hub (#94659) --- homeassistant/components/geniushub/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 87c8b851ea9..bafda44501b 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -76,7 +76,7 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): return list(HA_HVAC_TO_GH) @property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" if "_state" in self._zone.data: # only for v3 API if self._zone.data["output"] == 1: From 81d46fe2b2b978f8fcd82ff53f7bde719be6e3ee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 18:43:32 +0200 Subject: [PATCH 290/857] Fix HAVCAction typing in Balboa Spa Client (#94658) --- homeassistant/components/balboa/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 06e8d265502..0d0fa9bd179 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -78,7 +78,7 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): return HEAT_HVAC_MODE_MAP.get(self._client.heat_mode.state) @property - def hvac_action(self) -> str: + def hvac_action(self) -> HVACAction: """Return the current operation mode.""" return HEAT_STATE_HVAC_ACTION_MAP[self._client.heat_state] From 324bd9a97a09f6b7c33c9492533babf9b4de8c90 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 18:43:56 +0200 Subject: [PATCH 291/857] Fix HAVCAction typing in Atag (#94656) --- homeassistant/components/atag/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index 00897f127d9..9b2729f141e 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -58,7 +58,7 @@ class AtagThermostat(AtagEntity, ClimateEntity): return try_parse_enum(HVACMode, self.coordinator.data.climate.hvac_mode) @property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation.""" is_active = self.coordinator.data.climate.status return HVACAction.HEATING if is_active else HVACAction.IDLE From 21bdcd6b63a90a543725f50c2ce3b9cc95c0a844 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 15 Jun 2023 20:16:17 +0200 Subject: [PATCH 292/857] Remove str as a valid HVACMode & HVACAction type (#94644) * Remove str as a valid HAVCMode type * Adjust pylint plugin * Also remove str from hvac_action property method --- homeassistant/components/climate/__init__.py | 12 ++++++------ pylint/plugins/hass_enforce_type_hints.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index ed0f8f2a4aa..c5bc22ebc0c 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -214,9 +214,9 @@ class ClimateEntity(Entity): _attr_current_temperature: float | None = None _attr_fan_mode: str | None _attr_fan_modes: list[str] | None - _attr_hvac_action: HVACAction | str | None = None - _attr_hvac_mode: HVACMode | str | None - _attr_hvac_modes: list[HVACMode] | list[str] + _attr_hvac_action: HVACAction | None = None + _attr_hvac_mode: HVACMode | None + _attr_hvac_modes: list[HVACMode] _attr_is_aux_heat: bool | None _attr_max_humidity: int = DEFAULT_MAX_HUMIDITY _attr_max_temp: float @@ -361,17 +361,17 @@ class ClimateEntity(Entity): return self._attr_target_humidity @property - def hvac_mode(self) -> HVACMode | str | None: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" return self._attr_hvac_mode @property - def hvac_modes(self) -> list[HVACMode] | list[str]: + def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" return self._attr_hvac_modes @property - def hvac_action(self) -> HVACAction | str | None: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return self._attr_hvac_action diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 0320ff2d4f4..85b1b1370a1 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1023,15 +1023,15 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="hvac_mode", - return_type=["HVACMode", "str", None], + return_type=["HVACMode", None], ), TypeHintMatch( function_name="hvac_modes", - return_type=["list[HVACMode]", "list[str]"], + return_type="list[HVACMode]", ), TypeHintMatch( function_name="hvac_action", - return_type=["HVACAction", "str", None], + return_type=["HVACAction", None], ), TypeHintMatch( function_name="current_temperature", From b104680c6d1c517c565d0f92e70b1c4f6df15379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Modzelewski?= Date: Thu, 15 Jun 2023 23:57:36 +0200 Subject: [PATCH 293/857] Bump pyatv to 0.13.0 (#94683) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 2d2df955441..e95c4847a0e 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.12.0"], + "requirements": ["pyatv==0.13.0"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index ddf6bcdd98f..5a729dbaebd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.12.0 +pyatv==0.13.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7372d3f459c..db5b1a9f0c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1165,7 +1165,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.12.0 +pyatv==0.13.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 From 5e55f83cbc769080ea8acc78c142233aa49deaeb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 16 Jun 2023 00:44:58 +0200 Subject: [PATCH 294/857] Correct imap sensor measurement class and add suggested precision (#94060) * Fix imap sensor measurement class and precision * Test measurement class is set correctly * Remove unrelated changes * Move EntityDescription to module level --- homeassistant/components/imap/sensor.py | 17 ++++++++++++++--- tests/components/imap/test_init.py | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 776abc174a2..929ce6a9f61 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -1,7 +1,11 @@ """IMAP sensor support.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant @@ -13,6 +17,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator from .const import DOMAIN +IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( + key="imap_mail_count", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -22,8 +32,7 @@ async def async_setup_entry( coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( hass.data[DOMAIN][entry.entry_id] ) - - async_add_entities([ImapSensor(coordinator)]) + async_add_entities([ImapSensor(coordinator, IMAP_MAIL_COUNT_DESCRIPTION)]) class ImapSensor( @@ -38,9 +47,11 @@ class ImapSensor( def __init__( self, coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 2b7514cd3ea..ff949423614 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.imap import DOMAIN from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder +from homeassistant.components.sensor.const import SensorStateClass from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -135,6 +136,7 @@ async def test_receiving_message_successfully( # we should have received one message assert state is not None assert state.state == "1" + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT # we should have received one event assert len(event_called) == 1 From 298f763f120e2d6d6b75af126e86d1fb3ef455f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Jun 2023 15:15:33 -1000 Subject: [PATCH 295/857] Drop codeowner for myq (#94699) All of my garage doors have been migrated to using https://esphome-ratgdo.github.io/esphome-ratgdo/ All of my gates have been migrated to using a KC868-A4 board https://www.kincony.com/kc868-a4-digital-input-trigger-relay-output-esphome.html I no longer use myq in production as there is now a viable esphome replacement every place I was using myq --- CODEOWNERS | 4 ++-- homeassistant/components/myq/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index c1545d61429..0cc09743873 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -783,8 +783,8 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myq/ @bdraco @ehendrix23 -/tests/components/myq/ @bdraco @ehendrix23 +/homeassistant/components/myq/ @ehendrix23 +/tests/components/myq/ @ehendrix23 /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 05f698f2170..5e03f962d15 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -1,7 +1,7 @@ { "domain": "myq", "name": "MyQ", - "codeowners": ["@bdraco", "@ehendrix23"], + "codeowners": ["@ehendrix23"], "config_flow": true, "dhcp": [ { From 45bf1235d86d8651a830516da5a477dc8a0ffeec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Jun 2023 16:09:10 -1000 Subject: [PATCH 296/857] Remove airplay filter now that apple tv supports airplay 2 (#94693) remove airplay filter --- .../components/apple_tv/manifest.json | 19 +------------------ homeassistant/generated/zeroconf.py | 15 --------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index e95c4847a0e..9824f608f21 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -16,24 +16,7 @@ "_touch-able._tcp.local.", "_appletv-v2._tcp.local.", "_hscp._tcp.local.", - { - "type": "_airplay._tcp.local.", - "properties": { - "model": "appletv*" - } - }, - { - "type": "_airplay._tcp.local.", - "properties": { - "model": "audioaccessory*" - } - }, - { - "type": "_airplay._tcp.local.", - "properties": { - "am": "airport*" - } - }, + "_airplay._tcp.local.", { "type": "_raop._tcp.local.", "properties": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 93ccb404ae4..ea4c1c92816 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -251,21 +251,6 @@ ZEROCONF = { "_airplay._tcp.local.": [ { "domain": "apple_tv", - "properties": { - "model": "appletv*", - }, - }, - { - "domain": "apple_tv", - "properties": { - "model": "audioaccessory*", - }, - }, - { - "domain": "apple_tv", - "properties": { - "am": "airport*", - }, }, { "domain": "samsungtv", From 3440c1615d15265e1a0256c6e2873af407e68eda Mon Sep 17 00:00:00 2001 From: Dirk Sarodnick Date: Fri, 16 Jun 2023 04:10:04 +0200 Subject: [PATCH 297/857] Fix bluetooth tracker asyncio usage (#94695) * fix for asyncio usage fixes the error "Passing coroutines is forbidden, use tasks explicitly", caused by passing an async function into asyncio.wait directly instead of creating a task for it. * removes unnecessary default param * corrects formatting for black --- .../components/bluetooth_tracker/device_tracker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 659243df733..f4fc6a8df08 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -173,7 +173,11 @@ async def async_setup_scanner( rssi = await hass.async_add_executor_job(client.request_rssi) client.close() - tasks.append(see_device(hass, async_see, mac, friendly_name, rssi)) + tasks.append( + asyncio.create_task( + see_device(hass, async_see, mac, friendly_name, rssi) + ) + ) if tasks: await asyncio.wait(tasks) From 7e3510800d03159779e437910d6133d1eceb2be6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Jun 2023 16:11:14 -1000 Subject: [PATCH 298/857] Bump bluetooth-data-tools to 1.2.0 (#94696) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.1.0...v1.2.0 benchmark (0.4.0) currently in 2023.6.x: Parsing 100000 bluetooth messages took 8.928823958034627 seconds benchmark (1.2.0) this PR: Parsing 100000 bluetooth messages took 1.6808899159659632 seconds --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1faa546744f..2d96897ef9d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.2.0", - "bluetooth-data-tools==1.1.0", + "bluetooth-data-tools==1.2.0", "dbus-fast==1.86.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f2b98387f94..bc049153b8f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==14.0.0", - "bluetooth-data-tools==1.1.0", + "bluetooth-data-tools==1.2.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 7936bd64efd..b15eb343c06 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble/", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.1.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.2.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index f0fc0981065..b788ec21052 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble/", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.1.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.2.0", "led-ble==1.0.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 027d74a21e7..fdfd3560075 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.0.2 bleak==0.20.2 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.2.0 -bluetooth-data-tools==1.1.0 +bluetooth-data-tools==1.2.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==40.0.2 diff --git a/requirements_all.txt b/requirements_all.txt index 5a729dbaebd..e828fc104be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -540,7 +540,7 @@ bluetooth-auto-recovery==1.2.0 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.1.0 +bluetooth-data-tools==1.2.0 # homeassistant.components.bond bond-async==0.1.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db5b1a9f0c9..24daaf1cec2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ bluetooth-auto-recovery==1.2.0 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.1.0 +bluetooth-data-tools==1.2.0 # homeassistant.components.bond bond-async==0.1.23 From 34b725bb99af98b3328f58d343e9b55378911179 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Jun 2023 16:15:07 -1000 Subject: [PATCH 299/857] Debounce discoveries to improve event loop stability at the started event (#94690) * Debounce discoveries to improve event loop stability at the started event The first one is immediate and anything that fires within the next second will be debounced to only happen once every second * fix mock --- homeassistant/config_entries.py | 36 ++++++++++++++++++++++++--------- tests/common.py | 3 +++ tests/test_config_entries.py | 17 +++++++++++----- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 14d69e278fa..a52b869b830 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -29,6 +29,7 @@ from .exceptions import ( HomeAssistantError, ) from .helpers import device_registry, entity_registry, storage +from .helpers.debounce import Debouncer from .helpers.dispatcher import async_dispatcher_send from .helpers.event import ( RANDOM_MICROSECOND_MAX, @@ -88,6 +89,8 @@ PATH_CONFIG = ".config_entries.json" SAVE_DELAY = 1 +DISCOVERY_COOLDOWN = 1 + _R = TypeVar("_R") @@ -808,6 +811,13 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): self._hass_config = hass_config self._pending_import_flows: dict[str, dict[str, asyncio.Future[None]]] = {} self._initialize_tasks: dict[str, list[asyncio.Task]] = {} + self._discovery_debouncer = Debouncer( + hass, + _LOGGER, + cooldown=DISCOVERY_COOLDOWN, + immediate=True, + function=self._async_discovery, + ) async def async_wait_import_flow_initialized(self, handler: str) -> None: """Wait till all import flows in progress are initialized.""" @@ -883,6 +893,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): for task_list in self._initialize_tasks.values(): for task in task_list: task.cancel() + await self._discovery_debouncer.async_shutdown() async def async_finish_flow( self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult @@ -979,16 +990,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): # Create notification. if source in DISCOVERY_SOURCES: - self.hass.bus.async_fire(EVENT_FLOW_DISCOVERED) - persistent_notification.async_create( - self.hass, - title="New devices discovered", - message=( - "We have discovered new devices on your network. " - "[Check it out](/config/integrations)." - ), - notification_id=DISCOVERY_NOTIFICATION_ID, - ) + await self._discovery_debouncer.async_call() elif source == SOURCE_REAUTH: persistent_notification.async_create( self.hass, @@ -1000,6 +1002,20 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): notification_id=RECONFIGURE_NOTIFICATION_ID, ) + @callback + def _async_discovery(self) -> None: + """Handle discovery.""" + self.hass.bus.async_fire(EVENT_FLOW_DISCOVERED) + persistent_notification.async_create( + self.hass, + title="New devices discovered", + message=( + "We have discovered new devices on your network. " + "[Check it out](/config/integrations)." + ), + notification_id=DISCOVERY_NOTIFICATION_ID, + ) + class ConfigEntries: """Manage the configuration entries. diff --git a/tests/common.py b/tests/common.py index 38b7cf79b75..9c8ed5bb56e 100644 --- a/tests/common.py +++ b/tests/common.py @@ -248,6 +248,9 @@ async def async_test_home_assistant(event_loop, load_registries=True): ) }, ) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, hass.config_entries._async_shutdown + ) # Load the registries entity.async_setup(hass) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index b20f2af7669..68e6fc59987 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -68,9 +68,10 @@ def mock_handlers() -> Generator[None, None, None]: @pytest.fixture -def manager(hass: HomeAssistant) -> config_entries.ConfigEntries: +async def manager(hass: HomeAssistant) -> config_entries.ConfigEntries: """Fixture of a loaded config manager.""" manager = config_entries.ConfigEntries(hass, {}) + await manager.async_initialize() hass.config_entries = manager return manager @@ -712,7 +713,9 @@ async def test_forward_entry_does_not_setup_entry_if_setup_fails( assert len(mock_setup_entry.mock_calls) == 0 -async def test_discovery_notification(hass: HomeAssistant) -> None: +async def test_discovery_notification( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we create/dismiss a notification when source is discovery.""" mock_integration(hass, MockModule("test")) mock_entity_platform(hass, "config_flow.test", None) @@ -1052,7 +1055,9 @@ async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None assert len(mock_setup_entry.mock_calls) == 1 -async def test_create_entry_options(hass: HomeAssistant) -> None: +async def test_create_entry_options( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test a config entry being created with options.""" async def mock_async_setup(hass, config): @@ -2482,7 +2487,9 @@ async def test_partial_flows_hidden( assert "config_entry_discovery" in notifications -async def test_async_setup_init_entry(hass: HomeAssistant) -> None: +async def test_async_setup_init_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test a config entry being initialized during integration setup.""" async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -2527,7 +2534,7 @@ async def test_async_setup_init_entry(hass: HomeAssistant) -> None: async def test_async_setup_init_entry_completes_before_loaded_event_fires( - hass: HomeAssistant, + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test a config entry being initialized during integration setup before the loaded event fires.""" load_events: list[Event] = [] From 26be0fab78293ee5732a4fd08fa0b5bd1aec9aba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Jun 2023 16:15:49 -1000 Subject: [PATCH 300/857] Fix debouncer not scheduling timer when wrapped function raises (#94689) * Fix debouncer not scheduling timer when callback function raises * test coro as well * preen --- homeassistant/helpers/debounce.py | 19 +++-- tests/helpers/test_debounce.py | 129 +++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 2df8965de34..4e5d152135a 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -90,11 +90,11 @@ class Debouncer(Generic[_R_co]): if self._timer_task: return - task = self.hass.async_run_hass_job(self._job) - if task: - await task - - self._schedule_timer() + try: + if task := self.hass.async_run_hass_job(self._job): + await task + finally: + self._schedule_timer() async def _handle_timer_finish(self) -> None: """Handle a finished timer.""" @@ -112,14 +112,13 @@ class Debouncer(Generic[_R_co]): return try: - task = self.hass.async_run_hass_job(self._job) - if task: + if task := self.hass.async_run_hass_job(self._job): await task except Exception: # pylint: disable=broad-except self.logger.exception("Unexpected exception from %s", self.function) - - # Schedule a new timer to prevent new runs during cooldown - self._schedule_timer() + finally: + # Schedule a new timer to prevent new runs during cooldown + self._schedule_timer() async def async_shutdown(self) -> None: """Cancel any scheduled call, and prevent new runs.""" diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 4d36679d538..f31453cfe96 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock import pytest -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import debounce from homeassistant.util.dt import utcnow @@ -69,6 +69,133 @@ async def test_immediate_works(hass: HomeAssistant) -> None: assert debouncer._job.target == debouncer.function +async def test_immediate_works_with_passed_callback_function_raises( + hass: HomeAssistant, +) -> None: + """Test immediate works with a callback function that raises.""" + calls = [] + + @callback + def _append_and_raise() -> None: + calls.append(None) + raise RuntimeError("forced_raise") + + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=_append_and_raise, + ) + + # Call when nothing happening + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + + # Call and let timer run out + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 2 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job == before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +async def test_immediate_works_with_passed_coroutine_raises( + hass: HomeAssistant, +) -> None: + """Test immediate works with a coroutine that raises.""" + calls = [] + + async def _append_and_raise() -> None: + calls.append(None) + raise RuntimeError("forced_raise") + + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=_append_and_raise, + ) + + # Call when nothing happening + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + + # Call and let timer run out + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 2 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job == before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + async def test_not_immediate_works(hass: HomeAssistant) -> None: """Test immediate works.""" calls = [] From be05a749c57a7174839e75376a345bd184e97e0b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 16 Jun 2023 04:16:12 +0200 Subject: [PATCH 301/857] Add preheating HVAC action to climate (#94677) * Add preheating HVAC action to climate * Fix MQTT tests --- homeassistant/components/climate/const.py | 1 + homeassistant/components/climate/strings.json | 1 + tests/components/mqtt/test_climate.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 9ee561b9c1b..41d4646aeae 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -96,6 +96,7 @@ class HVACAction(StrEnum): HEATING = "heating" IDLE = "idle" OFF = "off" + PREHEATING = "preheating" # These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 00696b0738c..73ac4d6fbc4 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -54,6 +54,7 @@ "name": "Current action", "state": { "off": "Off", + "preheating": "Preheating", "heating": "Heating", "cooling": "Cooling", "drying": "Drying", diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index ce27a479308..de55bf71bed 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -966,7 +966,7 @@ async def test_handle_action_received( hvac_action = state.attributes.get(ATTR_HVAC_ACTION) assert hvac_action is None # Redefine actions according to https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action - actions = ["off", "heating", "cooling", "drying", "idle", "fan"] + actions = ["off", "preheating", "heating", "cooling", "drying", "idle", "fan"] assert all(elem in actions for elem in HVACAction) for action in actions: async_fire_mqtt_message(hass, "action", action) From 72c1273d25b498149bc9bbe48cd4edf47e799b2b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 16 Jun 2023 09:08:51 +0200 Subject: [PATCH 302/857] Fix Command Line update twice issue (#94672) Command Line update twice issue --- homeassistant/components/command_line/cover.py | 2 +- homeassistant/components/command_line/switch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index ebbc8a9b30b..90bc5b7d50e 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -222,7 +222,7 @@ class CommandCover(ManualTriggerEntity, CoverEntity): if payload: self._state = int(payload) self._process_manual_data(payload) - await self.async_update_ha_state(True) + self.async_write_ha_state() async def async_update(self) -> None: """Update the entity. diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 4a33d8072d7..5beb06eea9d 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -238,7 +238,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if payload or value: self._attr_is_on = (value or payload).lower() == "true" self._process_manual_data(payload) - await self.async_update_ha_state(True) + self.async_write_ha_state() async def async_update(self) -> None: """Update the entity. From 4eefbfd4f26ada9b86b8796933e7ed5dbe526b1c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 16 Jun 2023 09:10:55 +0200 Subject: [PATCH 303/857] Add strings for YouTube reauthentication (#94655) --- homeassistant/components/youtube/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index eb89738708e..1ecc2bc4db8 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -17,6 +17,10 @@ "data": { "channels": "YouTube channels" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The YouTube integration needs to re-authenticate your account" } } }, From 843a15b1bb0850fe886d6e01f8959d7dc820f699 Mon Sep 17 00:00:00 2001 From: Alistair Tudor <3691326+atudor2@users.noreply.github.com> Date: Fri, 16 Jun 2023 09:33:02 +0200 Subject: [PATCH 304/857] Fix unit for Habitica text sensors (#94550) --- homeassistant/components/habitica/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index e085167301f..d9e0fb227c0 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -24,7 +24,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) SENSORS_TYPES = { - "name": SensorType("Name", None, "", ["profile", "name"]), + "name": SensorType("Name", None, None, ["profile", "name"]), "hp": SensorType("HP", "mdi:heart", "HP", ["stats", "hp"]), "maxHealth": SensorType("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), "mp": SensorType("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), @@ -35,7 +35,7 @@ SENSORS_TYPES = { "Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"] ), "gp": SensorType("Gold", "mdi:circle-multiple", "Gold", ["stats", "gp"]), - "class": SensorType("Class", "mdi:sword", "", ["stats", "class"]), + "class": SensorType("Class", "mdi:sword", None, ["stats", "class"]), } TASKS_TYPES = { From 934e1a160387606731ff20a6e40c4efd71293dbf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 16 Jun 2023 03:35:29 -0400 Subject: [PATCH 305/857] Fix zwave_js trigger event reattach logic (#94702) --- .../components/zwave_js/triggers/event.py | 7 +- .../zwave_js/triggers/value_updated.py | 4 +- tests/components/zwave_js/test_trigger.py | 118 +++++++++++++----- 3 files changed, 96 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 32bd3130e03..33cb59d8505 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -142,8 +142,9 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" dev_reg = dr.async_get(hass) - nodes = async_get_nodes_from_targets(hass, config, dev_reg=dev_reg) - if config[ATTR_EVENT_SOURCE] == "node" and not nodes: + if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets( + hass, config, dev_reg=dev_reg + ): raise ValueError( f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." ) @@ -215,7 +216,7 @@ async def async_attach_trigger( # Nodes list can come from different drivers and we will need to listen to # server connections for all of them. drivers: set[Driver] = set() - if not nodes: + if not (nodes := async_get_nodes_from_targets(hass, config, dev_reg=dev_reg)): entry_id = config[ATTR_CONFIG_ENTRY_ID] client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] driver = client.driver diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 4e21774c98f..52ecc0a7742 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -91,7 +91,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" dev_reg = dr.async_get(hass) - if not (nodes := async_get_nodes_from_targets(hass, config, dev_reg=dev_reg)): + if not async_get_nodes_from_targets(hass, config, dev_reg=dev_reg): raise ValueError( f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." ) @@ -174,7 +174,7 @@ async def async_attach_trigger( # Nodes list can come from different drivers and we will need to listen to # server connections for all of them. drivers: set[Driver] = set() - for node in nodes: + for node in async_get_nodes_from_targets(hass, config, dev_reg=dev_reg): driver = node.client.driver assert driver is not None # The node comes from the driver. drivers.add(driver) diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 0fb3b829d9a..eae9d6f5416 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -1112,20 +1112,21 @@ def test_get_trigger_platform_failure() -> None: async def test_server_reconnect_event( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + lock_schlage_be469_state, + integration, ) -> None: """Test that when we reconnect to server, event triggers reattach.""" trigger_type = f"{DOMAIN}.event" - node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} - ) - assert device + old_node: Node = lock_schlage_be469 event_name = "interview stage completed" - original_len = len(node._listeners.get(event_name, [])) + old_node = client.driver.controller.nodes[20] + + original_len = len(old_node._listeners.get(event_name, [])) assert await async_setup_component( hass, @@ -1147,34 +1148,65 @@ async def test_server_reconnect_event( }, ) - assert len(node._listeners.get(event_name, [])) == original_len + 1 - old_listener = node._listeners.get(event_name, [])[original_len] + assert len(old_node._listeners.get(event_name, [])) == original_len + 1 + old_listener = old_node._listeners.get(event_name, [])[original_len] + # Remove node so that we can create a new node instance and make sure the listener + # attaches + node_removed_event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": False, + "node": lock_schlage_be469_state, + }, + ) + client.driver.controller.receive_event(node_removed_event) + assert 20 not in client.driver.controller.nodes + await hass.async_block_till_done() + + # Add node like new server connection would + node_added_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": lock_schlage_be469_state, + "result": {}, + }, + ) + client.driver.controller.receive_event(node_added_event) + await hass.async_block_till_done() + + # Reload integration to trigger the dispatch signal await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() - # Make sure there is still a listener added for the trigger - assert len(node._listeners.get(event_name, [])) == original_len + 1 + # Make sure there is a listener added for the trigger to the new node + new_node = client.driver.controller.nodes[20] + assert len(new_node._listeners.get(event_name, [])) == original_len + 1 - # Make sure the old listener was removed - assert old_listener not in node._listeners.get(event_name, []) + # Make sure the old listener is no longer referenced + assert old_listener not in new_node._listeners.get(event_name, []) async def test_server_reconnect_value_updated( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + lock_schlage_be469_state, + integration, ) -> None: """Test that when we reconnect to server, value_updated triggers reattach.""" trigger_type = f"{DOMAIN}.value_updated" - node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} - ) - assert device + old_node: Node = lock_schlage_be469 event_name = "value updated" - original_len = len(node._listeners.get(event_name, [])) + old_node = client.driver.controller.nodes[20] + + original_len = len(old_node._listeners.get(event_name, [])) assert await async_setup_component( hass, @@ -1196,14 +1228,44 @@ async def test_server_reconnect_value_updated( }, ) - assert len(node._listeners.get(event_name, [])) == original_len + 1 - old_listener = node._listeners.get(event_name, [])[original_len] + assert len(old_node._listeners.get(event_name, [])) == original_len + 1 + old_listener = old_node._listeners.get(event_name, [])[original_len] + # Remove node so that we can create a new node instance and make sure the listener + # attaches + node_removed_event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": False, + "node": lock_schlage_be469_state, + }, + ) + client.driver.controller.receive_event(node_removed_event) + assert 20 not in client.driver.controller.nodes + await hass.async_block_till_done() + + # Add node like new server connection would + node_added_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": lock_schlage_be469_state, + "result": {}, + }, + ) + client.driver.controller.receive_event(node_added_event) + await hass.async_block_till_done() + + # Reload integration to trigger the dispatch signal await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() - # Make sure there is still a listener added for the trigger - assert len(node._listeners.get(event_name, [])) == original_len + 1 + # Make sure there is a listener added for the trigger to the new node + new_node = client.driver.controller.nodes[20] + assert len(new_node._listeners.get(event_name, [])) == original_len + 1 - # Make sure the old listener was removed - assert old_listener not in node._listeners.get(event_name, []) + # Make sure the old listener is no longer referenced + assert old_listener not in new_node._listeners.get(event_name, []) From ea0e1c54d6eca0ffaf65e970cff3298d1b30e1c7 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Fri, 16 Jun 2023 03:35:44 -0400 Subject: [PATCH 306/857] Handle Insteon events correctly (#94549) Make events generalized --- homeassistant/components/insteon/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index f9c22ef62a5..d7cbe676eee 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -115,8 +115,8 @@ def add_insteon_events(hass: HomeAssistant, device: Device) -> None: """Register Insteon device events.""" @callback - def async_fire_group_on_off_event( - name: str, address: Address, group: int, button: str + def async_fire_insteon_event( + name: str, address: Address, group: int, button: str | None = None ): # Firing an event when a button is pressed. if button and button[-2] == "_": @@ -146,9 +146,9 @@ def add_insteon_events(hass: HomeAssistant, device: Device) -> None: for name_or_group, event in device.events.items(): if isinstance(name_or_group, int): for _, event in device.events[name_or_group].items(): - _register_event(event, async_fire_group_on_off_event) + _register_event(event, async_fire_insteon_event) else: - _register_event(event, async_fire_group_on_off_event) + _register_event(event, async_fire_insteon_event) def register_new_device_callback(hass): From 0ac50b4933f81a8497a19767f059fdf87bf0a426 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 16 Jun 2023 10:16:31 +0200 Subject: [PATCH 307/857] Update xknxproject to 3.2.0: support ETS 4 project files (#94692) Update xknxproject to 3.2.0 --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 1f0a6d3cc5e..822b18866c7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.10.0", - "xknxproject==3.1.1", + "xknxproject==3.2.0", "knx-frontend==2023.6.9.195839" ] } diff --git a/requirements_all.txt b/requirements_all.txt index e828fc104be..b5ebecc3028 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2691,7 +2691,7 @@ xiaomi-ble==0.17.2 xknx==2.10.0 # homeassistant.components.knx -xknxproject==3.1.1 +xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24daaf1cec2..ea1564fe384 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1967,7 +1967,7 @@ xiaomi-ble==0.17.2 xknx==2.10.0 # homeassistant.components.knx -xknxproject==3.1.1 +xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz From 92bba4d7be4c6c11e50e3db43a172a614de5ef33 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 16 Jun 2023 13:57:42 +0200 Subject: [PATCH 308/857] Fix typo in binary_sensor tests (#94712) --- tests/components/binary_sensor/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index df377cd09d1..a35a6c906df 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -38,7 +38,7 @@ def test_state() -> None: assert binary_sensor.BinarySensorEntity().state == STATE_ON -class STTFlow(ConfigFlow): +class MockFlow(ConfigFlow): """Test flow.""" @@ -47,7 +47,7 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with mock_config_flow(TEST_DOMAIN, STTFlow): + with mock_config_flow(TEST_DOMAIN, MockFlow): yield From 950b25bf426d6195b46cf2d517ea4e2a26176e53 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 16 Jun 2023 14:07:25 +0200 Subject: [PATCH 309/857] Remove unnecessary assert from Entity (#94711) --- homeassistant/helpers/entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 41fe362ece3..b80e244cb8a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -380,7 +380,6 @@ class Entity(ABC): def _device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" - assert self.platform if not self.has_entity_name: return None device_class_key = self.device_class or "_" From 12129e9d21db5a808478b0795ed3ac5a023a9124 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 16 Jun 2023 07:01:40 -0700 Subject: [PATCH 310/857] Update service call return values and error handling (#94657) * Update return signature of service calls * Add timeout error handling in websocket api for service calls * Update recorder tests to remove assertion on service call * Remove timeout behavior and update callers that depend on it today * Fix tests * Add missing else * await coro directly * Fix more tests * Update the intent task to use wait instead of timeout * Remove script service call limits and limit constants * Update tests that depend on service call limits * Use wait instead of wait_for and add test * Update homeassistant/helpers/intent.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- homeassistant/components/intent/__init__.py | 24 ++--- homeassistant/core.py | 43 +-------- homeassistant/helpers/intent.py | 35 ++++++-- homeassistant/helpers/script.py | 29 ++---- tests/common.py | 2 +- tests/components/abode/test_light.py | 2 +- tests/components/abode/test_switch.py | 4 +- .../androidtv_remote/test_media_player.py | 37 ++++---- .../androidtv_remote/test_remote.py | 12 +-- tests/components/august/test_binary_sensor.py | 8 +- tests/components/august/test_button.py | 2 +- tests/components/august/test_lock.py | 24 ++--- tests/components/bluetooth/test_manager.py | 1 + .../color_extractor/test_service.py | 4 +- tests/components/command_line/test_notify.py | 10 +-- tests/components/flux_led/test_number.py | 4 + tests/components/freedompro/test_climate.py | 6 +- tests/components/freedompro/test_cover.py | 6 +- tests/components/freedompro/test_fan.py | 6 +- tests/components/freedompro/test_lock.py | 4 +- tests/components/freedompro/test_switch.py | 4 +- tests/components/fritz/test_update.py | 2 +- tests/components/fritzbox/test_button.py | 2 +- tests/components/fritzbox/test_climate.py | 14 +-- tests/components/fritzbox/test_cover.py | 8 +- tests/components/fritzbox/test_light.py | 8 +- tests/components/fritzbox/test_switch.py | 4 +- tests/components/fully_kiosk/test_button.py | 10 +-- .../generic_thermostat/test_climate.py | 1 + .../google_assistant/test_smart_home.py | 1 + .../google_assistant_sdk/test_init.py | 1 + tests/components/google_mail/test_services.py | 3 + tests/components/gree/test_climate.py | 26 +++--- tests/components/gree/test_switch.py | 12 +-- tests/components/habitica/test_init.py | 2 +- tests/components/hassio/test_update.py | 8 +- tests/components/homeassistant/test_scene.py | 24 ++--- tests/components/lifx/test_light.py | 5 +- tests/components/mobile_app/test_notify.py | 19 ++-- tests/components/nextdns/test_switch.py | 4 +- tests/components/plex/test_button.py | 2 +- tests/components/plex/test_media_search.py | 40 ++++----- tests/components/plex/test_playback.py | 20 ++--- tests/components/plex/test_services.py | 10 +-- tests/components/profiler/test_init.py | 2 + tests/components/recorder/test_init.py | 10 +-- tests/components/rest/test_switch.py | 12 +-- tests/components/rflink/test_light.py | 5 +- tests/components/samsungtv/test_init.py | 2 +- .../components/samsungtv/test_media_player.py | 90 +++++++++---------- tests/components/samsungtv/test_remote.py | 6 +- tests/components/sonos/test_plex_playback.py | 6 +- tests/components/trace/test_websocket_api.py | 2 - tests/components/webostv/test_media_player.py | 50 +++++------ tests/components/wemo/test_fan.py | 8 +- tests/components/zwave_js/test_button.py | 4 +- tests/components/zwave_js/test_climate.py | 2 +- tests/components/zwave_js/test_sensor.py | 4 +- tests/components/zwave_js/test_services.py | 6 ++ tests/components/zwave_js/test_update.py | 2 +- tests/helpers/test_intent.py | 39 ++++++++ tests/helpers/test_script.py | 15 ---- tests/test_core.py | 64 ++----------- 63 files changed, 388 insertions(+), 434 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 1f390d35370..b2c77fed3af 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -70,18 +70,22 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): if state.domain == COVER_DOMAIN: # on = open # off = close - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER - if self.service == SERVICE_TURN_ON - else SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: state.entity_id}, - context=intent_obj.context, - blocking=True, - limit=self.service_timeout, + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER + if self.service == SERVICE_TURN_ON + else SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ) + ) ) + return - elif not hass.services.has_service(state.domain, self.service): + if not hass.services.has_service(state.domain, self.service): raise intent.IntentHandleError( f"Service {self.service} does not support entity {state.entity_id}" ) diff --git a/homeassistant/core.py b/homeassistant/core.py index 1966045f569..333f9b82cd2 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -130,9 +130,6 @@ DOMAIN = "homeassistant" # How long to wait to log tasks that are blocking BLOCK_LOG_TIMEOUT = 60 -# How long we wait for the result of a service call -SERVICE_CALL_LIMIT = 10 # seconds - class ConfigSource(StrEnum): """Source of core configuration.""" @@ -1807,7 +1804,6 @@ class ServiceRegistry: service_data: dict[str, Any] | None = None, blocking: bool = False, context: Context | None = None, - limit: float | None = SERVICE_CALL_LIMIT, target: dict[str, Any] | None = None, ) -> bool | None: """Call a service. @@ -1815,9 +1811,7 @@ class ServiceRegistry: See description of async_call for details. """ return asyncio.run_coroutine_threadsafe( - self.async_call( - domain, service, service_data, blocking, context, limit, target - ), + self.async_call(domain, service, service_data, blocking, context, target), self._hass.loop, ).result() @@ -1828,16 +1822,11 @@ class ServiceRegistry: service_data: dict[str, Any] | None = None, blocking: bool = False, context: Context | None = None, - limit: float | None = SERVICE_CALL_LIMIT, target: dict[str, Any] | None = None, - ) -> bool | None: + ) -> None: """Call a service. Specify blocking=True to wait until service is executed. - Waits a maximum of limit, which may be None for no timeout. - - If blocking = True, will return boolean if service executed - successfully within limit. This method will fire an event to indicate the service has been called. @@ -1888,33 +1877,9 @@ class ServiceRegistry: coro = self._execute_service(handler, service_call) if not blocking: self._run_service_in_background(coro, service_call) - return None + return - task = self._hass.async_create_task(coro) - try: - await asyncio.wait({task}, timeout=limit) - except asyncio.CancelledError: - # Task calling us was cancelled, so cancel service call task, and wait for - # it to be cancelled, within reason, before leaving. - _LOGGER.debug("Service call was cancelled: %s", service_call) - task.cancel() - await asyncio.wait({task}, timeout=SERVICE_CALL_LIMIT) - raise - - if task.cancelled(): - # Service call task was cancelled some other way, such as during shutdown. - _LOGGER.debug("Service was cancelled: %s", service_call) - raise asyncio.CancelledError - if task.done(): - # Propagate any exceptions that might have happened during service call. - task.result() - # Service call completed successfully! - return True - # Service call task did not complete before timeout expired. - # Let it keep running in background. - self._run_service_in_background(task, service_call) - _LOGGER.debug("Service did not complete before timeout: %s", service_call) - return False + await coro def _run_service_in_background( self, diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f2b29c0040b..f27cea8dd1e 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -493,15 +493,36 @@ class ServiceIntentHandler(IntentHandler): async def async_call_service(self, intent_obj: Intent, state: State) -> None: """Call service on entity.""" hass = intent_obj.hass - await hass.services.async_call( - self.domain, - self.service, - {ATTR_ENTITY_ID: state.entity_id}, - context=intent_obj.context, - blocking=True, - limit=self.service_timeout, + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + self.domain, + self.service, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ), + f"intent_call_service_{self.domain}_{self.service}", + ) ) + async def _run_then_background(self, task: asyncio.Task) -> None: + """Run task with timeout to (hopefully) catch validation errors. + + After the timeout the task will continue to run in the background. + """ + try: + await asyncio.wait({task}, timeout=self.service_timeout) + except asyncio.TimeoutError: + pass + except asyncio.CancelledError: + # Task calling us was cancelled, so cancel service call task, and wait for + # it to be cancelled, within reason, before leaving. + _LOGGER.debug("Service call was cancelled: %s", task.get_name()) + task.cancel() + await asyncio.wait({task}, timeout=5) + raise + class IntentCategory(Enum): """Category of an intent.""" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 9ba4e7a9d88..3bbc4ddd4ea 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -64,7 +64,6 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import ( - SERVICE_CALL_LIMIT, Context, Event, HassJob, @@ -664,28 +663,16 @@ class _ScriptRun: and params[CONF_SERVICE] == "trigger" or params[CONF_DOMAIN] in ("python_script", "script") ) - # If this might start a script then disable the call timeout. - # Otherwise use the normal service call limit. - if running_script: - limit = None - else: - limit = SERVICE_CALL_LIMIT - - trace_set_result(params=params, running_script=running_script, limit=limit) - service_task = self._hass.async_create_task( - self._hass.services.async_call( - **params, - blocking=True, - context=self._context, - limit=limit, + trace_set_result(params=params, running_script=running_script) + await self._async_run_long_action( + self._hass.async_create_task( + self._hass.services.async_call( + **params, + blocking=True, + context=self._context, + ) ) ) - if limit is not None: - # There is a call limit, so just wait for it to finish. - await service_task - return - - await self._async_run_long_action(service_task) async def _async_device_step(self): """Perform the device automation specified in the action.""" diff --git a/tests/common.py b/tests/common.py index 9c8ed5bb56e..06beb27481b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1353,7 +1353,7 @@ def async_capture_events(hass: HomeAssistant, event_name: str) -> list[Event]: def capture_events(event: Event) -> None: events.append(event) - hass.bus.async_listen(event_name, capture_events) + hass.bus.async_listen(event_name, capture_events, run_immediately=True) return events diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index 5716a18f195..56a924c1226 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -63,7 +63,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: await setup_platform(hass, LIGHT_DOMAIN) with patch("jaraco.abode.devices.light.Light.switch_off") as mock_switch_off: - assert await hass.services.async_call( + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True ) await hass.async_block_till_done() diff --git a/tests/components/abode/test_switch.py b/tests/components/abode/test_switch.py index bd9a5f8d72d..a18e554aa39 100644 --- a/tests/components/abode/test_switch.py +++ b/tests/components/abode/test_switch.py @@ -49,7 +49,7 @@ async def test_switch_on(hass: HomeAssistant) -> None: await setup_platform(hass, SWITCH_DOMAIN) with patch("jaraco.abode.devices.switch.Switch.switch_on") as mock_switch_on: - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True ) await hass.async_block_till_done() @@ -62,7 +62,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: await setup_platform(hass, SWITCH_DOMAIN) with patch("jaraco.abode.devices.switch.Switch.switch_off") as mock_switch_off: - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True ) await hass.async_block_till_done() diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index c716b0f8689..efb3fa97a22 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -1,4 +1,5 @@ """Tests for the Android TV Remote remote platform.""" +import asyncio from unittest.mock import MagicMock, call from androidtvremote2 import ConnectionClosed @@ -66,7 +67,7 @@ async def test_media_player_toggles( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "turn_off", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -76,7 +77,7 @@ async def test_media_player_toggles( mock_api.send_key_command.assert_called_with("POWER", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "turn_on", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -95,7 +96,7 @@ async def test_media_player_volume( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "volume_up", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -105,7 +106,7 @@ async def test_media_player_volume( mock_api.send_key_command.assert_called_with("VOLUME_UP", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "volume_down", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -115,7 +116,7 @@ async def test_media_player_volume( mock_api.send_key_command.assert_called_with("VOLUME_DOWN", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "volume_mute", {"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": True}, @@ -125,7 +126,7 @@ async def test_media_player_volume( mock_api.send_key_command.assert_called_with("VOLUME_MUTE", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "volume_mute", {"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": False}, @@ -144,7 +145,7 @@ async def test_media_player_controls( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "media_play", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -153,7 +154,7 @@ async def test_media_player_controls( mock_api.send_key_command.assert_called_with("MEDIA_PLAY", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "media_pause", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -162,7 +163,7 @@ async def test_media_player_controls( mock_api.send_key_command.assert_called_with("MEDIA_PAUSE", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "media_play_pause", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -171,7 +172,7 @@ async def test_media_player_controls( mock_api.send_key_command.assert_called_with("MEDIA_PLAY_PAUSE", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "media_stop", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -180,7 +181,7 @@ async def test_media_player_controls( mock_api.send_key_command.assert_called_with("MEDIA_STOP", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "media_previous_track", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -189,7 +190,7 @@ async def test_media_player_controls( mock_api.send_key_command.assert_called_with("MEDIA_PREVIOUS", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "media_next_track", {"entity_id": MEDIA_PLAYER_ENTITY}, @@ -207,7 +208,7 @@ async def test_media_player_play_media( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "play_media", { @@ -234,6 +235,10 @@ async def test_media_player_play_media( }, blocking=False, ) + + # Give background task time to run + await asyncio.sleep(0) + await hass.services.async_call( "media_player", "play_media", @@ -246,7 +251,7 @@ async def test_media_player_play_media( ) assert mock_api.send_key_command.call_count == 2 - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "play_media", { @@ -259,7 +264,7 @@ async def test_media_player_play_media( mock_api.send_launch_app_command.assert_called_with("https://www.youtube.com") with pytest.raises(ValueError): - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "play_media", { @@ -271,7 +276,7 @@ async def test_media_player_play_media( ) with pytest.raises(ValueError): - assert await hass.services.async_call( + await hass.services.async_call( "media_player", "play_media", { diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index cc1d8973d49..5157361a158 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -48,7 +48,7 @@ async def test_remote_toggles( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "remote", "turn_off", {"entity_id": REMOTE_ENTITY}, @@ -58,7 +58,7 @@ async def test_remote_toggles( mock_api.send_key_command.assert_called_with("POWER", "SHORT") - assert await hass.services.async_call( + await hass.services.async_call( "remote", "turn_on", {"entity_id": REMOTE_ENTITY}, @@ -69,7 +69,7 @@ async def test_remote_toggles( mock_api.send_key_command.assert_called_with("POWER", "SHORT") assert mock_api.send_key_command.call_count == 2 - assert await hass.services.async_call( + await hass.services.async_call( "remote", "turn_on", {"entity_id": REMOTE_ENTITY, "activity": "activity1"}, @@ -89,7 +89,7 @@ async def test_remote_send_command( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "remote", "send_command", { @@ -112,7 +112,7 @@ async def test_remote_send_command_multiple( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "remote", "send_command", { @@ -136,7 +136,7 @@ async def test_remote_send_command_with_hold_secs( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.services.async_call( + await hass.services.async_call( "remote", "send_command", { diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index e3c876645aa..f66ba73cebc 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -46,9 +46,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: assert binary_sensor_online_with_doorsense_name.state == STATE_ON data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( @@ -56,9 +54,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( diff --git a/tests/components/august/test_button.py b/tests/components/august/test_button.py index 07d2bf1f4ae..eb0bce2faca 100644 --- a/tests/components/august/test_button.py +++ b/tests/components/august/test_button.py @@ -16,7 +16,7 @@ async def test_wake_lock(hass: HomeAssistant) -> None: binary_sensor_online_with_doorsense_name = hass.states.get(entity_id) assert binary_sensor_online_with_doorsense_name is not None api_instance.async_status_async.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 8d04e4d0f99..d1e60951c20 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -122,9 +122,7 @@ async def test_one_lock_operation(hass: HomeAssistant) -> None: ) data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") @@ -136,9 +134,7 @@ async def test_one_lock_operation(hass: HomeAssistant) -> None: == "online_with_doorsense Name" ) - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") @@ -176,9 +172,7 @@ async def test_one_lock_operation_pubnub_connected(hass: HomeAssistant) -> None: ) data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) await hass.async_block_till_done() pubnub.message( @@ -203,9 +197,7 @@ async def test_one_lock_operation_pubnub_connected(hass: HomeAssistant) -> None: == "online_with_doorsense Name" ) - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) await hass.async_block_till_done() pubnub.message( @@ -262,9 +254,7 @@ async def test_lock_jammed(hass: HomeAssistant) -> None: ) data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") @@ -300,9 +290,7 @@ async def test_lock_throws_exception_on_unknown_status_code( data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} with pytest.raises(ClientResponseError): - assert await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True - ) + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 67b0b594249..f637ee3a27a 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1011,6 +1011,7 @@ async def test_debug_logging( {"homeassistant.components.bluetooth": "DEBUG"}, blocking=True, ) + await hass.async_block_till_done() address = "44:44:33:11:23:41" start_time_monotonic = 50.0 diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index b1236af89fb..31218387858 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -139,9 +139,7 @@ async def _async_load_color_extractor_url(hass, service_data): assert state.state == STATE_OFF # Call the shared service, our above mock should return the base64 decoded fixture 1x1 pixel - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, service_data, blocking=True - ) + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data, blocking=True) await hass.async_block_till_done() diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index aeda981ce26..a17b1ec33e1 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -94,7 +94,7 @@ async def test_command_line_output(hass: HomeAssistant) -> None: assert hass.services.has_service(NOTIFY_DOMAIN, "test3") - assert await hass.services.async_call( + await hass.services.async_call( NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True ) with open(filename, encoding="UTF-8") as handle: @@ -122,7 +122,7 @@ async def test_error_for_none_zero_exit_code( ) -> None: """Test if an error is logged for non zero exit codes.""" - assert await hass.services.async_call( + await hass.services.async_call( NOTIFY_DOMAIN, "test4", {"message": "error"}, blocking=True ) assert "Command failed" in caplog.text @@ -149,7 +149,7 @@ async def test_timeout( caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test blocking is not forever.""" - assert await hass.services.async_call( + await hass.services.async_call( NOTIFY_DOMAIN, "test5", {"message": "error"}, blocking=True ) assert "Timeout" in caplog.text @@ -185,13 +185,13 @@ async def test_subprocess_exceptions( subprocess.SubprocessError(), ] - assert await hass.services.async_call( + await hass.services.async_call( NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True ) assert check_output.call_count == 2 assert "Timeout for command" in caplog.text - assert await hass.services.async_call( + await hass.services.async_call( NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True ) assert check_output.call_count == 4 diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index d0d71cacbe1..ff288c777df 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -305,6 +305,7 @@ async def test_addressable_light_pixel_config(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: pixels_per_segment_entity_id, ATTR_VALUE: 100}, blocking=True, ) + await hass.async_block_till_done() bulb.async_set_device_config.assert_called_with(pixels_per_segment=100) bulb.async_set_device_config.reset_mock() @@ -322,6 +323,7 @@ async def test_addressable_light_pixel_config(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: music_pixels_per_segment_entity_id, ATTR_VALUE: 100}, blocking=True, ) + await hass.async_block_till_done() bulb.async_set_device_config.assert_called_with(music_pixels_per_segment=100) bulb.async_set_device_config.reset_mock() @@ -339,6 +341,7 @@ async def test_addressable_light_pixel_config(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: segments_entity_id, ATTR_VALUE: 5}, blocking=True, ) + await hass.async_block_till_done() bulb.async_set_device_config.assert_called_with(segments=5) bulb.async_set_device_config.reset_mock() @@ -356,6 +359,7 @@ async def test_addressable_light_pixel_config(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: music_segments_entity_id, ATTR_VALUE: 5}, blocking=True, ) + await hass.async_block_till_done() bulb.async_set_device_config.assert_called_with(music_segments=5) bulb.async_set_device_config.reset_mock() diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py index a3dbc19d711..ffb67fb27ac 100644 --- a/tests/components/freedompro/test_climate.py +++ b/tests/components/freedompro/test_climate.py @@ -101,7 +101,7 @@ async def test_climate_set_off(hass: HomeAssistant, init_integration) -> None: with patch( "homeassistant.components.freedompro.climate.put_state" ) as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, @@ -156,7 +156,7 @@ async def test_climate_set_temperature(hass: HomeAssistant, init_integration) -> with patch( "homeassistant.components.freedompro.climate.put_state" ) as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { @@ -200,7 +200,7 @@ async def test_climate_set_temperature_unsupported_hvac_mode( assert entry assert entry.unique_id == uid - assert await hass.services.async_call( + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { diff --git a/tests/components/freedompro/test_cover.py b/tests/components/freedompro/test_cover.py index e4c813e7207..0943f6db13c 100644 --- a/tests/components/freedompro/test_cover.py +++ b/tests/components/freedompro/test_cover.py @@ -116,7 +116,7 @@ async def test_cover_set_position( assert entry.unique_id == uid with patch("homeassistant.components.freedompro.cover.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 33}, @@ -181,7 +181,7 @@ async def test_cover_close( assert entry.unique_id == uid with patch("homeassistant.components.freedompro.cover.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: [entity_id]}, @@ -234,7 +234,7 @@ async def test_cover_open( assert entry.unique_id == uid with patch("homeassistant.components.freedompro.cover.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: [entity_id]}, diff --git a/tests/components/freedompro/test_fan.py b/tests/components/freedompro/test_fan.py index 1cfda1359d8..db72021a900 100644 --- a/tests/components/freedompro/test_fan.py +++ b/tests/components/freedompro/test_fan.py @@ -96,7 +96,7 @@ async def test_fan_set_off(hass: HomeAssistant, init_integration) -> None: assert entry.unique_id == uid with patch("homeassistant.components.freedompro.fan.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: [entity_id]}, @@ -137,7 +137,7 @@ async def test_fan_set_on(hass: HomeAssistant, init_integration) -> None: assert entry.unique_id == uid with patch("homeassistant.components.freedompro.fan.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, @@ -177,7 +177,7 @@ async def test_fan_set_percent(hass: HomeAssistant, init_integration) -> None: assert entry.unique_id == uid with patch("homeassistant.components.freedompro.fan.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, {ATTR_ENTITY_ID: [entity_id], ATTR_PERCENTAGE: 40}, diff --git a/tests/components/freedompro/test_lock.py b/tests/components/freedompro/test_lock.py index b90d7558114..eb94b36ed3f 100644 --- a/tests/components/freedompro/test_lock.py +++ b/tests/components/freedompro/test_lock.py @@ -90,7 +90,7 @@ async def test_lock_set_unlock(hass: HomeAssistant, init_integration) -> None: assert entry.unique_id == uid with patch("homeassistant.components.freedompro.lock.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: [entity_id]}, @@ -127,7 +127,7 @@ async def test_lock_set_lock(hass: HomeAssistant, init_integration) -> None: assert entry.unique_id == uid with patch("homeassistant.components.freedompro.lock.put_state") as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: [entity_id]}, diff --git a/tests/components/freedompro/test_switch.py b/tests/components/freedompro/test_switch.py index f041bebfc30..b9ffd5895c1 100644 --- a/tests/components/freedompro/test_switch.py +++ b/tests/components/freedompro/test_switch.py @@ -80,7 +80,7 @@ async def test_switch_set_off(hass: HomeAssistant, init_integration) -> None: with patch( "homeassistant.components.freedompro.switch.put_state" ) as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: [entity_id]}, @@ -119,7 +119,7 @@ async def test_switch_set_on(hass: HomeAssistant, init_integration) -> None: with patch( "homeassistant.components.freedompro.switch.put_state" ) as mock_put_state: - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index b139a86b82d..915a6bb6fd0 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -113,7 +113,7 @@ async def test_available_update_can_be_installed( assert update is not None assert update.state == "on" - assert await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.mock_title_fritz_os"}, diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index c6e73b32edc..9c53c895f5d 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -37,7 +37,7 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert fritz().apply_template.call_count == 1 diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index edfaf73e3b8..d49b5710a12 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -255,7 +255,7 @@ async def test_set_temperature_temperature(hass: HomeAssistant, fritz: Mock) -> hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 123}, @@ -271,7 +271,7 @@ async def test_set_temperature_mode_off(hass: HomeAssistant, fritz: Mock) -> Non hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { @@ -291,7 +291,7 @@ async def test_set_temperature_mode_heat(hass: HomeAssistant, fritz: Mock) -> No hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { @@ -311,7 +311,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, @@ -327,7 +327,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, @@ -343,7 +343,7 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, @@ -359,7 +359,7 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index d500efb9032..af725ce93da 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -38,7 +38,7 @@ async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_open.call_count == 1 @@ -51,7 +51,7 @@ async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_close.call_count == 1 @@ -64,7 +64,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 50}, @@ -80,7 +80,7 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_stop.call_count == 1 diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 074dd902fa1..0192ea7bb00 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -135,7 +135,7 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP_KELVIN: 3000}, @@ -158,7 +158,7 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, @@ -192,7 +192,7 @@ async def test_turn_on_color_unsupported_api_method( assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, @@ -215,7 +215,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_state_off.call_count == 1 diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 17c51af9d98..4ac36a13284 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -107,7 +107,7 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_switch_state_on.call_count == 1 @@ -120,7 +120,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_switch_state_off.call_count == 1 diff --git a/tests/components/fully_kiosk/test_button.py b/tests/components/fully_kiosk/test_button.py index 1c839e57dfd..7e780e3fec3 100644 --- a/tests/components/fully_kiosk/test_button.py +++ b/tests/components/fully_kiosk/test_button.py @@ -22,7 +22,7 @@ async def test_buttons( entry = entity_registry.async_get("button.amazon_fire_restart_browser") assert entry assert entry.unique_id == "abcdef-123456-restartApp" - assert await hass.services.async_call( + await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, {ATTR_ENTITY_ID: "button.amazon_fire_restart_browser"}, @@ -33,7 +33,7 @@ async def test_buttons( entry = entity_registry.async_get("button.amazon_fire_reboot_device") assert entry assert entry.unique_id == "abcdef-123456-rebootDevice" - assert await hass.services.async_call( + await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, {ATTR_ENTITY_ID: "button.amazon_fire_reboot_device"}, @@ -44,7 +44,7 @@ async def test_buttons( entry = entity_registry.async_get("button.amazon_fire_bring_to_foreground") assert entry assert entry.unique_id == "abcdef-123456-toForeground" - assert await hass.services.async_call( + await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, {ATTR_ENTITY_ID: "button.amazon_fire_bring_to_foreground"}, @@ -55,7 +55,7 @@ async def test_buttons( entry = entity_registry.async_get("button.amazon_fire_send_to_background") assert entry assert entry.unique_id == "abcdef-123456-toBackground" - assert await hass.services.async_call( + await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, {ATTR_ENTITY_ID: "button.amazon_fire_send_to_background"}, @@ -66,7 +66,7 @@ async def test_buttons( entry = entity_registry.async_get("button.amazon_fire_load_start_url") assert entry assert entry.unique_id == "abcdef-123456-loadStartUrl" - assert await hass.services.async_call( + await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, {ATTR_ENTITY_ID: "button.amazon_fire_load_start_url"}, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 99720ef8527..4eb2e3ce711 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -131,6 +131,7 @@ async def test_heater_input_boolean(hass: HomeAssistant, setup_comp_1) -> None: _setup_sensor(hass, 18) await hass.async_block_till_done() await common.async_set_temperature(hass, 23) + await hass.async_block_till_done() assert hass.states.get(heater_switch).state == STATE_ON diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 4fab32ac932..31f493db20c 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -744,6 +744,7 @@ async def test_execute_times_out( turn_on_wait.set() await hass.async_block_till_done() + await hass.async_block_till_done() # The remaining two calls should now have executed assert call_service_mock.call_count == 4 expected_calls.extend( diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 884107b7eb9..4cfdd42bcdd 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -230,6 +230,7 @@ async def test_send_text_command_expired_token_refresh_failure( {"command": "turn on tv"}, blocking=True, ) + await hass.async_block_till_done() assert any(entry.async_get_active_flows(hass, {"reauth"})) == requires_reauth diff --git a/tests/components/google_mail/test_services.py b/tests/components/google_mail/test_services.py index 2523e7a9591..b9fefa805e6 100644 --- a/tests/components/google_mail/test_services.py +++ b/tests/components/google_mail/test_services.py @@ -81,6 +81,9 @@ async def test_reauth_trigger( blocking=True, ) + await hass.async_block_till_done() + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index afed01c1a08..fe64b0ee7ef 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -326,7 +326,7 @@ async def test_send_command_device_timeout( device().push_state_update.side_effect = DeviceTimeoutError # Send failure should not raise exceptions or change device state - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, @@ -342,7 +342,7 @@ async def test_send_power_on(hass: HomeAssistant, discovery, device, mock_now) - """Test for sending power on command to the device.""" await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, @@ -362,7 +362,7 @@ async def test_send_power_off_device_timeout( await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, @@ -393,7 +393,7 @@ async def test_send_target_temperature( # Make sure we're trying to test something that isn't the default assert fake_device.current_temperature != temperature - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: temperature}, @@ -428,7 +428,7 @@ async def test_send_target_temperature_device_timeout( await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: temperature}, @@ -475,7 +475,7 @@ async def test_send_preset_mode( """Test for sending preset mode command to the device.""" await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset}, @@ -517,7 +517,7 @@ async def test_send_preset_mode_device_timeout( await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset}, @@ -565,7 +565,7 @@ async def test_send_hvac_mode( """Test for sending hvac mode command to the device.""" await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, @@ -589,7 +589,7 @@ async def test_send_hvac_mode_device_timeout( await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, @@ -636,7 +636,7 @@ async def test_send_fan_mode( """Test for sending fan mode command to the device.""" await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: fan_mode}, @@ -679,7 +679,7 @@ async def test_send_fan_mode_device_timeout( await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: fan_mode}, @@ -717,7 +717,7 @@ async def test_send_swing_mode( """Test for sending swing mode command to the device.""" await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: swing_mode}, @@ -759,7 +759,7 @@ async def test_send_swing_mode_device_timeout( await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: swing_mode}, diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index aee9c985e8c..d8160d99040 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -66,7 +66,7 @@ async def test_send_switch_on( """Test for sending power on command to the device.""" await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity}, @@ -96,7 +96,7 @@ async def test_send_switch_on_device_timeout( await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity}, @@ -124,7 +124,7 @@ async def test_send_switch_off( """Test for sending power on command to the device.""" await async_setup_gree(hass) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity}, @@ -153,7 +153,7 @@ async def test_send_switch_toggle( await async_setup_gree(hass) # Turn the service on first - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity}, @@ -165,7 +165,7 @@ async def test_send_switch_toggle( assert state.state == STATE_ON # Toggle it off - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity}, @@ -177,7 +177,7 @@ async def test_send_switch_toggle( assert state.state == STATE_OFF # Toggle is back on - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity}, diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 69fe2e6fc39..91fa6f90e9f 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -123,7 +123,7 @@ async def test_service_call( ATTR_PATH: ["tasks", "user", "post"], ATTR_ARGS: TEST_API_CALL_ARGS, } - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_API_CALL, TEST_SERVICE_DATA, blocking=True ) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 547e32dfddb..3f12874ef52 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -247,7 +247,7 @@ async def test_update_addon( json={"result": "ok", "data": {}}, ) - assert await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update"}, @@ -276,7 +276,7 @@ async def test_update_os( json={"result": "ok", "data": {}}, ) - assert await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_operating_system_update"}, @@ -305,7 +305,7 @@ async def test_update_core( json={"result": "ok", "data": {}}, ) - assert await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_os_update"}, @@ -334,7 +334,7 @@ async def test_update_supervisor( json={"result": "ok", "data": {}}, ) - assert await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_supervisor_update"}, diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index 4e2e9641128..085ed4f0641 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -49,13 +49,13 @@ async def test_apply_service(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) await hass.async_block_till_done() - assert await hass.services.async_call( + await hass.services.async_call( "scene", "apply", {"entities": {"light.bed_light": "off"}}, blocking=True ) assert hass.states.get("light.bed_light").state == "off" - assert await hass.services.async_call( + await hass.services.async_call( "scene", "apply", {"entities": {"light.bed_light": {"state": "on", "brightness": 50}}}, @@ -67,7 +67,7 @@ async def test_apply_service(hass: HomeAssistant) -> None: assert state.attributes["brightness"] == 50 turn_on_calls = async_mock_service(hass, "light", "turn_on") - assert await hass.services.async_call( + await hass.services.async_call( "scene", "apply", { @@ -98,7 +98,7 @@ async def test_create_service( assert hass.states.get("scene.hallo") is None assert hass.states.get("scene.hallo_2") is not None - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", {"scene_id": "hallo", "entities": {}, "snapshot_entities": []}, @@ -108,7 +108,7 @@ async def test_create_service( assert "Empty scenes are not allowed" in caplog.text assert hass.states.get("scene.hallo") is None - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", { @@ -126,7 +126,7 @@ async def test_create_service( assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.bed_light"] - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", { @@ -144,7 +144,7 @@ async def test_create_service( assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.kitchen_light"] - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", { @@ -173,7 +173,7 @@ async def test_snapshot_service( hass.states.async_set("light.my_light", "on", {"hs_color": (345, 75)}) assert hass.states.get("scene.hallo") is None - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", {"scene_id": "hallo", "snapshot_entities": ["light.my_light"]}, @@ -186,7 +186,7 @@ async def test_snapshot_service( hass.states.async_set("light.my_light", "off", {"hs_color": (123, 45)}) turn_on_calls = async_mock_service(hass, "light", "turn_on") - assert await hass.services.async_call( + await hass.services.async_call( "scene", "turn_on", {"entity_id": "scene.hallo"}, blocking=True ) await hass.async_block_till_done() @@ -194,7 +194,7 @@ async def test_snapshot_service( assert turn_on_calls[0].data.get("entity_id") == "light.my_light" assert turn_on_calls[0].data.get("hs_color") == (345, 75) - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", {"scene_id": "hallo_2", "snapshot_entities": ["light.not_existent"]}, @@ -207,7 +207,7 @@ async def test_snapshot_service( in caplog.text ) - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", { @@ -230,7 +230,7 @@ async def test_ensure_no_intersection(hass: HomeAssistant) -> None: await hass.async_block_till_done() with pytest.raises(vol.MultipleInvalid) as ex: - assert await hass.services.async_call( + await hass.services.async_call( "scene", "create", { diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 67bf2754d11..f64af98c9b5 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -683,7 +683,7 @@ async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_flame"}, blocking=True, ) - + await hass.async_block_till_done() assert len(bulb.set_power.calls) == 1 assert len(bulb.set_tile_effect.calls) == 1 @@ -824,7 +824,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_move"}, blocking=True, ) - + await hass.async_block_till_done() assert len(bulb.set_power.calls) == 1 assert len(bulb.set_multizone_effect.calls) == 1 @@ -881,6 +881,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_stop"}, blocking=True, ) + await hass.async_block_till_done() assert len(bulb.set_power.calls) == 0 assert len(bulb.set_multizone_effect.calls) == 1 call_dict = bulb.set_multizone_effect.calls[0][1] diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 5ce010eee40..23e2530c70a 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -143,7 +143,7 @@ async def test_notify_works( ) -> None: """Test notify works.""" assert hass.services.has_service("notify", "mobile_app_test") is True - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_test", {"message": "Hello world"}, blocking=True ) @@ -191,7 +191,7 @@ async def test_notify_ws_works( sub_result = await client.receive_json() assert sub_result["success"] - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_test", {"message": "Hello world"}, blocking=True ) @@ -212,7 +212,7 @@ async def test_notify_ws_works( sub_result = await client.receive_json() assert sub_result["success"] - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_test", {"message": "Hello world 2"}, blocking=True ) @@ -271,7 +271,7 @@ async def test_notify_ws_confirming_works( assert sub_result["success"] # Sent a message that will be delivered locally - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_test", {"message": "Hello world"}, blocking=True ) @@ -359,23 +359,24 @@ async def test_notify_ws_not_confirming( sub_result = await client.receive_json() assert sub_result["success"] - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_test", {"message": "Hello world 1"}, blocking=True ) with patch( "homeassistant.components.mobile_app.push_notification.PUSH_CONFIRM_TIMEOUT", 0 ): - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_test", {"message": "Hello world 2"}, blocking=True ) await hass.async_block_till_done() + await hass.async_block_till_done() # When we fail, all unconfirmed ones and failed one are sent via cloud assert len(aioclient_mock.mock_calls) == 2 # All future ones also go via cloud - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_test", {"message": "Hello world 3"}, blocking=True ) @@ -389,7 +390,7 @@ async def test_local_push_only( ) -> None: """Test a local only push registration.""" with pytest.raises(HomeAssistantError) as e_info: - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_websocket_push_name", {"message": "Not connected"}, @@ -411,7 +412,7 @@ async def test_local_push_only( sub_result = await client.receive_json() assert sub_result["success"] - assert await hass.services.async_call( + await hass.services.async_call( "notify", "mobile_app_websocket_push_name", {"message": "Hello world 1"}, diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index ed0ea4e8620..33a3f804902 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -637,7 +637,7 @@ async def test_switch_on(hass: HomeAssistant) -> None: with patch( "homeassistant.components.nextdns.NextDns.set_setting", return_value=True ) as mock_switch_on: - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, @@ -663,7 +663,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: with patch( "homeassistant.components.nextdns.NextDns.set_setting", return_value=True ) as mock_switch_on: - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.fake_profile_web3"}, diff --git a/tests/components/plex/test_button.py b/tests/components/plex/test_button.py index 4ac79ed0b7e..e8e734143b3 100644 --- a/tests/components/plex/test_button.py +++ b/tests/components/plex/test_button.py @@ -26,7 +26,7 @@ async def test_scan_clients_button_schedule( dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), ) - assert await hass.services.async_call( + await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, { diff --git a/tests/components/plex/test_media_search.py b/tests/components/plex/test_media_search.py index 1a3e0279605..0cc94134f1c 100644 --- a/tests/components/plex/test_media_search.py +++ b/tests/components/plex/test_media_search.py @@ -30,7 +30,7 @@ async def test_media_lookups( requests_mock.post("/playqueues", text=playqueue_created) requests_mock.get("/player/playback/playMedia", status_code=200) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -43,7 +43,7 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo, patch( "plexapi.server.PlexServer.fetchItem", side_effect=NotFound ): - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -58,7 +58,7 @@ async def test_media_lookups( # TV show searches with pytest.raises(MediaNotFound) as excinfo: payload = '{"library_name": "Not a Library", "show_name": "TV Show"}' - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -71,7 +71,7 @@ async def test_media_lookups( assert "Library 'Not a Library' not found in" in str(excinfo.value) with patch("plexapi.library.LibrarySection.search") as search: - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -83,7 +83,7 @@ async def test_media_lookups( ) search.assert_called_with(**{"show.title": "TV Show", "libtype": "show"}) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -97,7 +97,7 @@ async def test_media_lookups( **{"episode.title": "An Episode", "libtype": "episode"} ) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -111,7 +111,7 @@ async def test_media_lookups( **{"show.title": "TV Show", "season.index": 1, "libtype": "season"} ) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -130,7 +130,7 @@ async def test_media_lookups( } ) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -142,7 +142,7 @@ async def test_media_lookups( ) search.assert_called_with(**{"artist.title": "Artist", "libtype": "artist"}) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -154,7 +154,7 @@ async def test_media_lookups( ) search.assert_called_with(**{"album.title": "Album", "libtype": "album"}) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -168,7 +168,7 @@ async def test_media_lookups( **{"artist.title": "Artist", "track.title": "Track 3", "libtype": "track"} ) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -182,7 +182,7 @@ async def test_media_lookups( **{"artist.title": "Artist", "album.title": "Album", "libtype": "album"} ) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -201,7 +201,7 @@ async def test_media_lookups( } ) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -221,7 +221,7 @@ async def test_media_lookups( ) # Movie searches - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -233,7 +233,7 @@ async def test_media_lookups( ) search.assert_called_with(**{"movie.title": "Movie 1", "libtype": None}) - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -247,7 +247,7 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo: payload = '{"title": "Movie 1"}' - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -262,7 +262,7 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo: payload = '{"library_name": "Movies", "title": "Not a Movie"}' with patch("plexapi.library.LibrarySection.search", side_effect=BadRequest): - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -275,7 +275,7 @@ async def test_media_lookups( assert "Problem in query" in str(excinfo.value) # Playlist searches - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -288,7 +288,7 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo: payload = '{"playlist_name": "Not a Playlist"}' - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -302,7 +302,7 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo: payload = "{}" - assert await hass.services.async_call( + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index 58dd49a0542..c9dba4e4aca 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -67,7 +67,7 @@ async def test_media_player_playback( with patch( "plexapi.library.LibrarySection.search", return_value=None ), pytest.raises(HomeAssistantError) as excinfo: - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -87,7 +87,7 @@ async def test_media_player_playback( # Test movie success movies = [movie1] with patch("plexapi.library.LibrarySection.search", return_value=movies): - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -102,7 +102,7 @@ async def test_media_player_playback( # Test movie success with resume playmedia_mock.reset() with patch("plexapi.library.LibrarySection.search", return_value=movies): - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -117,7 +117,7 @@ async def test_media_player_playback( # Test movie success with media browser URL playmedia_mock.reset() - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -132,7 +132,7 @@ async def test_media_player_playback( # Test movie success with media browser URL and resuming playmedia_mock.reset() - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -148,7 +148,7 @@ async def test_media_player_playback( # Test movie success with legacy media browser URL playmedia_mock.reset() - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -164,7 +164,7 @@ async def test_media_player_playback( playmedia_mock.reset() movies = [movie1, movie2] with patch("plexapi.library.LibrarySection.search", return_value=movies): - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -182,7 +182,7 @@ async def test_media_player_playback( with pytest.raises(HomeAssistantError) as excinfo: payload = '{"library_name": "Movies", "title": "Movie" }' with patch("plexapi.library.LibrarySection.search", return_value=movies): - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -200,7 +200,7 @@ async def test_media_player_playback( with patch("plexapi.library.LibrarySection.search", return_value=movies), patch( "homeassistant.components.plex.server.PlexServer.create_playqueue" ) as mock_create_playqueue: - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -216,7 +216,7 @@ async def test_media_player_playback( # Test radio station playmedia_mock.reset() radio_id = "/library/sections/3/stations/1" - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index 12a36057056..a74b3e91460 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -45,7 +45,7 @@ async def test_refresh_library( # Test with non-existent server with pytest.raises(HomeAssistantError): - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_REFRESH_LIBRARY, {"server_name": "Not a Server", "library_name": "Movies"}, @@ -54,7 +54,7 @@ async def test_refresh_library( assert not refresh.called # Test with non-existent library - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_REFRESH_LIBRARY, {"library_name": "Not a Library"}, @@ -63,7 +63,7 @@ async def test_refresh_library( assert not refresh.called # Test with valid library - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_REFRESH_LIBRARY, {"library_name": "Movies"}, @@ -96,7 +96,7 @@ async def test_refresh_library( # Test multiple servers available but none specified with pytest.raises(HomeAssistantError) as excinfo: - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_REFRESH_LIBRARY, {"library_name": "Movies"}, @@ -108,7 +108,7 @@ async def test_refresh_library( async def test_scan_clients(hass: HomeAssistant, mock_plex_server) -> None: """Test scan_for_clients service call.""" - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SCAN_CLIENTS, blocking=True, diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 6f61b45518b..7c2aeb2a29a 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -224,6 +224,8 @@ async def test_log_scheduled( assert hass.services.has_service(DOMAIN, SERVICE_LOG_EVENT_LOOP_SCHEDULED) + hass.loop.call_later(0.1, lambda: None) + await hass.services.async_call( DOMAIN, SERVICE_LOG_EVENT_LOOP_SCHEDULED, {}, blocking=True ) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 84dbb9a2816..0bb315365b5 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1426,7 +1426,7 @@ def test_service_disable_events_not_recording( """Test that events are not recorded when recorder is disabled using service.""" hass = hass_recorder() - assert hass.services.call( + hass.services.call( DOMAIN, SERVICE_DISABLE, {}, @@ -1460,7 +1460,7 @@ def test_service_disable_events_not_recording( ) assert len(db_events) == 0 - assert hass.services.call( + hass.services.call( DOMAIN, SERVICE_ENABLE, {}, @@ -1510,7 +1510,7 @@ def test_service_disable_states_not_recording( """Test that state changes are not recorded when recorder is disabled using service.""" hass = hass_recorder() - assert hass.services.call( + hass.services.call( DOMAIN, SERVICE_DISABLE, {}, @@ -1523,7 +1523,7 @@ def test_service_disable_states_not_recording( with session_scope(hass=hass, read_only=True) as session: assert len(list(session.query(States))) == 0 - assert hass.services.call( + hass.services.call( DOMAIN, SERVICE_ENABLE, {}, @@ -1563,7 +1563,7 @@ def test_service_disable_run_information_recorded(tmp_path: Path) -> None: assert db_run_info[0].start is not None assert db_run_info[0].end is None - assert hass.services.call( + hass.services.call( DOMAIN, SERVICE_DISABLE, {}, diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 5584fce5e3a..a6895183d4e 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -262,7 +262,7 @@ async def test_turn_on_success(hass: HomeAssistant) -> None: route = respx.post(RESOURCE) % HTTPStatus.OK respx.get(RESOURCE).mock(side_effect=httpx.RequestError) - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.foo"}, @@ -282,7 +282,7 @@ async def test_turn_on_status_not_ok(hass: HomeAssistant) -> None: await _async_setup_test_switch(hass) route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.foo"}, @@ -302,7 +302,7 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: await _async_setup_test_switch(hass) respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.foo"}, @@ -320,7 +320,7 @@ async def test_turn_off_success(hass: HomeAssistant) -> None: route = respx.post(RESOURCE) % HTTPStatus.OK respx.get(RESOURCE).mock(side_effect=httpx.RequestError) - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.foo"}, @@ -341,7 +341,7 @@ async def test_turn_off_status_not_ok(hass: HomeAssistant) -> None: await _async_setup_test_switch(hass) route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.foo"}, @@ -362,7 +362,7 @@ async def test_turn_off_timeout(hass: HomeAssistant) -> None: await _async_setup_test_switch(hass) respx.post(RESOURCE).mock(side_effect=asyncio.TimeoutError()) - assert await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.foo"}, diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py index ddbe4783323..27dca72fd96 100644 --- a/tests/components/rflink/test_light.py +++ b/tests/components/rflink/test_light.py @@ -4,6 +4,8 @@ Test setup of RFLink lights component/platform. State tracking and control of RFLink switch devices. """ +import asyncio + from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.rflink import EVENT_BUTTON_PRESSED from homeassistant.const import ( @@ -283,7 +285,8 @@ async def test_signal_repetitions_cancelling(hass: HomeAssistant, monkeypatch) - await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: f"{DOMAIN}.test"} ) - + # Get background service time to start running + await asyncio.sleep(0) await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, blocking=True ) diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index e15d84913be..7491f3b76b7 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -65,7 +65,7 @@ async def test_setup(hass: HomeAssistant) -> None: ) # test host and port - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 1be9982d6c4..3d3077a1c6a 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -524,7 +524,7 @@ async def test_connection_closed_during_update_can_recover( async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: """Test for send key.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -540,7 +540,7 @@ async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=BrokenPipeError("Boom")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -555,7 +555,7 @@ async def test_send_key_connection_closed_retry_succeed( remote.control = Mock( side_effect=[exceptions.ConnectionClosed("Boom"), DEFAULT_MOCK, DEFAULT_MOCK] ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -574,7 +574,7 @@ async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -586,7 +586,7 @@ async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=WebSocketException("Boom")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -600,7 +600,7 @@ async def test_send_key_websocketexception_encrypted( """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) remoteencws.send_commands = Mock(side_effect=WebSocketException("Boom")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -612,7 +612,7 @@ async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=OSError("Boom")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -626,7 +626,7 @@ async def test_send_key_os_error_ws_encrypted( """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) remoteencws.send_commands = Mock(side_effect=OSError("Boom")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -637,7 +637,7 @@ async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=OSError("Boom")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -656,12 +656,12 @@ async def test_name(hass: HomeAssistant) -> None: async def test_state(hass: HomeAssistant) -> None: """Test for state property.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) @@ -711,7 +711,7 @@ async def test_turn_off_websocket( remotews.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called @@ -723,11 +723,11 @@ async def test_turn_off_websocket( # commands not sent : power off in progress remotews.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "TV is powering off, not sending keys: ['KEY_VOLUP']" in caplog.text - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "Deezer"}, @@ -750,7 +750,7 @@ async def test_turn_off_websocket_frame( remotews.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called @@ -778,7 +778,7 @@ async def test_turn_off_encrypted_websocket( remoteencws.send_commands.reset_mock() caplog.clear() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called @@ -793,7 +793,7 @@ async def test_turn_off_encrypted_websocket( # commands not sent : power off in progress remoteencws.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "TV is powering off, not sending keys: ['KEY_VOLUP']" in caplog.text @@ -819,7 +819,7 @@ async def test_turn_off_encrypted_websocket_key_type( remoteencws.send_commands.reset_mock() caplog.clear() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called @@ -834,7 +834,7 @@ async def test_turn_off_encrypted_websocket_key_type( async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: """Test for turn_off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called @@ -849,7 +849,7 @@ async def test_turn_off_os_error( caplog.set_level(logging.DEBUG) await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.close = Mock(side_effect=OSError("BOOM")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "Could not establish connection" in caplog.text @@ -863,7 +863,7 @@ async def test_turn_off_ws_os_error( caplog.set_level(logging.DEBUG) await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.close = Mock(side_effect=OSError("BOOM")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "Error closing connection" in caplog.text @@ -877,7 +877,7 @@ async def test_turn_off_encryptedws_os_error( caplog.set_level(logging.DEBUG) await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) remoteencws.close = Mock(side_effect=OSError("BOOM")) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "Error closing connection" in caplog.text @@ -886,7 +886,7 @@ async def test_turn_off_encryptedws_os_error( async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_up.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -899,7 +899,7 @@ async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_down.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -912,7 +912,7 @@ async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: """Test for mute_volume.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_MUTE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True}, @@ -928,7 +928,7 @@ async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: """Test for media_play.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -937,7 +937,7 @@ async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: assert remote.close.call_count == 1 assert remote.close.call_args_list == [call()] - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -950,7 +950,7 @@ async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: """Test for media_pause.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -959,7 +959,7 @@ async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: assert remote.close.call_count == 1 assert remote.close.call_args_list == [call()] - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -972,7 +972,7 @@ async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_next_track.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -985,7 +985,7 @@ async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_previous_track.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key and update called @@ -1009,7 +1009,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: with patch( "homeassistant.components.samsungtv.media_player.send_magic_packet" ) as mock_send_magic_packet: - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) await hass.async_block_till_done() @@ -1031,7 +1031,7 @@ async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: """Test for play_media.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch("homeassistant.components.samsungtv.bridge.asyncio.sleep") as sleep: - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, { @@ -1060,7 +1060,7 @@ async def test_play_media_invalid_type(hass: HomeAssistant) -> None: url = "https://example.com" await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, { @@ -1082,7 +1082,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: url = "https://example.com" await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, { @@ -1103,7 +1103,7 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, { @@ -1122,7 +1122,7 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: """Test for select_source.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "HDMI"}, @@ -1140,7 +1140,7 @@ async def test_select_source_invalid_source(hass: HomeAssistant) -> None: with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "INVALID"}, @@ -1158,7 +1158,7 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, { @@ -1182,7 +1182,7 @@ async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "Deezer"}, @@ -1207,7 +1207,7 @@ async def test_websocket_unsupported_remote_control( remotews.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) remotews.raise_mock_ws_event_callback( @@ -1256,7 +1256,7 @@ async def test_volume_control_upnp( assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False # Upnp action succeeds - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, @@ -1270,7 +1270,7 @@ async def test_volume_control_upnp( dmr_device.async_set_volume_level.side_effect = UpnpActionResponseError( status=500, error_code=501, error_desc="Action Failed" ) - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, @@ -1289,7 +1289,7 @@ async def test_upnp_not_available( assert "Unable to create Upnp DMR device" in caplog.text # Upnp action fails - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, @@ -1307,7 +1307,7 @@ async def test_upnp_missing_service( assert "Unable to create Upnp DMR device" in caplog.text # Upnp action fails - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index d6c43060b85..88cf47bf148 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -46,7 +46,7 @@ async def test_main_services( remoteencws.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, @@ -64,7 +64,7 @@ async def test_main_services( # commands not sent : power off in progress remoteencws.send_commands.reset_mock() - assert await hass.services.async_call( + await hass.services.async_call( REMOTE_DOMAIN, SERVICE_SEND_COMMAND, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["dash"]}, @@ -79,7 +79,7 @@ async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> N """Test the send command.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - assert await hass.services.async_call( + await hass.services.async_call( REMOTE_DOMAIN, SERVICE_SEND_COMMAND, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["dash"]}, diff --git a/tests/components/sonos/test_plex_playback.py b/tests/components/sonos/test_plex_playback.py index a612359ac78..99269ca10ef 100644 --- a/tests/components/sonos/test_plex_playback.py +++ b/tests/components/sonos/test_plex_playback.py @@ -34,7 +34,7 @@ async def test_plex_play_media(hass: HomeAssistant, async_autosetup_sonos) -> No "homeassistant.components.sonos.media_player.SonosMediaPlayerEntity.set_shuffle" ) as mock_shuffle: # Test successful Plex service call - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -59,7 +59,7 @@ async def test_plex_play_media(hass: HomeAssistant, async_autosetup_sonos) -> No '"album_name": "Album", "shuffle": 1}' ) - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { @@ -107,7 +107,7 @@ async def test_plex_play_media(hass: HomeAssistant, async_autosetup_sonos) -> No "homeassistant.components.plex.services.get_plex_server", return_value=mock_plex_server, ): - assert await hass.services.async_call( + await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, { diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index 5dbd78268e2..8b3bca86565 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -156,7 +156,6 @@ async def test_get_trace( } sun_action = { - "limit": 10, "params": { "domain": "test", "service": "automation", @@ -1594,7 +1593,6 @@ async def test_trace_blueprint_automation( }, } sun_action = { - "limit": 10, "params": { "domain": "test", "service": "automation", diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index b400951f61f..fec1bf7a04a 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -90,7 +90,7 @@ async def test_services_with_parameters( await setup_webostv(hass) data = {ATTR_ENTITY_ID: ENTITY_ID, **attr_data} - assert await hass.services.async_call(MP_DOMAIN, service, data, True) + await hass.services.async_call(MP_DOMAIN, service, data, True) getattr(client, client_call[0]).assert_called_once_with(client_call[1]) @@ -111,7 +111,7 @@ async def test_services(hass: HomeAssistant, client, service, client_call) -> No await setup_webostv(hass) data = {ATTR_ENTITY_ID: ENTITY_ID} - assert await hass.services.async_call(MP_DOMAIN, service, data, True) + await hass.services.async_call(MP_DOMAIN, service, data, True) getattr(client, client_call).assert_called_once() @@ -123,17 +123,13 @@ async def test_media_play_pause(hass: HomeAssistant, client) -> None: data = {ATTR_ENTITY_ID: ENTITY_ID} # After init state is playing - check pause call - assert await hass.services.async_call( - MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data, True - ) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data, True) client.pause.assert_called_once() client.play.assert_not_called() # After pause state is paused - check play call - assert await hass.services.async_call( - MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data, True - ) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data, True) client.play.assert_called_once() client.pause.assert_called_once() @@ -154,7 +150,7 @@ async def test_media_next_previous_track( # check channel up/down for live TV channels data = {ATTR_ENTITY_ID: ENTITY_ID} - assert await hass.services.async_call(MP_DOMAIN, service, data, True) + await hass.services.async_call(MP_DOMAIN, service, data, True) getattr(client, client_call[0]).assert_not_called() getattr(client, client_call[1]).assert_called_once() @@ -162,7 +158,7 @@ async def test_media_next_previous_track( # check next/previous for not Live TV channels monkeypatch.setattr(client, "current_app_id", "in1") data = {ATTR_ENTITY_ID: ENTITY_ID} - assert await hass.services.async_call(MP_DOMAIN, service, data, True) + await hass.services.async_call(MP_DOMAIN, service, data, True) getattr(client, client_call[0]).assert_called_once() getattr(client, client_call[1]).assert_called_once() @@ -179,7 +175,7 @@ async def test_select_source_with_empty_source_list( ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "nonexistent", } - assert await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) + await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) client.launch_app.assert_not_called() client.set_input.assert_not_called() @@ -195,7 +191,7 @@ async def test_select_app_source(hass: HomeAssistant, client) -> None: ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "Live TV", } - assert await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) + await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) client.launch_app.assert_called_once_with(LIVE_TV_APP_ID) client.set_input.assert_not_called() @@ -210,7 +206,7 @@ async def test_select_input_source(hass: HomeAssistant, client) -> None: ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "Input01", } - assert await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) + await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) client.launch_app.assert_not_called() client.set_input.assert_called_once_with("in1") @@ -224,8 +220,8 @@ async def test_button(hass: HomeAssistant, client) -> None: ATTR_ENTITY_ID: ENTITY_ID, ATTR_BUTTON: "test", } - assert await hass.services.async_call(DOMAIN, SERVICE_BUTTON, data, True) - + await hass.services.async_call(DOMAIN, SERVICE_BUTTON, data, True) + await hass.async_block_till_done() client.button.assert_called_once() client.button.assert_called_with("test") @@ -238,8 +234,8 @@ async def test_command(hass: HomeAssistant, client) -> None: ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: "test", } - assert await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) - + await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) + await hass.async_block_till_done() client.request.assert_called_with("test", payload=None) @@ -252,8 +248,8 @@ async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: ATTR_COMMAND: "test", ATTR_PAYLOAD: {"target": "https://www.google.com"}, } - assert await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) - + await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) + await hass.async_block_till_done() client.request.assert_called_with( "test", payload={"target": "https://www.google.com"} ) @@ -267,10 +263,8 @@ async def test_select_sound_output(hass: HomeAssistant, client) -> None: ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_OUTPUT: "external_speaker", } - assert await hass.services.async_call( - DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True - ) - + await hass.services.async_call(DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True) + await hass.async_block_till_done() client.change_sound_output.assert_called_once_with("external_speaker") @@ -359,9 +353,7 @@ async def test_service_entity_id_none(hass: HomeAssistant, client) -> None: ATTR_ENTITY_ID: ENTITY_MATCH_NONE, ATTR_SOUND_OUTPUT: "external_speaker", } - assert await hass.services.async_call( - DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True - ) + await hass.services.async_call(DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True) client.change_sound_output.assert_not_called() @@ -384,7 +376,7 @@ async def test_play_media(hass: HomeAssistant, client, media_id, ch_id) -> None: ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, ATTR_MEDIA_CONTENT_ID: media_id, } - assert await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data, True) + await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data, True) client.set_channel.assert_called_once_with(ch_id) @@ -493,7 +485,7 @@ async def test_control_error_handling( # Device on, raise HomeAssistantError with pytest.raises(HomeAssistantError) as exc: - assert await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) assert ( str(exc.value) @@ -505,7 +497,7 @@ async def test_control_error_handling( monkeypatch.setattr(client, "is_on", False) monkeypatch.setattr(client, "play", Mock(side_effect=asyncio.TimeoutError)) await client.mock_state_update() - assert await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) assert client.play.call_count == 1 assert ( diff --git a/tests/components/wemo/test_fan.py b/tests/components/wemo/test_fan.py index 738b15f77c1..48ad5774b0a 100644 --- a/tests/components/wemo/test_fan.py +++ b/tests/components/wemo/test_fan.py @@ -106,7 +106,7 @@ async def test_fan_reset_filter_service( hass: HomeAssistant, pywemo_device, wemo_entity ) -> None: """Verify that SERVICE_RESET_FILTER_LIFE is registered and works.""" - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, fan.SERVICE_RESET_FILTER_LIFE, {ATTR_ENTITY_ID: wemo_entity.entity_id}, @@ -130,7 +130,7 @@ async def test_fan_set_humidity_service( hass: HomeAssistant, pywemo_device, wemo_entity, test_input, expected ) -> None: """Verify that SERVICE_SET_HUMIDITY is registered and works.""" - assert await hass.services.async_call( + await hass.services.async_call( DOMAIN, fan.SERVICE_SET_HUMIDITY, { @@ -157,7 +157,7 @@ async def test_fan_set_percentage( hass: HomeAssistant, pywemo_device, wemo_entity, percentage, expected_fan_mode ) -> None: """Verify set_percentage works properly through the entire range of FanModes.""" - assert await hass.services.async_call( + await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, {ATTR_ENTITY_ID: [wemo_entity.entity_id], ATTR_PERCENTAGE: percentage}, @@ -170,7 +170,7 @@ async def test_fan_mode_high_initially(hass: HomeAssistant, pywemo_device) -> No """Verify the FanMode is set to High when turned on.""" pywemo_device.fan_mode = FanMode.Off wemo_entity = await async_create_wemo_entity(hass, pywemo_device, "") - assert await hass.services.async_call( + await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index e4c33e313c6..68a8e07ffe4 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -28,7 +28,7 @@ async def test_ping_entity( }, blocking=True, ) - + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args_list[0][0][0] assert args["command"] == "node.ping" @@ -47,7 +47,7 @@ async def test_ping_entity( }, blocking=True, ) - + await hass.async_block_till_done() assert "There is no value to refresh for this entity" in caplog.text # Assert a node ping button entity is not created for the controller diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 00006673785..d3f38aaa307 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -297,7 +297,7 @@ async def test_thermostat_v2( }, blocking=True, ) - + await hass.async_block_till_done() assert "Error while refreshing value" in caplog.text diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 766d5684af5..d809d52821c 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -368,7 +368,7 @@ async def test_node_status_sensor_not_ready( }, blocking=True, ) - + await hass.async_block_till_done() assert "There is no value to refresh for this entity" in caplog.text @@ -755,7 +755,7 @@ async def test_statistics_sensors( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - + await hass.async_block_till_done() assert caplog.text.count("There is no value to refresh for this entity") == len( [ *CONTROLLER_STATISTICS_SUFFIXES, diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index a92d34dd412..a8671edbe64 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -678,6 +678,7 @@ async def test_refresh_value( {ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY}, blocking=True, ) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.poll_value" @@ -701,6 +702,7 @@ async def test_refresh_value( }, blocking=True, ) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 8 client.async_send_command.reset_mock() @@ -716,6 +718,7 @@ async def test_refresh_value( }, blocking=True, ) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 8 client.async_send_command.reset_mock() @@ -733,6 +736,7 @@ async def test_refresh_value( }, blocking=True, ) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 8 client.async_send_command.reset_mock() @@ -1593,6 +1597,7 @@ async def test_invoke_cc_api( }, blocking=True, ) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "endpoint.invoke_cc_api" @@ -1645,6 +1650,7 @@ async def test_invoke_cc_api( }, blocking=True, ) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "endpoint.invoke_cc_api" diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 6a8cbdd724a..dcd71789e84 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -127,7 +127,7 @@ async def test_update_entity_states( }, blocking=True, ) - + await hass.async_block_till_done() assert "There is no value to refresh for this entity" in caplog.text client.async_send_command.return_value = {"updates": []} diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index f3256e90b62..98e93785f58 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,4 +1,5 @@ """Tests for the intent helpers.""" +import asyncio from unittest.mock import MagicMock, patch import pytest @@ -246,3 +247,41 @@ def test_async_remove_no_existing(hass: HomeAssistant) -> None: # simply shouldn't cause an exception assert intent.DATA_KEY not in hass.data + + +async def test_validate_then_run_in_background(hass: HomeAssistant) -> None: + """Test we don't execute a service in foreground forever.""" + hass.states.async_set("light.kitchen", "off") + call_done = asyncio.Event() + calls = [] + + # Register a service that takes 0.1 seconds to execute + async def mock_service(call): + """Mock service.""" + await asyncio.sleep(0.1) + call_done.set() + calls.append(call) + + hass.services.async_register("light", "turn_on", mock_service) + + # Create intent handler with a service timeout of 0.05 seconds + handler = intent.ServiceIntentHandler( + "TestType", "light", "turn_on", "Turned {} on" + ) + handler.service_timeout = 0.05 + intent.async_register(hass, handler) + + result = await intent.async_handle( + hass, + "test", + "TestType", + slots={"name": {"value": "kitchen"}}, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + assert not call_done.is_set() + await call_done.wait() + + assert len(calls) == 1 + assert calls[0].data == {"entity_id": "light.kitchen"} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 5affce5d501..de16dcac053 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -23,7 +23,6 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import ( - SERVICE_CALL_LIMIT, Context, CoreState, HomeAssistant, @@ -264,7 +263,6 @@ async def test_calling_service_basic( "0": [ { "result": { - "limit": SERVICE_CALL_LIMIT, "params": { "domain": "test", "service": "script", @@ -317,7 +315,6 @@ async def test_calling_service_template(hass: HomeAssistant) -> None: "0": [ { "result": { - "limit": SERVICE_CALL_LIMIT, "params": { "domain": "test", "service": "script", @@ -356,7 +353,6 @@ async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: "0": [ { "result": { - "limit": SERVICE_CALL_LIMIT, "params": { "domain": "test", "service": "script", @@ -3338,7 +3334,6 @@ async def test_parallel_error( { "error_type": ServiceNotFound, "result": { - "limit": 10, "params": { "domain": "epic", "service": "failure", @@ -3387,7 +3382,6 @@ async def test_propagate_error_service_not_found(hass: HomeAssistant) -> None: { "error_type": ServiceNotFound, "result": { - "limit": 10, "params": { "domain": "test", "service": "script", @@ -3424,7 +3418,6 @@ async def test_propagate_error_invalid_service_data(hass: HomeAssistant) -> None { "error_type": vol.MultipleInvalid, "result": { - "limit": 10, "params": { "domain": "test", "service": "script", @@ -3465,7 +3458,6 @@ async def test_propagate_error_service_exception(hass: HomeAssistant) -> None: { "error_type": ValueError, "result": { - "limit": 10, "params": { "domain": "test", "service": "script", @@ -4343,7 +4335,6 @@ async def test_set_variable( "1": [ { "result": { - "limit": SERVICE_CALL_LIMIT, "params": { "domain": "test", "service": "script", @@ -4386,7 +4377,6 @@ async def test_set_redefines_variable( "1": [ { "result": { - "limit": SERVICE_CALL_LIMIT, "params": { "domain": "test", "service": "script", @@ -4402,7 +4392,6 @@ async def test_set_redefines_variable( "3": [ { "result": { - "limit": SERVICE_CALL_LIMIT, "params": { "domain": "test", "service": "script", @@ -4936,7 +4925,6 @@ async def test_continue_on_error(hass: HomeAssistant) -> None: "1": [ { "result": { - "limit": 10, "params": { "domain": "broken", "service": "service", @@ -4952,7 +4940,6 @@ async def test_continue_on_error(hass: HomeAssistant) -> None: { "error_type": HomeAssistantError, "result": { - "limit": 10, "params": { "domain": "broken", "service": "service", @@ -5011,7 +4998,6 @@ async def test_continue_on_error_automation_issue(hass: HomeAssistant) -> None: { "error_type": ServiceNotFound, "result": { - "limit": 10, "params": { "domain": "service", "service": "not_found", @@ -5059,7 +5045,6 @@ async def test_continue_on_error_unknown_error(hass: HomeAssistant) -> None: { "error_type": MyLibraryError, "result": { - "limit": 10, "params": { "domain": "some", "service": "service", diff --git a/tests/test_core.py b/tests/test_core.py index 5cb92ffe5c9..2759ca751b5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -957,9 +957,7 @@ async def test_serviceregistry_call_with_blocking_done_in_time( assert registered_events[0].data["domain"] == "test_domain" assert registered_events[0].data["service"] == "register_calls" - assert await hass.services.async_call( - "test_domain", "REGISTER_CALLS", blocking=True - ) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) assert len(calls) == 1 @@ -981,9 +979,7 @@ async def test_serviceregistry_async_service(hass: HomeAssistant) -> None: hass.services.async_register("test_domain", "register_calls", service_handler) - assert await hass.services.async_call( - "test_domain", "REGISTER_CALLS", blocking=True - ) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) assert len(calls) == 1 @@ -1000,9 +996,7 @@ async def test_serviceregistry_async_service_partial(hass: HomeAssistant) -> Non ) await hass.async_block_till_done() - assert await hass.services.async_call( - "test_domain", "REGISTER_CALLS", blocking=True - ) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) assert len(calls) == 1 @@ -1017,9 +1011,7 @@ async def test_serviceregistry_callback_service(hass: HomeAssistant) -> None: hass.services.async_register("test_domain", "register_calls", service_handler) - assert await hass.services.async_call( - "test_domain", "REGISTER_CALLS", blocking=True - ) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) assert len(calls) == 1 @@ -1063,12 +1055,10 @@ async def test_serviceregistry_async_service_raise_exception( hass.services.async_register("test_domain", "register_calls", service_handler) with pytest.raises(ValueError): - assert await hass.services.async_call( - "test_domain", "REGISTER_CALLS", blocking=True - ) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) # Non-blocking service call never throw exception - await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) + hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) await hass.async_block_till_done() @@ -1085,12 +1075,10 @@ async def test_serviceregistry_callback_service_raise_exception( hass.services.async_register("test_domain", "register_calls", service_handler) with pytest.raises(ValueError): - assert await hass.services.async_call( - "test_domain", "REGISTER_CALLS", blocking=True - ) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) # Non-blocking service call never throw exception - await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) + hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) await hass.async_block_till_done() @@ -1359,42 +1347,6 @@ async def test_async_functions_with_callback(hass: HomeAssistant) -> None: assert len(runs) == 3 -@pytest.mark.parametrize("cancel_call", [True, False]) -async def test_cancel_service_task(hass: HomeAssistant, cancel_call) -> None: - """Test cancellation.""" - service_called = asyncio.Event() - service_cancelled = False - - async def service_handler(call): - nonlocal service_cancelled - service_called.set() - try: - await asyncio.sleep(10) - except asyncio.CancelledError: - service_cancelled = True - raise - - hass.services.async_register("test_domain", "test_service", service_handler) - call_task = hass.async_create_task( - hass.services.async_call("test_domain", "test_service", blocking=True) - ) - - tasks_1 = asyncio.all_tasks() - await asyncio.wait_for(service_called.wait(), timeout=1) - tasks_2 = asyncio.all_tasks() - tasks_1 - assert len(tasks_2) == 1 - service_task = tasks_2.pop() - - if cancel_call: - call_task.cancel() - else: - service_task.cancel() - with pytest.raises(asyncio.CancelledError): - await call_task - - assert service_cancelled - - def test_valid_entity_id() -> None: """Test valid entity ID.""" for invalid in [ From 84c66b3cadf5b06ca47bb9bf50f3b6faff50f5de Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 16 Jun 2023 09:43:35 -0700 Subject: [PATCH 311/857] Add support for services to return data (#94401) * Add support for service calls with resopnse data. Update the service calls to allow returning responses with data, with an initial use case supporting basic service calls usable within script. * Revert enttiy platform/component changes * Remove unnecessary comma diff * Revert additional unnecessary changes * Simplify service call * Simplify and fix typing and revert whitespace * Clarify typing intent * Revert more entity service calls * Revert additional entity service changes * Set blocking=True for group notify service call * Revert unnecessary changes * Reverting more whitespace changes * Revert more service changes * Add test coverage for None return case * Add parameter to service calls indicating return values were requested * Update tests/test_core.py Co-authored-by: Paulus Schoutsen * Add additional service call tests * Update test comment --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/group/notify.py | 4 +- homeassistant/core.py | 77 ++++++++++---- tests/test_core.py | 125 ++++++++++++++++++++++- 3 files changed, 184 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 378a7852343..2747ba55ee1 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -66,7 +66,7 @@ class GroupNotifyPlatform(BaseNotificationService): payload: dict[str, Any] = {ATTR_MESSAGE: message} payload.update({key: val for key, val in kwargs.items() if val}) - tasks: list[asyncio.Task[bool | None]] = [] + tasks: list[asyncio.Task[Any]] = [] for entity in self.entities: sending_payload = deepcopy(payload.copy()) if (default_data := entity.get(ATTR_DATA)) is not None: @@ -74,7 +74,7 @@ class GroupNotifyPlatform(BaseNotificationService): tasks.append( asyncio.create_task( self.hass.services.async_call( - DOMAIN, entity[ATTR_SERVICE], sending_payload + DOMAIN, entity[ATTR_SERVICE], sending_payload, blocking=True ) ) ) diff --git a/homeassistant/core.py b/homeassistant/core.py index 333f9b82cd2..6405b0860e1 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -88,6 +88,7 @@ from .util.async_ import ( run_callback_threadsafe, shutdown_run_callback_threadsafe, ) +from .util.json import JsonObjectType from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager from .util.unit_system import ( @@ -130,6 +131,8 @@ DOMAIN = "homeassistant" # How long to wait to log tasks that are blocking BLOCK_LOG_TIMEOUT = 60 +ServiceResult = JsonObjectType | None + class ConfigSource(StrEnum): """Source of core configuration.""" @@ -1659,7 +1662,7 @@ class Service: def __init__( self, - func: Callable[[ServiceCall], Coroutine[Any, Any, None] | None], + func: Callable[[ServiceCall], Coroutine[Any, Any, ServiceResult] | None], schema: vol.Schema | None, domain: str, service: str, @@ -1673,7 +1676,7 @@ class Service: class ServiceCall: """Representation of a call to a service.""" - __slots__ = ["domain", "service", "data", "context"] + __slots__ = ["domain", "service", "data", "context", "return_values"] def __init__( self, @@ -1681,12 +1684,14 @@ class ServiceCall: service: str, data: dict[str, Any] | None = None, context: Context | None = None, + return_values: bool = False, ) -> None: """Initialize a service call.""" self.domain = domain.lower() self.service = service.lower() self.data = ReadOnlyDict(data or {}) self.context = context or Context() + self.return_values = return_values def __repr__(self) -> str: """Return the representation of the service.""" @@ -1731,7 +1736,10 @@ class ServiceRegistry: self, domain: str, service: str, - service_func: Callable[[ServiceCall], Coroutine[Any, Any, None] | None], + service_func: Callable[ + [ServiceCall], + Coroutine[Any, Any, ServiceResult] | None, + ], schema: vol.Schema | None = None, ) -> None: """Register a service. @@ -1747,7 +1755,9 @@ class ServiceRegistry: self, domain: str, service: str, - service_func: Callable[[ServiceCall], Coroutine[Any, Any, None] | None], + service_func: Callable[ + [ServiceCall], Coroutine[Any, Any, ServiceResult] | None + ], schema: vol.Schema | None = None, ) -> None: """Register a service. @@ -1805,13 +1815,22 @@ class ServiceRegistry: blocking: bool = False, context: Context | None = None, target: dict[str, Any] | None = None, - ) -> bool | None: + return_values: bool = False, + ) -> ServiceResult: """Call a service. See description of async_call for details. """ return asyncio.run_coroutine_threadsafe( - self.async_call(domain, service, service_data, blocking, context, target), + self.async_call( + domain, + service, + service_data, + blocking, + context, + target, + return_values, + ), self._hass.loop, ).result() @@ -1823,11 +1842,16 @@ class ServiceRegistry: blocking: bool = False, context: Context | None = None, target: dict[str, Any] | None = None, - ) -> None: + return_values: bool = False, + ) -> ServiceResult: """Call a service. Specify blocking=True to wait until service is executed. + If return_values=True, indicates that the caller can consume return values + from the service, if any. Return values are a dict that can be returned by the + standard JSON serialization process. Return values can only be used with blocking=True. + This method will fire an event to indicate the service has been called. Because the service is sent as an event you are not allowed to use @@ -1840,6 +1864,9 @@ class ServiceRegistry: context = context or Context() service_data = service_data or {} + if return_values and not blocking: + raise ValueError("Invalid argument return_values=True when blocking=False") + try: handler = self._services[domain][service] except KeyError: @@ -1862,7 +1889,9 @@ class ServiceRegistry: else: processed_data = service_data - service_call = ServiceCall(domain, service, processed_data, context) + service_call = ServiceCall( + domain, service, processed_data, context, return_values + ) self._hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -1877,13 +1906,20 @@ class ServiceRegistry: coro = self._execute_service(handler, service_call) if not blocking: self._run_service_in_background(coro, service_call) - return + return None - await coro + response_data = await coro + if not return_values: + return None + if not isinstance(response_data, dict): + raise HomeAssistantError( + f"Service response data expected a dictionary, was {type(response_data)}" + ) + return response_data def _run_service_in_background( self, - coro_or_task: Coroutine[Any, Any, None] | asyncio.Task[None], + coro_or_task: Coroutine[Any, Any, Any] | asyncio.Task[Any], service_call: ServiceCall, ) -> None: """Run service call in background, catching and logging any exceptions.""" @@ -1909,18 +1945,21 @@ class ServiceRegistry: async def _execute_service( self, handler: Service, service_call: ServiceCall - ) -> None: + ) -> ServiceResult: """Execute a service.""" if handler.job.job_type == HassJobType.Coroutinefunction: - await cast(Callable[[ServiceCall], Awaitable[None]], handler.job.target)( + return await cast( + Callable[[ServiceCall], Awaitable[ServiceResult]], + handler.job.target, + )(service_call) + if handler.job.job_type == HassJobType.Callback: + return cast(Callable[[ServiceCall], ServiceResult], handler.job.target)( service_call ) - elif handler.job.job_type == HassJobType.Callback: - cast(Callable[[ServiceCall], None], handler.job.target)(service_call) - else: - await self._hass.async_add_executor_job( - cast(Callable[[ServiceCall], None], handler.job.target), service_call - ) + return await self._hass.async_add_executor_job( + cast(Callable[[ServiceCall], ServiceResult], handler.job.target), + service_call, + ) class Config: diff --git a/tests/test_core.py b/tests/test_core.py index 2759ca751b5..ebc5718c7cb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -33,8 +33,9 @@ from homeassistant.const import ( __version__, ) import homeassistant.core as ha -from homeassistant.core import HassJob, HomeAssistant, State +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, ServiceResult, State from homeassistant.exceptions import ( + HomeAssistantError, InvalidEntityFormatError, InvalidStateError, MaxLengthExceeded, @@ -1082,6 +1083,128 @@ async def test_serviceregistry_callback_service_raise_exception( await hass.async_block_till_done() +async def test_serviceregistry_return_values(hass: HomeAssistant) -> None: + """Test service call for a service that has return values.""" + + def service_handler(call: ServiceCall) -> ServiceResult: + """Service handler coroutine.""" + assert call.return_values + return {"test-reply": "test-value1"} + + hass.services.async_register( + "test_domain", + "test_service", + service_handler, + ) + result = await hass.services.async_call( + "test_domain", + "test_service", + service_data={}, + blocking=True, + return_values=True, + ) + await hass.async_block_till_done() + assert result == {"test-reply": "test-value1"} + + +async def test_serviceregistry_async_return_values(hass: HomeAssistant) -> None: + """Test service call for an async service that has return values.""" + + async def service_handler(call: ServiceCall) -> ServiceResult: + """Service handler coroutine.""" + assert call.return_values + return {"test-reply": "test-value1"} + + hass.services.async_register( + "test_domain", + "test_service", + service_handler, + ) + result = await hass.services.async_call( + "test_domain", + "test_service", + service_data={}, + blocking=True, + return_values=True, + ) + await hass.async_block_till_done() + assert result == {"test-reply": "test-value1"} + + +async def test_services_call_return_values_requires_blocking( + hass: HomeAssistant, +) -> None: + """Test that non-blocking service calls cannot return values.""" + async_mock_service(hass, "test_domain", "test_service") + with pytest.raises(ValueError, match="when blocking=False"): + await hass.services.async_call( + "test_domain", + "test_service", + service_data={}, + blocking=False, + return_values=True, + ) + + +@pytest.mark.parametrize( + ("return_value", "expected_error"), + [ + (True, "expected a dictionary"), + (False, "expected a dictionary"), + (None, "expected a dictionary"), + ("some-value", "expected a dictionary"), + (["some-list"], "expected a dictionary"), + ], +) +async def test_serviceregistry_return_values_invalid( + hass: HomeAssistant, return_value: Any, expected_error: str +) -> None: + """Test service call return values are not returned when there is no result schema.""" + + def service_handler(call: ServiceCall) -> ServiceResult: + """Service handler coroutine.""" + assert call.return_values + return return_value + + hass.services.async_register( + "test_domain", + "test_service", + service_handler, + ) + with pytest.raises(HomeAssistantError, match=expected_error): + await hass.services.async_call( + "test_domain", + "test_service", + service_data={}, + blocking=True, + return_values=True, + ) + await hass.async_block_till_done() + + +async def test_serviceregistry_no_return_values(hass: HomeAssistant) -> None: + """Test service call data when not asked for return values.""" + + def service_handler(call: ServiceCall) -> None: + """Service handler coroutine.""" + assert not call.return_values + return + + hass.services.async_register( + "test_domain", + "test_service", + service_handler, + ) + result = await hass.services.async_call( + "test_domain", + "test_service", + service_data={}, + blocking=True, + ) + await hass.async_block_till_done() + assert not result + + async def test_config_defaults() -> None: """Test config defaults.""" hass = Mock() From d7755a92c31dd3efb28119fd5f65bc1dd0cb0d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Fri, 16 Jun 2023 19:12:52 +0200 Subject: [PATCH 312/857] Fix warning from rapt_ble caused by payload version 2 (#94718) --- homeassistant/components/rapt_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json index d3eab0641a6..1bde135de35 100644 --- a/homeassistant/components/rapt_ble/manifest.json +++ b/homeassistant/components/rapt_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/rapt_ble", "iot_class": "local_push", - "requirements": ["rapt-ble==0.1.1"] + "requirements": ["rapt-ble==0.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b5ebecc3028..564b21d22ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2253,7 +2253,7 @@ radiotherm==2.1.0 raincloudy==0.0.7 # homeassistant.components.rapt_ble -rapt-ble==0.1.1 +rapt-ble==0.1.2 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea1564fe384..49bd3a91909 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1643,7 +1643,7 @@ radios==0.1.1 radiotherm==2.1.0 # homeassistant.components.rapt_ble -rapt-ble==0.1.1 +rapt-ble==0.1.2 # homeassistant.components.rainmachine regenmaschine==2023.06.0 From 3778e1cd7722bf03a422000c308c31b566dacd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Modzelewski?= Date: Fri, 16 Jun 2023 20:05:46 +0200 Subject: [PATCH 313/857] Support launching app deep links in apple_tv integration (#94705) --- homeassistant/components/apple_tv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 4196dd1bd9a..a70a30656f2 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -282,7 +282,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Send the play_media command to the media player.""" # If input (file) has a file format supported by pyatv, then stream it with # RAOP. Otherwise try to play it with regular AirPlay. - if media_type == MediaType.APP: + if media_type in {MediaType.APP, MediaType.URL}: await self.atv.apps.launch_app(media_id) return From 68cf796be8fefdd5f411825ca1c567e081d3d3bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jun 2023 20:07:57 -0500 Subject: [PATCH 314/857] Speed up entity service calls (#94731) * Speed up entity service calls - Avoid permissions check if the caller is an admin - Use set intersection instead of linear search of entity platforms to find entities * tweak * fix light test to not use an admin user --- homeassistant/helpers/service.py | 37 +++++++++++++---------------- tests/components/light/test_init.py | 6 ++--- tests/helpers/test_service.py | 5 ++-- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index a9d7b906e73..a1ecdc75c71 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -680,15 +680,13 @@ async def entity_service_call( # noqa: C901 Calls all platforms simultaneously. """ + entity_perms: None | (Callable[[str, str], bool]) = None if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) if user is None: raise UnknownUser(context=call.context) - entity_perms: None | ( - Callable[[str, str], bool] - ) = user.permissions.check_entity - else: - entity_perms = None + if not user.is_admin: + entity_perms = user.permissions.check_entity target_all_entities = call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL @@ -714,15 +712,15 @@ async def entity_service_call( # noqa: C901 if entity_perms is None: for platform in platforms: + platform_entities = platform.entities if target_all_entities: - entity_candidates.extend(platform.entities.values()) + entity_candidates.extend(platform_entities.values()) else: assert all_referenced is not None entity_candidates.extend( [ - entity - for entity in platform.entities.values() - if entity.entity_id in all_referenced + platform_entities[entity_id] + for entity_id in all_referenced.intersection(platform_entities) ] ) @@ -742,21 +740,20 @@ async def entity_service_call( # noqa: C901 assert all_referenced is not None for platform in platforms: - platform_entities = [] - for entity in platform.entities.values(): - if entity.entity_id not in all_referenced: - continue - - if not entity_perms(entity.entity_id, POLICY_CONTROL): + platform_entities = platform.entities + platform_entity_candidates = [] + entity_id_matches = all_referenced.intersection(platform_entities) + for entity_id in entity_id_matches: + if not entity_perms(entity_id, POLICY_CONTROL): raise Unauthorized( context=call.context, - entity_id=entity.entity_id, + entity_id=entity_id, permission=POLICY_CONTROL, ) - platform_entities.append(entity) + platform_entity_candidates.append(platform_entities[entity_id]) - entity_candidates.extend(platform_entities) + entity_candidates.extend(platform_entity_candidates) if not target_all_entities: assert referenced is not None @@ -769,7 +766,7 @@ async def entity_service_call( # noqa: C901 referenced.log_missing(missing) - entities = [] + entities: list[Entity] = [] for entity in entity_candidates: if not entity.available: @@ -810,7 +807,7 @@ async def entity_service_call( # noqa: C901 for future in done: future.result() # pop exception if have - tasks = [] + tasks: list[asyncio.Task[None]] = [] for entity in entities: if not entity.should_poll: diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 4b4c027541e..2dc8e504898 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -872,7 +872,7 @@ async def test_light_context( async def test_light_turn_on_auth( - hass: HomeAssistant, hass_admin_user: MockUser, enable_custom_integrations: None + hass: HomeAssistant, hass_read_only_user: MockUser, enable_custom_integrations: None ) -> None: """Test that light context works.""" platform = getattr(hass.components, "test.light") @@ -883,7 +883,7 @@ async def test_light_turn_on_auth( state = hass.states.get("light.ceiling") assert state is not None - hass_admin_user.mock_policy({}) + hass_read_only_user.mock_policy({}) with pytest.raises(Unauthorized): await hass.services.async_call( @@ -891,7 +891,7 @@ async def test_light_turn_on_auth( "turn_on", {"entity_id": state.entity_id}, blocking=True, - context=core.Context(user_id=hass_admin_user.id), + context=core.Context(user_id=hass_read_only_user.id), ) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 8e8123ac7af..845b5bacd1a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -714,7 +714,8 @@ async def test_call_context_target_all( return_value=Mock( permissions=PolicyPermissions( {"entities": {"entity_ids": {"light.kitchen": True}}}, None - ) + ), + is_admin=False, ), ): await service.entity_service_call( @@ -767,7 +768,7 @@ async def test_call_context_target_specific_no_auth( """Check targeting specific entities without auth.""" with pytest.raises(exceptions.Unauthorized) as err, patch( "homeassistant.auth.AuthManager.async_get_user", - return_value=Mock(permissions=PolicyPermissions({}, None)), + return_value=Mock(permissions=PolicyPermissions({}, None), is_admin=False), ): await service.entity_service_call( hass, From 4f669b326f3f26b4a0f842a4daac4c336628969e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 17 Jun 2023 03:08:14 +0200 Subject: [PATCH 315/857] Fix typo in tts tests (#94725) --- tests/components/tts/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 4e57b85ba4f..a9a95eae2f4 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -211,7 +211,7 @@ async def mock_config_entry_setup( async def async_unload_entry_init( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: - """Unload up test config entry.""" + """Unload test config entry.""" await hass.config_entries.async_forward_entry_unload(config_entry, TTS_DOMAIN) return True From c4284c07b6d32c2445713f723518411735b140b9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 16 Jun 2023 19:59:44 -0700 Subject: [PATCH 316/857] Allow scripts to capture service response data in variables (#94757) * Allow scripts service actions to save return values * Simplify script service response data * Rename result_variable to response_variable based on feedback --- homeassistant/const.py | 1 + homeassistant/helpers/config_validation.py | 2 + homeassistant/helpers/script.py | 23 ++++--- tests/helpers/test_script.py | 75 ++++++++++++++++++++++ 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5d4b0c2b515..94c932b1fd1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -222,6 +222,7 @@ CONF_REPEAT: Final = "repeat" CONF_RESOURCE: Final = "resource" CONF_RESOURCES: Final = "resources" CONF_RESOURCE_TEMPLATE: Final = "resource_template" +CONF_RESPONSE_VARIABLE: Final = "response_variable" CONF_RGB: Final = "rgb" CONF_ROOM: Final = "room" CONF_SCAN_INTERVAL: Final = "scan_interval" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 27e4bc2c41f..db6a2fc5a8d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -59,6 +59,7 @@ from homeassistant.const import ( CONF_PARALLEL, CONF_PLATFORM, CONF_REPEAT, + CONF_RESPONSE_VARIABLE, CONF_SCAN_INTERVAL, CONF_SCENE, CONF_SEQUENCE, @@ -1265,6 +1266,7 @@ SERVICE_SCHEMA = vol.All( ), vol.Optional(CONF_ENTITY_ID): comp_entity_ids, vol.Optional(CONF_TARGET): vol.Any(TARGET_SERVICE_FIELDS, dynamic_template), + vol.Optional(CONF_RESPONSE_VARIABLE): str, # The frontend stores data here. Don't use in core. vol.Remove("metadata"): dict, } diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 3bbc4ddd4ea..b876affb9e6 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -11,7 +11,7 @@ from functools import partial import itertools import logging from types import MappingProxyType -from typing import Any, TypedDict, cast +from typing import Any, TypedDict, TypeVar, cast import async_timeout import voluptuous as vol @@ -46,6 +46,7 @@ from homeassistant.const import ( CONF_MODE, CONF_PARALLEL, CONF_REPEAT, + CONF_RESPONSE_VARIABLE, CONF_SCENE, CONF_SEQUENCE, CONF_SERVICE, @@ -99,6 +100,8 @@ from .typing import ConfigType # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs +_T = TypeVar("_T") + SCRIPT_MODE_PARALLEL = "parallel" SCRIPT_MODE_QUEUED = "queued" SCRIPT_MODE_RESTART = "restart" @@ -617,7 +620,7 @@ class _ScriptRun: task.cancel() unsub() - async def _async_run_long_action(self, long_task: asyncio.Task) -> None: + async def _async_run_long_action(self, long_task: asyncio.Task[_T]) -> _T | None: """Run a long task while monitoring for stop request.""" async def async_cancel_long_task() -> None: @@ -645,10 +648,10 @@ class _ScriptRun: raise asyncio.CancelledError if long_task.done(): # Propagate any exceptions that occurred. - long_task.result() - else: - # Stopped before long task completed, so cancel it. - await async_cancel_long_task() + return long_task.result() + # Stopped before long task completed, so cancel it. + await async_cancel_long_task() + return None async def _async_call_service_step(self): """Call the service specified in the action.""" @@ -663,16 +666,20 @@ class _ScriptRun: and params[CONF_SERVICE] == "trigger" or params[CONF_DOMAIN] in ("python_script", "script") ) + response_variable = self._action.get(CONF_RESPONSE_VARIABLE) trace_set_result(params=params, running_script=running_script) - await self._async_run_long_action( + response_data = await self._async_run_long_action( self._hass.async_create_task( self._hass.services.async_call( **params, blocking=True, context=self._context, + return_values=(response_variable is not None), ) - ) + ), ) + if response_variable: + self._variables[response_variable] = response_data async def _async_device_step(self): """Perform the device automation specified in the action.""" diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index de16dcac053..0868bb5a0cc 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -27,6 +27,7 @@ from homeassistant.core import ( CoreState, HomeAssistant, ServiceCall, + ServiceResult, callback, ) from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound @@ -329,6 +330,80 @@ async def test_calling_service_template(hass: HomeAssistant) -> None: ) +async def test_calling_service_return_values( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the calling of a service with return values.""" + context = Context() + + def mock_service(call: ServiceCall) -> ServiceResult: + """Mock service call.""" + if call.return_values: + return {"data": "value-12345"} + return None + + hass.services.async_register("test", "script", mock_service) + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "service step1", + "service": "test.script", + # Store the result of the service call as a variable + "response_variable": "my_response", + }, + { + "alias": "service step2", + "service": "test.script", + "data_template": { + # Result of previous service call + "key": "{{ my_response.data }}" + }, + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=context) + await hass.async_block_till_done() + + assert "Executing step service step1" in caplog.text + assert "Executing step service step2" in caplog.text + + assert_action_trace( + { + "0": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {}, + "target": {}, + }, + "running_script": False, + } + } + ], + "1": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {"key": "value-12345"}, + "target": {}, + }, + "running_script": False, + }, + "variables": { + "my_response": {"data": "value-12345"}, + }, + } + ], + } + ) + + async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: """Test the calling of a service with a data_template with a templated key.""" context = Context() From 71e8ee52e5484b2d17c0a2a9aed264c924f5b4fc Mon Sep 17 00:00:00 2001 From: disforw Date: Sat, 17 Jun 2023 10:06:28 -0400 Subject: [PATCH 317/857] Fix QNAP Sensor Entity Descriptions (#94749) --- homeassistant/components/qnap/sensor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 85b9167243f..59dab85f04c 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ATTR_NAME, @@ -70,6 +71,8 @@ _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( name="System Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + state_class=SensorStateClass.MEASUREMENT, ), ) _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -78,12 +81,16 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( name="CPU Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:checkbox-marked-circle-outline", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="cpu_usage", name="CPU Usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", + state_class=SensorStateClass.MEASUREMENT, ), ) _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -93,6 +100,8 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="memory_used", @@ -100,12 +109,15 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="memory_percent_used", name="Memory Usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), ) _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -120,6 +132,8 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="network_rx", @@ -127,6 +141,8 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), ) _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -134,12 +150,16 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( key="drive_smart_status", name="SMART Status", icon="mdi:checkbox-marked-circle-outline", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="drive_temp", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), ) _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -149,6 +169,8 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volume_size_free", @@ -156,12 +178,15 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volume_percentage_used", name="Volume Used", native_unit_of_measurement=PERCENTAGE, icon="mdi:chart-pie", + state_class=SensorStateClass.MEASUREMENT, ), ) From b5e1d35e1885c46a089fee16510171813d826388 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jun 2023 15:23:06 -0500 Subject: [PATCH 318/857] Bump cryptography to 41.0.1 and PyOpenSSL to 23.2.0 (#94777) changelog: https://cryptography.io/en/latest/changelog/ changelog: https://www.pyopenssl.org/en/latest/changelog.html --- homeassistant/package_constraints.txt | 4 ++-- pyproject.toml | 6 +++--- requirements.txt | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fdfd3560075..a2881676f5c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bluetooth-auto-recovery==1.2.0 bluetooth-data-tools==1.2.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==40.0.2 +cryptography==41.0.1 dbus-fast==1.86.0 fnv-hash-fast==0.3.1 ha-av==10.1.0 @@ -37,7 +37,7 @@ pip>=21.0,<23.2 psutil-home-assistant==0.0.1 PyJWT==2.7.0 PyNaCl==1.5.0 -pyOpenSSL==23.1.0 +pyOpenSSL==23.2.0 pyserial==3.5 python-slugify==4.0.1 PyTurboJPEG==1.6.7 diff --git a/pyproject.toml b/pyproject.toml index ee351493323..bea8a1696fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ dependencies = [ "lru-dict==1.2.0", "PyJWT==2.7.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==40.0.2", - # pyOpenSSL 23.1.0 is required to work with cryptography 39+ - "pyOpenSSL==23.1.0", + "cryptography==41.0.1", + # pyOpenSSL 23.2.0 is required to work with cryptography 41+ + "pyOpenSSL==23.2.0", "orjson==3.9.1", "pip>=21.0,<23.2", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index bf21c2d8643..cf86475098f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,8 +16,8 @@ ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.7.0 -cryptography==40.0.2 -pyOpenSSL==23.1.0 +cryptography==41.0.1 +pyOpenSSL==23.2.0 orjson==3.9.1 pip>=21.0,<23.2 python-slugify==4.0.1 From be638d3772599c566b798772b2247c0a7ac8f176 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jun 2023 15:23:33 -0500 Subject: [PATCH 319/857] Bump recommended esphome version for bluetooth to 2023.6.0 (#94773) 2023.6.0 is needed for #94138 to work --- homeassistant/components/esphome/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5e113aff86f..70c026614c6 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -73,7 +73,7 @@ CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) _R = TypeVar("_R") -STABLE_BLE_VERSION_STR = "2023.4.0" +STABLE_BLE_VERSION_STR = "2023.6.0" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 0513117a1104bb7cd6f8a4af23f434c0d1842ee6 Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Sat, 17 Jun 2023 14:07:18 -0700 Subject: [PATCH 320/857] Add hub to keyboard_remote manifest (#94788) added hub to keyboard_remote manifest --- homeassistant/components/keyboard_remote/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index 2b298901ca9..bb84b32defc 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -3,6 +3,7 @@ "name": "Keyboard Remote", "codeowners": ["@bendavid", "@lanrat"], "documentation": "https://www.home-assistant.io/integrations/keyboard_remote", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"] From 7f7b7aee6d1b6efe75e4feb0e01da8e6457a3826 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Sat, 17 Jun 2023 17:59:19 -0700 Subject: [PATCH 321/857] pyWeMo serialnumber is deprecated, use serial_number (#94791) --- homeassistant/components/wemo/__init__.py | 10 +++++----- homeassistant/components/wemo/entity.py | 2 +- homeassistant/components/wemo/wemo_device.py | 6 +++--- tests/components/wemo/conftest.py | 6 +++--- tests/components/wemo/test_init.py | 4 ++-- tests/components/wemo/test_light_bridge.py | 4 ++-- tests/components/wemo/test_wemo_device.py | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index b208a30f5ec..4488e881938 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -154,14 +154,14 @@ class WemoDispatcher: self, hass: HomeAssistant, wemo: pywemo.WeMoDevice ) -> None: """Add a WeMo device to hass if it has not already been added.""" - if wemo.serialnumber in self._added_serial_numbers: + if wemo.serial_number in self._added_serial_numbers: return try: coordinator = await async_register_device(hass, self._config_entry, wemo) except pywemo.PyWeMoException as err: - if wemo.serialnumber not in self._failed_serial_numbers: - self._failed_serial_numbers.add(wemo.serialnumber) + if wemo.serial_number not in self._failed_serial_numbers: + self._failed_serial_numbers.add(wemo.serial_number) _LOGGER.error( "Unable to add WeMo %s %s: %s", repr(wemo), wemo.host, err ) @@ -194,8 +194,8 @@ class WemoDispatcher: coordinator, ) - self._added_serial_numbers.add(wemo.serialnumber) - self._failed_serial_numbers.discard(wemo.serialnumber) + self._added_serial_numbers.add(wemo.serial_number) + self._failed_serial_numbers.discard(wemo.serial_number) class WemoDiscovery: diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index c8ed9cdde08..1debc32a39b 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -53,7 +53,7 @@ class WemoEntity(CoordinatorEntity[DeviceCoordinator]): @property def unique_id(self) -> str: """Return the id of this WeMo device.""" - serial_number: str = self.wemo.serialnumber + serial_number: str = self.wemo.serial_number if suffix := self.unique_id_suffix: return f"{serial_number}_{suffix}" return serial_number diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 56ed9b8e1e6..9f2c72a1585 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -61,7 +61,7 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): CONF_NAME: self.wemo.name, CONF_TYPE: event_type, CONF_PARAMS: params, - CONF_UNIQUE_ID: self.wemo.serialnumber, + CONF_UNIQUE_ID: self.wemo.serial_number, }, ) else: @@ -131,14 +131,14 @@ def _create_device_info(wemo: WeMoDevice) -> DeviceInfo: _dev_info = _device_info(wemo) if wemo.model_name == "DLI emulated Belkin Socket": _dev_info[ATTR_CONFIGURATION_URL] = f"http://{wemo.host}" - _dev_info[ATTR_IDENTIFIERS] = {(DOMAIN, wemo.serialnumber[:-1])} + _dev_info[ATTR_IDENTIFIERS] = {(DOMAIN, wemo.serial_number[:-1])} return _dev_info def _device_info(wemo: WeMoDevice) -> DeviceInfo: return DeviceInfo( connections={(CONNECTION_UPNP, wemo.udn)}, - identifiers={(DOMAIN, wemo.serialnumber)}, + identifiers={(DOMAIN, wemo.serial_number)}, manufacturer="Belkin", model=wemo.model_name, name=wemo.name, diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 6dc7b1e5d2c..5fe798004da 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -61,9 +61,9 @@ def create_pywemo_device(pywemo_registry, pywemo_model): device.host = MOCK_HOST device.port = MOCK_PORT device.name = MOCK_NAME - device.serialnumber = MOCK_SERIAL_NUMBER + device.serial_number = MOCK_SERIAL_NUMBER device.model_name = pywemo_model.replace("LongPress", "") - device.udn = f"uuid:{device.model_name}-1_0-{device.serialnumber}" + device.udn = f"uuid:{device.model_name}-1_0-{device.serial_number}" device.firmware_version = MOCK_FIRMWARE_VERSION device.get_state.return_value = 0 # Default to Off device.supports_long_press.return_value = cls.supports_long_press() @@ -102,7 +102,7 @@ def pywemo_dli_device_fixture(pywemo_registry, pywemo_model): """Fixture for Digital Loggers emulated instances.""" with create_pywemo_device(pywemo_registry, pywemo_model) as pywemo_dli_device: pywemo_dli_device.model_name = "DLI emulated Belkin Socket" - pywemo_dli_device.serialnumber = "1234567891" + pywemo_dli_device.serial_number = "1234567891" yield pywemo_dli_device diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index efdcb5424d1..0e9ba19af42 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -138,9 +138,9 @@ async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None: device.host = f"{MOCK_HOST}_{counter}" device.port = MOCK_PORT + counter device.name = f"{MOCK_NAME}_{counter}" - device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{counter}" + device.serial_number = f"{MOCK_SERIAL_NUMBER}_{counter}" device.model_name = "Motion" - device.udn = f"uuid:{device.model_name}-1_0-{device.serialnumber}" + device.udn = f"uuid:{device.model_name}-1_0-{device.serial_number}" device.firmware_version = MOCK_FIRMWARE_VERSION device.get_state.return_value = 0 # Default to Off device.supports_long_press.return_value = False diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 62e68b2ea39..6f4180626b2 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -35,12 +35,12 @@ def pywemo_model(): def pywemo_bridge_light_fixture(pywemo_device): """Fixture for Bridge.Light WeMoDevice instances.""" light = create_autospec(pywemo.ouimeaux_device.bridge.Light, instance=True) - light.uniqueID = pywemo_device.serialnumber + light.uniqueID = pywemo_device.serial_number light.name = pywemo_device.name light.bridge = pywemo_device light.state = {"onoff": 0, "available": True} light.capabilities = ["onoff", "levelcontrol", "colortemperature"] - pywemo_device.Lights = {pywemo_device.serialnumber: light} + pywemo_device.Lights = {pywemo_device.serial_number: light} return light diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 49c6664f7bb..40e06f0a698 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -84,7 +84,7 @@ async def test_long_press_event( "name": device.wemo.name, "params": "testing_params", "type": EVENT_TYPE_LONG_PRESS, - "unique_id": device.wemo.serialnumber, + "unique_id": device.wemo.serial_number, } From 9f83e4b2defe4351c085da6d6fd860cb0aad531a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 18 Jun 2023 03:59:06 +0200 Subject: [PATCH 322/857] Make YouTube select lower quality thumbnails (#94652) * Make YouTube select lower quality thumbnails * Make YouTube select lower quality thumbnails * Make YouTube select lower quality thumbnails * Make YouTube select lower quality thumbnails * Add tests * Add tests * Add tests * Add tests * Add tests --- .../components/youtube/coordinator.py | 9 ++- homeassistant/components/youtube/sensor.py | 4 +- .../youtube/fixtures/thumbnail/default.json | 42 ++++++++++++++ .../youtube/fixtures/thumbnail/high.json | 52 +++++++++++++++++ .../youtube/fixtures/thumbnail/medium.json | 47 +++++++++++++++ .../youtube/fixtures/thumbnail/none.json | 36 ++++++++++++ .../youtube/fixtures/thumbnail/standard.json | 57 +++++++++++++++++++ tests/components/youtube/test_sensor.py | 36 ++++++++++++ 8 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 tests/components/youtube/fixtures/thumbnail/default.json create mode 100644 tests/components/youtube/fixtures/thumbnail/high.json create mode 100644 tests/components/youtube/fixtures/thumbnail/medium.json create mode 100644 tests/components/youtube/fixtures/thumbnail/none.json create mode 100644 tests/components/youtube/fixtures/thumbnail/standard.json diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 190e79e3393..d3430d8329c 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -85,9 +85,16 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"], ATTR_TITLE: video["snippet"]["title"], ATTR_DESCRIPTION: video["snippet"]["description"], - ATTR_THUMBNAIL: video["snippet"]["thumbnails"]["standard"]["url"], + ATTR_THUMBNAIL: self._get_thumbnail(video), ATTR_VIDEO_ID: video["contentDetails"]["videoId"], }, ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]), } return data + + def _get_thumbnail(self, video: dict[str, Any]) -> str | None: + thumbnails = video["snippet"]["thumbnails"] + for size in ("standard", "high", "medium", "default"): + if size in thumbnails: + return thumbnails[size]["url"] + return None diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index c605b960475..4560dcfda8c 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -30,7 +30,7 @@ class YouTubeMixin: """Mixin for required keys.""" value_fn: Callable[[Any], StateType] - entity_picture_fn: Callable[[Any], str] + entity_picture_fn: Callable[[Any], str | None] attributes_fn: Callable[[Any], dict[str, Any]] | None @@ -87,7 +87,7 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): return self.entity_description.value_fn(self.coordinator.data[self._channel_id]) @property - def entity_picture(self) -> str: + def entity_picture(self) -> str | None: """Return the value reported by the sensor.""" return self.entity_description.entity_picture_fn( self.coordinator.data[self._channel_id] diff --git a/tests/components/youtube/fixtures/thumbnail/default.json b/tests/components/youtube/fixtures/thumbnail/default.json new file mode 100644 index 00000000000..6b5d66d6501 --- /dev/null +++ b/tests/components/youtube/fixtures/thumbnail/default.json @@ -0,0 +1,42 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", + "snippet": { + "publishedAt": "2023-05-11T00:20:46Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home in less than 1 minute", + "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", + "width": 120, + "height": 90 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "wysukDrMdqU" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "wysukDrMdqU", + "videoPublishedAt": "2023-05-11T00:20:46Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 1 + } +} diff --git a/tests/components/youtube/fixtures/thumbnail/high.json b/tests/components/youtube/fixtures/thumbnail/high.json new file mode 100644 index 00000000000..430ad3715cc --- /dev/null +++ b/tests/components/youtube/fixtures/thumbnail/high.json @@ -0,0 +1,52 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", + "snippet": { + "publishedAt": "2023-05-11T00:20:46Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home in less than 1 minute", + "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", + "width": 480, + "height": 360 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "wysukDrMdqU" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "wysukDrMdqU", + "videoPublishedAt": "2023-05-11T00:20:46Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 1 + } +} diff --git a/tests/components/youtube/fixtures/thumbnail/medium.json b/tests/components/youtube/fixtures/thumbnail/medium.json new file mode 100644 index 00000000000..21cb09bd886 --- /dev/null +++ b/tests/components/youtube/fixtures/thumbnail/medium.json @@ -0,0 +1,47 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", + "snippet": { + "publishedAt": "2023-05-11T00:20:46Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home in less than 1 minute", + "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", + "width": 320, + "height": 180 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "wysukDrMdqU" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "wysukDrMdqU", + "videoPublishedAt": "2023-05-11T00:20:46Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 1 + } +} diff --git a/tests/components/youtube/fixtures/thumbnail/none.json b/tests/components/youtube/fixtures/thumbnail/none.json new file mode 100644 index 00000000000..d4c28730cab --- /dev/null +++ b/tests/components/youtube/fixtures/thumbnail/none.json @@ -0,0 +1,36 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", + "snippet": { + "publishedAt": "2023-05-11T00:20:46Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home in less than 1 minute", + "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", + "thumbnails": {}, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "wysukDrMdqU" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "wysukDrMdqU", + "videoPublishedAt": "2023-05-11T00:20:46Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 1 + } +} diff --git a/tests/components/youtube/fixtures/thumbnail/standard.json b/tests/components/youtube/fixtures/thumbnail/standard.json new file mode 100644 index 00000000000..bdbedfcf4c9 --- /dev/null +++ b/tests/components/youtube/fixtures/thumbnail/standard.json @@ -0,0 +1,57 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", + "snippet": { + "publishedAt": "2023-05-11T00:20:46Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home in less than 1 minute", + "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg", + "width": 640, + "height": 480 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "wysukDrMdqU" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "wysukDrMdqU", + "videoPublishedAt": "2023-05-11T00:20:46Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 1 + } +} diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index 3462e291af8..6bd99399952 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch from google.auth.exceptions import RefreshError +import pytest from homeassistant import config_entries from homeassistant.components.youtube import DOMAIN @@ -87,3 +88,38 @@ async def test_sensor_reauth_trigger( assert flow["step_id"] == "reauth_confirm" assert flow["handler"] == DOMAIN assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + + +@pytest.mark.parametrize( + ("fixture", "url", "has_entity_picture"), + [ + ("standard", "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg", True), + ("high", "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", True), + ("medium", "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", True), + ("default", "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", True), + ("none", None, False), + ], +) +async def test_thumbnail( + hass: HomeAssistant, + setup_integration: ComponentSetup, + fixture: str, + url: str | None, + has_entity_picture: bool, +) -> None: + """Test if right thumbnail is selected.""" + await setup_integration() + + with patch( + "homeassistant.components.youtube.api.build", + return_value=MockService( + playlist_items_fixture=f"youtube/thumbnail/{fixture}.json" + ), + ): + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert ("entity_picture" in state.attributes) is has_entity_picture + assert state.attributes.get("entity_picture") == url From 52f49fc32d4c91c19df86ee158817f561846567c Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sun, 18 Jun 2023 09:19:31 +0200 Subject: [PATCH 323/857] bmw_conected_drive: Allow WASHING_FLUID in condition based service (#94762) Allow WASHING_FLUID --- homeassistant/components/bmw_connected_drive/binary_sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 640f4e3653b..6fd5f3e7693 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -37,6 +37,7 @@ ALLOWED_CONDITION_BASED_SERVICE_KEYS = { "TIRE_WEAR_REAR", "VEHICLE_CHECK", "VEHICLE_TUV", + "WASHING_FLUID", } LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set() From ee7f44b3da30ab7e2767292ffa6c4e531b05a69c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 18 Jun 2023 13:13:21 +0200 Subject: [PATCH 324/857] Bump reolink-aio to 0.7.1 (#94761) --- homeassistant/components/reolink/host.py | 31 ++++++++++++------- .../components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/conftest.py | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 2e61c131490..ec4ca304d49 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -9,6 +9,7 @@ from typing import Any import aiohttp from aiohttp.web import Request from reolink_aio.api import Host +from reolink_aio.enums import SubType from reolink_aio.exceptions import ReolinkError, SubscriptionError from homeassistant.components import webhook @@ -256,7 +257,7 @@ class ReolinkHost: if self.webhook_id is None: self.register_webhook() - if self._api.subscribed: + if self._api.subscribed(SubType.push): _LOGGER.debug( "Host %s: is already subscribed to webhook %s", self._api.host, @@ -275,7 +276,7 @@ class ReolinkHost: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" try: - await self._renew() + await self._renew(SubType.push) except SubscriptionError as err: if not self._lost_subscription: self._lost_subscription = True @@ -287,22 +288,24 @@ class ReolinkHost: else: self._lost_subscription = False - async def _renew(self) -> None: + async def _renew(self, sub_type: SubType) -> None: """Execute the renew of the subscription.""" - if not self._api.subscribed: + if not self._api.subscribed(sub_type): _LOGGER.debug( - "Host %s: requested to renew a non-existing Reolink subscription, " + "Host %s: requested to renew a non-existing Reolink %s subscription, " "trying to subscribe from scratch", self._api.host, + sub_type, ) await self.subscribe() return - timer = self._api.renewtimer + timer = self._api.renewtimer(sub_type) _LOGGER.debug( - "Host %s:%s should renew subscription in: %i seconds", + "Host %s:%s should renew %s subscription in: %i seconds", self._api.host, self._api.port, + sub_type, timer, ) if timer > SUBSCRIPTION_RENEW_THRESHOLD: @@ -310,25 +313,29 @@ class ReolinkHost: if timer > 0: try: - await self._api.renew() + await self._api.renew(sub_type) except SubscriptionError as err: _LOGGER.debug( - "Host %s: error renewing Reolink subscription, " + "Host %s: error renewing Reolink %s subscription, " "trying to subscribe again: %s", self._api.host, + sub_type, err, ) else: _LOGGER.debug( - "Host %s successfully renewed Reolink subscription", self._api.host + "Host %s successfully renewed Reolink %s subscription", + self._api.host, + sub_type, ) return - await self._api.subscribe(self._webhook_url) + await self._api.subscribe(self._webhook_url, sub_type) _LOGGER.debug( - "Host %s: Reolink re-subscription successful after it was expired", + "Host %s: Reolink %s re-subscription successful after it was expired", self._api.host, + sub_type, ) def register_webhook(self) -> None: diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 46aee506f9c..69b3d5db6f7 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.6.0"] + "requirements": ["reolink-aio==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 564b21d22ca..12a4d67381a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2268,7 +2268,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.6.0 +reolink-aio==0.7.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49bd3a91909..169e9fbfc66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1655,7 +1655,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.6.0 +reolink-aio==0.7.1 # homeassistant.components.rflink rflink==0.0.65 diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d36aea905f7..1e6f9aa4902 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -59,7 +59,7 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None host_mock.user_level = "admin" host_mock.sw_version_update_required = False host_mock.timeout = 60 - host_mock.renewtimer = 600 + host_mock.renewtimer.return_value = 600 yield host_mock From 5834d700375759b760066cdd481575b42a62bc0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jun 2023 09:33:42 -0500 Subject: [PATCH 325/857] Bump zeroconf to 0.68.0 (#94786) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 1c3835f109e..37d08c08f1a 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.66.0"] + "requirements": ["zeroconf==0.68.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a2881676f5c..17d16a75318 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.66.0 +zeroconf==0.68.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 12a4d67381a..d9c2ff0a299 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2736,7 +2736,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.66.0 +zeroconf==0.68.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 169e9fbfc66..5d32d1acb9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.66.0 +zeroconf==0.68.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 13a217ad895f6173efd660aeff1d4c76bd24065f Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sun, 18 Jun 2023 22:06:09 +0200 Subject: [PATCH 326/857] Bump bthome to 2.12.0 (#94822) --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 27 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_sensor.py | 51 +++++++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index ef3d9bc002d..91f4940a4e5 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.11.3"] + "requirements": ["bthome-ble==2.12.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index f8693c5fb34..fc8673e801b 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -47,6 +47,15 @@ from .coordinator import ( from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { + # Acceleration (m/s²) + ( + BTHomeSensorDeviceClass.ACCELERATION, + Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.ACCELERATION}_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + ), # Battery (percent) (BTHomeSensorDeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.BATTERY}_{Units.PERCENTAGE}", @@ -131,6 +140,15 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, ), + # Gyroscope (°/s) + ( + BTHomeSensorDeviceClass.GYROSCOPE, + Units.GYROSCOPE_DEGREES_PER_SECOND, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.GYROSCOPE}_{Units.GYROSCOPE_DEGREES_PER_SECOND}", + native_unit_of_measurement=Units.GYROSCOPE_DEGREES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + ), # Humidity in (percent) (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}", @@ -242,6 +260,15 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + # Timestamp (datetime object) + ( + BTHomeSensorDeviceClass.TIMESTAMP, + None, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.TIMESTAMP}", + device_class=SensorDeviceClass.TIMESTAMP, + state_class=SensorStateClass.MEASUREMENT, + ), # UV index (-) ( BTHomeSensorDeviceClass.UV_INDEX, diff --git a/requirements_all.txt b/requirements_all.txt index d9c2ff0a299..49fb73e3b52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -568,7 +568,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.11.3 +bthome-ble==2.12.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d32d1acb9e..c0ae8d72dd5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,7 +472,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.11.3 +bthome-ble==2.12.0 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 7aafe7ba7a9..4450bfcc936 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -858,6 +858,57 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x50\x5D\x39\x61\x64", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_timestamp", + "friendly_name": "Test Device 18B2 Timestamp", + "unit_of_measurement": "s", + "state_class": "measurement", + "expected_state": "2023-05-14T19:41:17+00:00", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x51\x87\x56", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_acceleration", + "friendly_name": "Test Device 18B2 Acceleration", + "unit_of_measurement": "m/s²", + "state_class": "measurement", + "expected_state": "22.151", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x52\x87\x56", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_gyroscope", + "friendly_name": "Test Device 18B2 Gyroscope", + "unit_of_measurement": "°/s", + "state_class": "measurement", + "expected_state": "22.151", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( From 15ddf69c6a04f248d5f7a6d8e3cd3610e23f14d5 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 18 Jun 2023 22:10:29 +0000 Subject: [PATCH 327/857] Bump Shelly backend library to version 5.4.0 (#94829) Bump aioshelly to version 5.4.0 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 39a1427346d..6031b2dcc82 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==5.3.2"], + "requirements": ["aioshelly==5.4.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 49fb73e3b52..0b4565f18c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -342,7 +342,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.3.2 +aioshelly==5.4.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0ae8d72dd5..e24fb09d1e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -314,7 +314,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.3.2 +aioshelly==5.4.0 # homeassistant.components.skybell aioskybell==22.7.0 From 24add59d1549bbfe6a87c8d4c648f855f12f169c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Jun 2023 00:39:03 -0500 Subject: [PATCH 328/857] Bump zeroconf to 0.69.0 (#94828) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.68.0...0.69.0 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 37d08c08f1a..9134a92c799 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.68.0"] + "requirements": ["zeroconf==0.69.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 17d16a75318..7802a76f7a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.68.0 +zeroconf==0.69.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 0b4565f18c6..a68df43a1e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2736,7 +2736,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.68.0 +zeroconf==0.69.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e24fb09d1e4..84841def92e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.68.0 +zeroconf==0.69.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From e6a7ff97c8df370ab2441575a89eb07e872c7ddf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 11:04:59 +0200 Subject: [PATCH 329/857] Explicitly opt-in to device name in the cast integration (#94847) --- homeassistant/components/cast/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 3031eb8365b..ee3834e4edd 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -267,6 +267,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): """Representation of a Cast device on the network.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_media_image_remotely_accessible = True _mz_only = False From 76a5e6d7ce9e7691a7d603dbfd83c8e38d9cbfea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 11:47:29 +0200 Subject: [PATCH 330/857] Explicitly opt-in to device name in the cpuspeed integration (#94844) --- homeassistant/components/cpuspeed/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index c71de53ebbe..7eb3cfab753 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -35,6 +35,7 @@ class CPUSpeedSensor(SensorEntity): _attr_device_class = SensorDeviceClass.FREQUENCY _attr_icon = "mdi:pulse" _attr_has_entity_name = True + _attr_name = None _attr_native_unit_of_measurement = UnitOfFrequency.GIGAHERTZ def __init__(self, entry: ConfigEntry) -> None: From c7d636a371e3d513d759a9fadc80053213c93ed0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 11:47:44 +0200 Subject: [PATCH 331/857] Explicitly opt-in to device name in the season integration (#94845) --- homeassistant/components/season/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index 27a46943bb3..4d78e60db0f 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -94,6 +94,7 @@ class SeasonSensorEntity(SensorEntity): _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True + _attr_name = None _attr_options = ["spring", "summer", "autumn", "winter"] _attr_translation_key = "season" From 17797c04c38458451aea43e268dd1954bc431e99 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 11:47:54 +0200 Subject: [PATCH 332/857] Explicitly opt-in to device name in the uptime integration (#94846) --- homeassistant/components/uptime/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index f3b215356e5..56d570110b0 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -26,6 +26,7 @@ class UptimeSensor(SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__(self, entry: ConfigEntry) -> None: From a7f3bde3ac2632f3bc10c2ea0dabe15fcfa1a7c4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 19 Jun 2023 11:51:04 +0200 Subject: [PATCH 333/857] Add Twitch codeowner (#94851) --- CODEOWNERS | 2 ++ homeassistant/components/twitch/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 0cc09743873..dfa2d0d045a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1300,6 +1300,8 @@ build.json @home-assistant/supervisor /tests/components/twentemilieu/ @frenck /homeassistant/components/twinkly/ @dr1rrb @Robbie1221 /tests/components/twinkly/ @dr1rrb @Robbie1221 +/homeassistant/components/twitch/ @joostlek +/tests/components/twitch/ @joostlek /homeassistant/components/ukraine_alarm/ @PaulAnnekov /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index c11be26c45a..5613360c594 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -1,7 +1,7 @@ { "domain": "twitch", "name": "Twitch", - "codeowners": [], + "codeowners": ["@joostlek"], "documentation": "https://www.home-assistant.io/integrations/twitch", "iot_class": "cloud_polling", "loggers": ["twitch"], From 80d4f90e70ff200abed716f45d57ec4de33601da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Jun 2023 04:51:39 -0500 Subject: [PATCH 334/857] Add missing abort string to apple_tv (#94818) --- homeassistant/components/apple_tv/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 1420c0ffefc..e5948a54a8d 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -49,6 +49,7 @@ }, "abort": { "ipv6_not_supported": "IPv6 is not supported.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "device_did_not_pair": "No attempt to finish pairing process was made from the device.", From 546139e491ee5fe19b716416562ba62fa3d2fb9f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 19 Jun 2023 10:04:25 +0000 Subject: [PATCH 335/857] Return `None` as Accuweather weather entity name (#94803) --- homeassistant/components/accuweather/weather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 6107285e376..f801f2a5e46 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -50,6 +50,7 @@ class AccuWeatherEntity( """Define an AccuWeather entity.""" _attr_has_entity_name = True + _attr_name = None def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None: """Initialize.""" From c7e460ccab26e9389fde962ae6bc323b2b4eb20b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 19 Jun 2023 10:04:50 +0000 Subject: [PATCH 336/857] Return `None` as BraviaTV media_player/remote entity name (#94804) --- homeassistant/components/braviatv/media_player.py | 1 + homeassistant/components/braviatv/remote.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index ff5691f9aed..cfa388fcce7 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -40,6 +40,7 @@ async def async_setup_entry( class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): """Representation of a Bravia TV Media Player.""" + _attr_name = None _attr_assumed_state = True _attr_device_class = MediaPlayerDeviceClass.TV _attr_supported_features = ( diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index f45b2d74004..f9e3f464dcb 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -30,6 +30,8 @@ async def async_setup_entry( class BraviaTVRemote(BraviaTVEntity, RemoteEntity): """Representation of a Bravia TV Remote.""" + _attr_name = None + @property def is_on(self) -> bool: """Return true if device is on.""" From a027a015353931c1657f942e91846fcf509f672f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Jun 2023 06:10:39 -0500 Subject: [PATCH 337/857] Log a traceback when importing a component fails (#94778) `2023-06-17 12:44:37.961 ERROR (MainThread) [homeassistant.setup] Setup failed for switchbot: Unable to import component: cannot import name DEFAULT_CIPHERS from urllib3.util.ssl_ (/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/urllib3/util/ssl_.py)` is not very helpful as it does not show which module tried to import. adding a traceback makes it more obvious, and since ImportError is usually not something the user can easily solve, it makes issue reports much more helpful ``` DEFAULT_CIPHERS from urllib3.util.ssl_ (/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/urllib3/util/ssl_.py) Traceback (most recent call last): File "/Users/bdraco/home-assistant/homeassistant/setup.py", line 213, in _async_setup_component component = integration.get_component() ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/loader.py", line 813, in get_component ComponentProtocol, importlib.import_module(self.pkg_path) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/opt/homebrew/Cellar/python@3.11/3.11.3/Frameworks/Python.framework/Versions/3.11/lib/python3.11/importlib/__init__.py", line 126, in import_module return _bootstrap._gcd_import(name[level:], package, level) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "", line 1206, in _gcd_import File "", line 1178, in _find_and_load File "", line 1149, in _find_and_load_unlocked File "", line 690, in _load_unlocked File "", line 940, in exec_module File "", line 241, in _call_with_frames_removed File "/Users/bdraco/home-assistant/homeassistant/components/switchbot/__init__.py", line 5, in import switchbot File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/switchbot/__init__.py", line 22, in from .devices.lock import SwitchbotLock File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/switchbot/devices/lock.py", line 12, in import boto3 File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/boto3/__init__.py", line 16, in from boto3.session import Session File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/boto3/session.py", line 17, in import botocore.session File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/botocore/session.py", line 29, in import botocore.credentials File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/botocore/credentials.py", line 34, in from botocore.config import Config File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/botocore/config.py", line 16, in from botocore.endpoint import DEFAULT_TIMEOUT, MAX_POOL_CONNECTIONS File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/botocore/endpoint.py", line 22, in from botocore.awsrequest import create_request_object File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/botocore/awsrequest.py", line 24, in import botocore.utils File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/botocore/utils.py", line 32, in import botocore.httpsession File "/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/botocore/httpsession.py", line 10, in from urllib3.util.ssl_ import ( ImportError: cannot import name DEFAULT_CIPHERS from urllib3.util.ssl_ (/Users/bdraco/home-assistant/venv/lib/python3.11/site-packages/urllib3/util/ssl_.py) ``` --- homeassistant/setup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 2adc5fe1024..bf405d5deda 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -174,7 +174,7 @@ async def _async_setup_component( """ integration: loader.Integration | None = None - def log_error(msg: str) -> None: + def log_error(msg: str, exc_info: Exception | None = None) -> None: """Log helper.""" if integration is None: custom = "" @@ -182,7 +182,9 @@ async def _async_setup_component( else: custom = "" if integration.is_built_in else "custom integration " link = integration.documentation - _LOGGER.error("Setup failed for %s%s: %s", custom, domain, msg) + _LOGGER.error( + "Setup failed for %s%s: %s", custom, domain, msg, exc_info=exc_info + ) async_notify_setup_error(hass, domain, link) try: @@ -212,7 +214,7 @@ async def _async_setup_component( try: component = integration.get_component() except ImportError as err: - log_error(f"Unable to import component: {err}") + log_error(f"Unable to import component: {err}", err) return False processed_config = await conf_util.async_process_component_config( From d3bf52c136f4803d09a033a6d7f551042b054225 Mon Sep 17 00:00:00 2001 From: Mike Heath Date: Mon, 19 Jun 2023 05:12:04 -0600 Subject: [PATCH 338/857] Register Fully Kiosk services regardless of setup result (#88647) * Register services at integration level If HA is unable to connect to Fully Kiosk, the services don't get registered. This can cause repair to create notifications saying that the 'fully_kiosk.load_url' service is unknown. Fixes #85444 * Validate config entry is loaded * Refactor service invocation Raises `HomeAssistantError` when the user provides an device id that is not in the device registry or a device that is not a Fully Kiosk device. If the device's config entry is not loaded, a warning is logged. * Update homeassistant/components/fully_kiosk/services.py Co-authored-by: Martin Hjelmare * Assert HomeAssistantError when integration unloaded * Remove unused import * Set CONFIG_SCHEMA * Update homeassistant/components/fully_kiosk/__init__.py Co-authored-by: Martin Hjelmare * Add test for non fkb devices targets in service calls * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare --- .../components/fully_kiosk/__init__.py | 14 ++- .../components/fully_kiosk/services.py | 70 ++++++------ tests/components/fully_kiosk/test_services.py | 102 +++++++++++++++++- 3 files changed, 141 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index dd1cc70c9f4..8b350433858 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -2,6 +2,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import FullyKioskDataUpdateCoordinator @@ -16,6 +18,16 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Fully Kiosk Browser.""" + + await async_setup_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Fully Kiosk Browser from a config entry.""" @@ -28,8 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) coordinator.async_update_listeners() - await async_setup_services(hass) - return True diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index 3fca9228735..b3c5886187a 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -1,14 +1,12 @@ """Services for the Fully Kiosk Browser integration.""" from __future__ import annotations -from collections.abc import Callable -from typing import Any - -from fullykiosk import FullyKiosk import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -16,59 +14,53 @@ from .const import ( ATTR_APPLICATION, ATTR_URL, DOMAIN, - LOGGER, SERVICE_LOAD_URL, SERVICE_START_APPLICATION, ) +from .coordinator import FullyKioskDataUpdateCoordinator async def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Fully Kiosk Browser integration.""" - async def execute_service( - call: ServiceCall, - fully_method: Callable, - *args: list[str], - **kwargs: dict[str, Any], - ) -> None: - """Execute a Fully service call. - - :param call: {ServiceCall} HA service call. - :param fully_method: {Callable} A method of the FullyKiosk class. - :param args: Arguments for fully_method. - :param kwargs: Key-word arguments for fully_method. - :return: None - """ - LOGGER.debug( - "Calling Fully service %s with args: %s, %s", ServiceCall, args, kwargs - ) + async def collect_coordinators( + device_ids: list[str], + ) -> list[FullyKioskDataUpdateCoordinator]: + config_entries = list[ConfigEntry]() registry = dr.async_get(hass) - for target in call.data[ATTR_DEVICE_ID]: + for target in device_ids: device = registry.async_get(target) if device: - for key in device.config_entries: - entry = hass.config_entries.async_get_entry(key) - if not entry: - continue - if entry.domain != DOMAIN: - continue - coordinator = hass.data[DOMAIN][key] - # fully_method(coordinator.fully, *args, **kwargs) would make - # test_services.py fail. - await getattr(coordinator.fully, fully_method.__name__)( - *args, **kwargs + device_entries = list[ConfigEntry]() + for entry_id in device.config_entries: + entry = hass.config_entries.async_get_entry(entry_id) + if entry and entry.domain == DOMAIN: + device_entries.append(entry) + if not device_entries: + raise HomeAssistantError( + f"Device '{target}' is not a {DOMAIN} device" ) - break + config_entries.extend(device_entries) + else: + raise HomeAssistantError( + f"Device '{target}' not found in device registry" + ) + coordinators = list[FullyKioskDataUpdateCoordinator]() + for config_entry in config_entries: + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError(f"{config_entry.title} is not loaded") + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) + return coordinators async def async_load_url(call: ServiceCall) -> None: """Load a URL on the Fully Kiosk Browser.""" - await execute_service(call, FullyKiosk.loadUrl, call.data[ATTR_URL]) + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.fully.loadUrl(call.data[ATTR_URL]) async def async_start_app(call: ServiceCall) -> None: """Start an app on the device.""" - await execute_service( - call, FullyKiosk.startApplication, call.data[ATTR_APPLICATION] - ) + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.fully.startApplication(call.data[ATTR_APPLICATION]) # Register all the above services service_mapping = [ diff --git a/tests/components/fully_kiosk/test_services.py b/tests/components/fully_kiosk/test_services.py index 386bc542e3c..504aa4893e6 100644 --- a/tests/components/fully_kiosk/test_services.py +++ b/tests/components/fully_kiosk/test_services.py @@ -1,6 +1,8 @@ """Test Fully Kiosk Browser services.""" from unittest.mock import MagicMock +import pytest + from homeassistant.components.fully_kiosk.const import ( ATTR_APPLICATION, ATTR_URL, @@ -10,6 +12,7 @@ from homeassistant.components.fully_kiosk.const import ( ) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -28,20 +31,111 @@ async def test_services( assert device_entry + url = "https://example.com" await hass.services.async_call( DOMAIN, SERVICE_LOAD_URL, - {ATTR_DEVICE_ID: [device_entry.id], ATTR_URL: "https://example.com"}, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_URL: url}, blocking=True, ) - assert len(mock_fully_kiosk.loadUrl.mock_calls) == 1 + mock_fully_kiosk.loadUrl.assert_called_once_with(url) + app = "de.ozerov.fully" await hass.services.async_call( DOMAIN, SERVICE_START_APPLICATION, - {ATTR_DEVICE_ID: [device_entry.id], ATTR_APPLICATION: "de.ozerov.fully"}, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_APPLICATION: app}, blocking=True, ) - assert len(mock_fully_kiosk.startApplication.mock_calls) == 1 + mock_fully_kiosk.startApplication.assert_called_once_with(app) + + +async def test_service_unloaded_entry( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test service not called when config entry unloaded.""" + await init_integration.async_unload(hass) + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "abcdef-123456")} + ) + + assert device_entry + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_LOAD_URL, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_URL: "https://nabucasa.com"}, + blocking=True, + ) + assert "Test device is not loaded" in str(excinfo) + mock_fully_kiosk.loadUrl.assert_not_called() + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_START_APPLICATION, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_APPLICATION: "de.ozerov.fully"}, + blocking=True, + ) + assert "Test device is not loaded" in str(excinfo) + mock_fully_kiosk.startApplication.assert_not_called() + + +async def test_service_bad_device_id( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test Fully Kiosk Browser service invocation with bad device id.""" + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_LOAD_URL, + {ATTR_DEVICE_ID: ["bad-device_id"], ATTR_URL: "https://example.com"}, + blocking=True, + ) + + assert "Device 'bad-device_id' not found in device registry" in str(excinfo) + + +async def test_service_called_with_non_fkb_target_devices( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Services raise exception when no valid devices provided.""" + device_registry = dr.async_get(hass) + + other_domain = "NotFullyKiosk" + other_config_id = "555" + await hass.config_entries.async_add( + MockConfigEntry( + title="Not Fully Kiosk", domain=other_domain, entry_id=other_config_id + ) + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=other_config_id, + identifiers={ + (other_domain, 1), + }, + ) + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + DOMAIN, + SERVICE_LOAD_URL, + { + ATTR_DEVICE_ID: [device_entry.id], + ATTR_URL: "https://example.com", + }, + blocking=True, + ) + + assert f"Device '{device_entry.id}' is not a fully_kiosk device" in str(excinfo) From e49c2fde14abd6dccf69b2c259f2a71a4f89289f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 13:24:36 +0200 Subject: [PATCH 339/857] Add tests for kitchen_sink lock platform (#94723) --- homeassistant/components/kitchen_sink/lock.py | 14 ++- .../kitchen_sink/snapshots/test_lock.ambr | 49 ++++++++ tests/components/kitchen_sink/test_lock.py | 105 ++++++++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tests/components/kitchen_sink/snapshots/test_lock.ambr create mode 100644 tests/components/kitchen_sink/test_lock.py diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py index 343190acb63..b25941cf1a3 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNLOCKING +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -17,7 +17,7 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo sensors.""" + """Set up the Demo locks.""" async_add_entities( [ DemoLock( @@ -70,6 +70,8 @@ class DemoLock(LockEntity): self._attr_unique_id = unique_id self._attr_supported_features = features self._state = state + self._attr_is_locking = False + self._attr_is_unlocking = False @property def is_locked(self) -> bool: @@ -78,12 +80,18 @@ class DemoLock(LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" + self._attr_is_locking = True + self.async_write_ha_state() + self._attr_is_locking = False self._state = STATE_LOCKED self.async_write_ha_state() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - self._state = STATE_UNLOCKING + self._attr_is_unlocking = True + self.async_write_ha_state() + self._attr_is_unlocking = False + self._state = STATE_UNLOCKED self.async_write_ha_state() async def async_open(self, **kwargs: Any) -> None: diff --git a/tests/components/kitchen_sink/snapshots/test_lock.ambr b/tests/components/kitchen_sink/snapshots/test_lock.ambr new file mode 100644 index 00000000000..9303401bdd5 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_lock.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_states + set({ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Another basic lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.another_basic_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Another openable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.another_openable_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Basic lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.basic_lock', + 'last_changed': , + 'last_updated': , + 'state': 'locked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Openable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.openable_lock', + 'last_changed': , + 'last_updated': , + 'state': 'locked', + }), + }) +# --- diff --git a/tests/components/kitchen_sink/test_lock.py b/tests/components/kitchen_sink/test_lock.py new file mode 100644 index 00000000000..a74c9a19a23 --- /dev/null +++ b/tests/components/kitchen_sink/test_lock.py @@ -0,0 +1,105 @@ +"""The tests for the kitchen_sink lock platform.""" +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events, async_mock_service + +LOCKED_LOCK = "lock.basic_lock" +OPENABLE_LOCK = "lock.openable_lock" +UNLOCKED_LOCK = "lock.another_basic_lock" + + +@pytest.fixture +async def lock_only() -> None: + """Enable only the lock platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.LOCK], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, lock_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test the expected lock entities are added.""" + states = hass.states.async_all() + assert set(states) == snapshot + + +async def test_locking(hass: HomeAssistant) -> None: + """Test the locking of a lock.""" + state = hass.states.get(UNLOCKED_LOCK) + assert state.state == STATE_UNLOCKED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: UNLOCKED_LOCK}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == UNLOCKED_LOCK + assert state_changes[0].data["new_state"].state == STATE_LOCKING + + assert state_changes[1].data["entity_id"] == UNLOCKED_LOCK + assert state_changes[1].data["new_state"].state == STATE_LOCKED + + +async def test_unlocking(hass: HomeAssistant) -> None: + """Test the unlocking of a lock.""" + state = hass.states.get(LOCKED_LOCK) + assert state.state == STATE_LOCKED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: LOCKED_LOCK}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == LOCKED_LOCK + assert state_changes[0].data["new_state"].state == STATE_UNLOCKING + + assert state_changes[1].data["entity_id"] == LOCKED_LOCK + assert state_changes[1].data["new_state"].state == STATE_UNLOCKED + + +async def test_opening_mocked(hass: HomeAssistant) -> None: + """Test the opening of a lock.""" + calls = async_mock_service(hass, LOCK_DOMAIN, SERVICE_OPEN) + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True + ) + assert len(calls) == 1 + + +async def test_opening(hass: HomeAssistant) -> None: + """Test the opening of a lock.""" + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True + ) + state = hass.states.get(OPENABLE_LOCK) + assert state.state == STATE_UNLOCKED From 3ee63ba2c29a979213d09f0f0da5867b829d49df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 14:16:18 +0200 Subject: [PATCH 340/857] Add tests for kitchen_sink sensor platform (#94724) * Add tests for kitchen_sink sensor platform * Address review comments --- .../components/kitchen_sink/sensor.py | 13 +----- .../kitchen_sink/snapshots/test_sensor.ambr | 40 +++++++++++++++++++ tests/components/kitchen_sink/test_sensor.py | 33 +++++++++++++++ 3 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 tests/components/kitchen_sink/snapshots/test_sensor.ambr create mode 100644 tests/components/kitchen_sink/test_sensor.py diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 6692f53810b..6912c940482 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_BATTERY_LEVEL, UnitOfPower +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -31,7 +31,6 @@ async def async_setup_entry( None, SensorStateClass.MEASUREMENT, UnitOfPower.WATT, # Not a volume unit - None, ), DemoSensor( "statistics_issue_2", @@ -40,7 +39,6 @@ async def async_setup_entry( None, SensorStateClass.MEASUREMENT, "dogs", # Can't be converted to cats - None, ), DemoSensor( "statistics_issue_3", @@ -49,7 +47,6 @@ async def async_setup_entry( None, None, # Wrong state class UnitOfPower.WATT, - None, ), ] ) @@ -68,9 +65,6 @@ class DemoSensor(SensorEntity): device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit_of_measurement: str | None, - battery: StateType, - options: list[str] | None = None, - translation_key: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class @@ -79,13 +73,8 @@ class DemoSensor(SensorEntity): self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id - self._attr_options = options - self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, name=name, ) - - if battery: - self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery} diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..de3297b7fd8 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -0,0 +1,40 @@ +# serializer version: 1 +# name: test_states + set({ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issue 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.statistics_issue_1', + 'last_changed': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issue 2', + 'state_class': , + 'unit_of_measurement': 'dogs', + }), + 'context': , + 'entity_id': 'sensor.statistics_issue_2', + 'last_changed': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issue 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.statistics_issue_3', + 'last_changed': , + 'last_updated': , + 'state': '100', + }), + }) +# --- diff --git a/tests/components/kitchen_sink/test_sensor.py b/tests/components/kitchen_sink/test_sensor.py new file mode 100644 index 00000000000..8d3f611f15d --- /dev/null +++ b/tests/components/kitchen_sink/test_sensor.py @@ -0,0 +1,33 @@ +"""The tests for the kitchen_sink sensor platform.""" +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture +async def sensor_only() -> None: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.SENSOR], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, sensor_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test the expected sensor entities are added.""" + states = hass.states.async_all() + assert set(states) == snapshot From 43c4dec3edbea96d093dcfc7c71143e6edd443ea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 15:56:48 +0200 Subject: [PATCH 341/857] Explicitly opt-in to device name in the demo integration (#94647) --- homeassistant/components/demo/binary_sensor.py | 2 +- homeassistant/components/demo/button.py | 1 + homeassistant/components/demo/climate.py | 1 + homeassistant/components/demo/cover.py | 1 + homeassistant/components/demo/date.py | 1 + homeassistant/components/demo/datetime.py | 1 + homeassistant/components/demo/light.py | 1 + homeassistant/components/demo/number.py | 1 + homeassistant/components/demo/select.py | 1 + homeassistant/components/demo/sensor.py | 1 + homeassistant/components/demo/switch.py | 1 + homeassistant/components/demo/text.py | 2 +- homeassistant/components/demo/time.py | 1 + homeassistant/components/demo/update.py | 1 + 14 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index 9f808ae1f61..236d4bbb1b0 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -38,6 +38,7 @@ class DemoBinarySensor(BinarySensorEntity): """representation of a Demo binary sensor.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( @@ -49,7 +50,6 @@ class DemoBinarySensor(BinarySensorEntity): ) -> None: """Initialize the demo sensor.""" self._unique_id = unique_id - self._attr_name = None self._state = state self._attr_device_class = device_class self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index 70c255ad4b5..f7a653e1779 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -31,6 +31,7 @@ class DemoButton(ButtonEntity): """Representation of a demo button entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 9855bfc2695..340a4b306cb 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -92,6 +92,7 @@ class DemoClimate(ClimateEntity): """Representation of a demo climate device.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_translation_key = "ubercool" diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 3d611297c0b..42e30aa8336 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -58,6 +58,7 @@ class DemoCover(CoverEntity): """Representation of a demo cover.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py index 718fa3dc4a4..4129d0d392a 100644 --- a/homeassistant/components/demo/date.py +++ b/homeassistant/components/demo/date.py @@ -35,6 +35,7 @@ class DemoDate(DateEntity): """Representation of a Demo date entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py index 57d14be24b6..b769f9baba3 100644 --- a/homeassistant/components/demo/datetime.py +++ b/homeassistant/components/demo/datetime.py @@ -35,6 +35,7 @@ class DemoDateTime(DateTimeEntity): """Representation of a Demo date/time entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index 91fc49b7c7e..fbc35965dc4 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -96,6 +96,7 @@ class DemoLight(LightEntity): """Representation of a demo light.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 38bab325c92..719b1078b8c 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -79,6 +79,7 @@ class DemoNumber(NumberEntity): """Representation of a demo Number entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index 48ad4c6931b..6349b10040c 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -38,6 +38,7 @@ class DemoSelect(SelectEntity): """Representation of a demo select entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 81795540d1f..26689582fae 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -142,6 +142,7 @@ class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 080488642e7..49e06839be5 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -37,6 +37,7 @@ class DemoSwitch(SwitchEntity): """Representation of a demo switch.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index ff50e508354..7c243b73ea5 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -54,6 +54,7 @@ class DemoText(TextEntity): """Representation of a demo text entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( @@ -69,7 +70,6 @@ class DemoText(TextEntity): ) -> None: """Initialize the Demo text entity.""" self._attr_unique_id = unique_id - self._attr_name = None self._attr_native_value = native_value self._attr_icon = icon self._attr_mode = mode diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index d5e34779927..0384c0822f4 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -25,6 +25,7 @@ class DemoTime(TimeEntity): """Representation of a Demo time entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index f89f5a160e2..6373c485037 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -88,6 +88,7 @@ class DemoUpdate(UpdateEntity): """Representation of a demo update entity.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( From 5303bef83eaa23c65299b2c24d9390f85fb48365 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 17:03:48 +0200 Subject: [PATCH 342/857] Add image entity component (#90564) --- .core_files.yaml | 1 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/image/__init__.py | 211 ++++++++++++++++++ homeassistant/components/image/const.py | 6 + homeassistant/components/image/manifest.json | 9 + homeassistant/components/image/recorder.py | 10 + homeassistant/components/image/strings.json | 8 + .../components/kitchen_sink/__init__.py | 2 +- .../components/kitchen_sink/image.py | 66 ++++++ .../components/kitchen_sink/qr_code.png | Bin 0 -> 14425 bytes homeassistant/const.py | 1 + mypy.ini | 10 + tests/components/image/__init__.py | 1 + tests/components/image/conftest.py | 160 +++++++++++++ tests/components/image/test_init.py | 169 ++++++++++++++ tests/components/image/test_recorder.py | 40 ++++ tests/components/kitchen_sink/test_image.py | 60 +++++ 18 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/image/__init__.py create mode 100644 homeassistant/components/image/const.py create mode 100644 homeassistant/components/image/manifest.json create mode 100644 homeassistant/components/image/recorder.py create mode 100644 homeassistant/components/image/strings.json create mode 100644 homeassistant/components/kitchen_sink/image.py create mode 100644 homeassistant/components/kitchen_sink/qr_code.png create mode 100644 tests/components/image/__init__.py create mode 100644 tests/components/image/conftest.py create mode 100644 tests/components/image/test_init.py create mode 100644 tests/components/image/test_recorder.py create mode 100644 tests/components/kitchen_sink/test_image.py diff --git a/.core_files.yaml b/.core_files.yaml index 9af81c59934..b1870654be0 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -27,6 +27,7 @@ base_platforms: &base_platforms - homeassistant/components/fan/** - homeassistant/components/geo_location/** - homeassistant/components/humidifier/** + - homeassistant/components/image/** - homeassistant/components/image_processing/** - homeassistant/components/light/** - homeassistant/components/lock/** diff --git a/.strict-typing b/.strict-typing index 801827df6dc..39480601388 100644 --- a/.strict-typing +++ b/.strict-typing @@ -172,6 +172,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* diff --git a/CODEOWNERS b/CODEOWNERS index dfa2d0d045a..cf747b9b69c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -563,6 +563,8 @@ build.json @home-assistant/supervisor /tests/components/icloud/ @Quentame @nzapponi /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte +/homeassistant/components/image/ @home-assistant/core +/tests/components/image/ @home-assistant/core /homeassistant/components/image_processing/ @home-assistant/core /tests/components/image_processing/ @home-assistant/core /homeassistant/components/image_upload/ @home-assistant/core diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py new file mode 100644 index 00000000000..bff9e8cc4c6 --- /dev/null +++ b/homeassistant/components/image/__init__.py @@ -0,0 +1,211 @@ +"""The image integration.""" +from __future__ import annotations + +import asyncio +import collections +from contextlib import suppress +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from random import SystemRandom +from typing import Final, final + +from aiohttp import hdrs, web +import async_timeout + +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL: Final = timedelta(seconds=30) +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" + +DEFAULT_CONTENT_TYPE: Final = "image/jpeg" +ENTITY_IMAGE_URL: Final = "/api/image_proxy/{0}?token={1}" + +TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=5) +_RND: Final = SystemRandom() + + +@dataclass +class ImageEntityDescription(EntityDescription): + """A class that describes image entities.""" + + +@dataclass +class Image: + """Represent an image.""" + + content_type: str + content: bytes + + +async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: + """Fetch image from an image entity.""" + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + async with async_timeout.timeout(timeout): + if image_bytes := await image_entity.async_image(): + content_type = image_entity.content_type + image = Image(content_type, image_bytes) + return image + + raise HomeAssistantError("Unable to get image") + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the image component.""" + component = hass.data[DOMAIN] = EntityComponent[ImageEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + hass.http.register_view(ImageView(component)) + + await component.async_setup(config) + + @callback + def update_tokens(time: datetime) -> None: + """Update tokens of the entities.""" + for entity in component.entities: + entity.async_update_token() + entity.async_write_ha_state() + + unsub = async_track_time_interval( + hass, update_tokens, TOKEN_CHANGE_INTERVAL, name="Image update tokens" + ) + + @callback + def unsub_track_time_interval(_event: Event) -> None: + """Unsubscribe track time interval timer.""" + unsub() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[ImageEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[ImageEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class ImageEntity(Entity): + """The base class for image entities.""" + + # Entity Properties + _attr_content_type: str = DEFAULT_CONTENT_TYPE + _attr_image_last_updated: datetime | None = None + _attr_should_poll: bool = False # No need to poll image entities + _attr_state: None = None # State is determined by last_updated + + def __init__(self) -> None: + """Initialize an image entity.""" + self.access_tokens: collections.deque = collections.deque([], 2) + self.async_update_token() + + @property + def content_type(self) -> str: + """Image content type.""" + return self._attr_content_type + + @property + def entity_picture(self) -> str: + """Return a link to the image as entity picture.""" + if self._attr_entity_picture is not None: + return self._attr_entity_picture + return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) + + @property + def image_last_updated(self) -> datetime | None: + """The time when the image was last updated.""" + return self._attr_image_last_updated + + def image(self) -> bytes | None: + """Return bytes of image.""" + raise NotImplementedError() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return await self.hass.async_add_executor_job(self.image) + + @property + @final + def state(self) -> str | None: + """Return the state.""" + if self.image_last_updated is None: + return None + return self.image_last_updated.isoformat() + + @final + @property + def state_attributes(self) -> dict[str, str | None]: + """Return the state attributes.""" + return {"access_token": self.access_tokens[-1]} + + @callback + def async_update_token(self) -> None: + """Update the used token.""" + self.access_tokens.append(hex(_RND.getrandbits(256))[2:]) + + +class ImageView(HomeAssistantView): + """View to serve an image.""" + + name = "api:image:image" + requires_auth = False + url = "/api/image_proxy/{entity_id}" + + def __init__(self, component: EntityComponent[ImageEntity]) -> None: + """Initialize an image view.""" + self.component = component + + async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: + """Start a GET request.""" + if (image_entity := self.component.get_entity(entity_id)) is None: + raise web.HTTPNotFound() + + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") in image_entity.access_tokens + ) + + if not authenticated: + # Attempt with invalid bearer token, raise unauthorized + # so ban middleware can handle it. + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized() + # Invalid sigAuth or image entity access token + raise web.HTTPForbidden() + + return await self.handle(request, image_entity) + + async def handle( + self, request: web.Request, image_entity: ImageEntity + ) -> web.StreamResponse: + """Serve image.""" + try: + image = await _async_get_image(image_entity, IMAGE_TIMEOUT) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError() from ex + + return web.Response(body=image.content, content_type=image.content_type) diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py new file mode 100644 index 00000000000..d262bb460f7 --- /dev/null +++ b/homeassistant/components/image/const.py @@ -0,0 +1,6 @@ +"""Constants for the image integration.""" +from typing import Final + +DOMAIN: Final = "image" + +IMAGE_TIMEOUT: Final = 10 diff --git a/homeassistant/components/image/manifest.json b/homeassistant/components/image/manifest.json new file mode 100644 index 00000000000..0335710a30b --- /dev/null +++ b/homeassistant/components/image/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "image", + "name": "Image", + "codeowners": ["@home-assistant/core"], + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/image", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/image/recorder.py b/homeassistant/components/image/recorder.py new file mode 100644 index 00000000000..5c141220881 --- /dev/null +++ b/homeassistant/components/image/recorder.py @@ -0,0 +1,10 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude access_token and entity_picture from being recorded in the database.""" + return {"access_token", "entity_picture"} diff --git a/homeassistant/components/image/strings.json b/homeassistant/components/image/strings.json new file mode 100644 index 00000000000..ea7ecd16956 --- /dev/null +++ b/homeassistant/components/image/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Image", + "entity_component": { + "_": { + "name": "[%key:component::image::title%]" + } + } +} diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 39143c8b84b..7857e6b3149 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -26,7 +26,7 @@ import homeassistant.util.dt as dt_util DOMAIN = "kitchen_sink" -COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK] +COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK, Platform.IMAGE] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py new file mode 100644 index 00000000000..7719b188c38 --- /dev/null +++ b/homeassistant/components/kitchen_sink/image.py @@ -0,0 +1,66 @@ +"""Demo image platform.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up image entities.""" + async_add_entities( + [ + DemoImage( + "kitchen_sink_image_001", + "QR Code", + "image/png", + "qr_code.png", + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Everything but the Kitchen Sink config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoImage(ImageEntity): + """Representation of an image entity.""" + + def __init__( + self, + unique_id: str, + name: str, + content_type: str, + image: str, + ) -> None: + """Initialize the image entity.""" + super().__init__() + self._attr_content_type = content_type + self._attr_name = name + self._attr_unique_id = unique_id + self._image_filename = image + + async def async_added_to_hass(self): + """Set the update time.""" + self._attr_image_last_updated = dt_util.utcnow() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + image_path = Path(__file__).parent / self._image_filename + return await self.hass.async_add_executor_job(image_path.read_bytes) diff --git a/homeassistant/components/kitchen_sink/qr_code.png b/homeassistant/components/kitchen_sink/qr_code.png new file mode 100644 index 0000000000000000000000000000000000000000..d8350728b633a9c70fa87edce1fb19a7d0ac17ff GIT binary patch literal 14425 zcmeAS@N?(olHy`uVBq!ia0y~yU}R@tVC3XrV_;yIx$x0-1_lPk;vjb?hIQv;UNSH+ zu%tWsIx;Y9?C1WI$jZRLz**oCSngteJO6#E-t6hd&+n+@J8^xkoL5=&e9!kgb?V>m%`^_yTN2o$z;Qfq z-aoD;g_goMCm-(r|9AcMfUuokE_r93Qqlh%7xUNH*!XJx{=dtjwq!W!K3o&OKkn+9 z$l%b`VZBj@em-oM54pW9*V9<@)&0io``wckd=`4SZTi=0w}Tc9mc`Ewcuetpd0`>* zTh;T@`Fjq2`>bkdIrG_*mp3=7-#$NmqO$v?IX0Y}oH_dsO*l1Gd-L0UENiVbH9K>^ zFRlFiEN8d=WOe^#du=}Y%rt7fs}?`YH2c`L-E~ZnwQE(xMy0JS#ZOKMipt8aU9@P?3e`DpZ*N~cV}`^fpG%i6g*3DAdKll_ znB2a-M!Mthis0pZnQG;COQ-MpdM(=XwES$d+#uuhb1G+3uC5Bz($wrUnf&wf^VLVY z#V4l;mE>%Av$Od5q_j&Llijx#JaoF`$A}F4=2^+#k9YQ#IlHxz!qTVDNBB`4?hHs9Eqk4KlxG|9epM)LWCU3uH` z?yh=vc6NHN>y6#z>sJLXX3JFj{r!Eo`P~xXOtt)dKbNfvSjd#A7P>m@Ymwazj;`VZ zh3jK?n`J&sxGh)tMDS8qTi{~1i&IX%-G0CB(IXi)Ua2GBv~_N7&zE0&a>6X4%h%d4at99{l1O|z$ob0S@Zi7(wcs;XJ%$DnVE1`JhntI^WlN( z`}6KvO`S5uMfc%>^LABVGD6qISY~mu7Ye=K`&}+m?L_j$b+Oi$D)jdMDVk+;aO2;n z)8jpiU*6rlUCO@BCR1&ycK9+);T&s?_0b3m6c0o z&Z&B}GVAfN-X${=ZhQ_6k9C#mKdZ_nzDnEFrj2!R$X?zZjm;a@N-tTmM5eSlQo=Z` zXV>$2)gH#3-QB^Gl9EfF^6#)PoO8D|`?m7k?|XI_d22bh^Xb}zr)5r42!HnC;^Lci z`?loYpZDy^MRT*;=ch~O?NHoO^RsC6mDS<>Z=auDjF6H-+psiVPC+zcFlnV7{(j|Lkn@i^zet@{T4&KNsO(lz7caeh zZrQA=r&Gf{j3q6Lmdvs&R?AfD5Zw84*=!Hvmv?qgtt9CJgZ|W9=7J#$H&$GE!`HMw{c;2`R$(%tPI z+jU>um{N)-J05=|qs031Zfwo(x7*`9i=`d+CqG%N>uYiBsj}Z;*X?!WV{NohqNkBV2ne42Y8y#DRh>yxJ03p~HDIoK*#}+FDQU z^zZNP#`UzE%`SX!fN{Ayy#MlRY| zyUl|ANXDKIhq#x_Tob?lo}_n!1>+H;8x8t(CnhK^5!E|AO*gpwP9b}yTF3f=UO$(0 z?f?G%o_y@_sn3N^r-oNOKj(I#^7zJsJGVC0aBt(e-4Q5ZmLrj=)=}u-D=57qEruoc z_qN>IA$NC`dK#ZRcP`9#wwdbLls7jvvi244c)#!Wo(Ohn$J=6Gsu#QUhRk~%W|DEC zA@}>z;^*hKsLa!g-L>Iwjih~@&DoQ}PmJAUn-?Yh`0((JRc+?QMXa~qojx&9Inz^p zo}_WwhTBiPot&6%zw-{b+oce-HS4I}_38fhf2Tatso9d4c>SffmlxOBlZ$z|Z=avO zz@q)-<>i+ramjZU3$QG_A%NC^M5;|>x{DqB7{mGg@2&n`WtS_yZVsFI{TgH6j|c8o zKc8DZDecj#`Sa$zJ8E@fRjl%T=bD;5rEmV<+L}H0-wRdmX)Ctn-ky~9>74a@i`AaW zd+L=>>pWxfxBUHo%P#p8#0C6jJk+?0ci#D1htlWQE=x*I_Iw<6b=caXb@d+~9ewp?^Le$iETx98uB>#Pb@KiG z|Ldg8ay({n7A5ZZWT~+4h}iRn?7TleJ}#N5@Q!1rRSrw8^dp&FWpBM^bzZ+-JS(f| zkxbO~Jm2DJHlHS)Kd5@EV8h#eEKiFa?sBc0ukTo@53Z7oF1Gc*ogB`&D%!B@O$4}X zIJZ#ed9jDDZ|d(KO`lI~{VgQ&{K8^){;0{a1l#7*&L>`6RUH3Hd{(09`T=3NU#F;Z8Z*Ol8-9Auic=N}G_uXUB`*Y0<_u#NTU z%Zg7Y)kD9&y6S1{>ErVyDIzDP@TllDi}uXR%Ph0g-rU^mesxXcWRuAYviVHyV}1rt znmjpl-s>=*ITjZ`Ono-((wy^(EAm?RS==m(Z~A2EBJ^CqCGh&%7x(wu-##y1%XoPX ztFoS6U+g;lb-P55`nL1Q-pKu?I;n=wRra~UPZ<_R4FhnV#@Z;Gw!xx3Zf{j(uS`3i zY|%A)2S0}JyIpJ6=sf>X{9u*!#l6+#SC{!pUpnOYmi3B$!CHAs>70L6N5U7Bwesfd z&c6Be_4ROmn-2_`YA3=cOq=F5>tykH+vWd0_Ge#dioKcj^WTJ13%~8zQ29AcKkVSK zHeP9)`rX{&wn9&MISyU5?|3}D|9_b7>Z!r9j~9pBdg%OW{{KJAzur)0W3d#@Q8><8 zWOYNrs$@mg%cavjj8{f%T=aR5A{*02F0)@>Uq9DvR#sO(zB)T`x7xwb`WZWiG2gldUKYWJPFpVSsVv@F^)>5~Pr!s@rHdxS94eit zEb;u)X?=BF8J6&!lZqH*e8K~zCFG?!4*h(){XV2cW_*!XPl@HtjfRJR-zChcN{L)H zYrArEYIXXPCn{d|xy7&c1vy6@UBUf$>SYb(58UPxcw;6z9!osZA-H5FOYG*w?)_zI zs}7pJ(qeQ#Vf$zWEPT2e^ zQx~w1yZ>JBaljmq2ANC$wT_y#G(Rr1=oVQRA@DeWNnrN@<>N~$EYoBR-rQuhG+o2; z_ua2mJWX}SFC_0+XTe!CkqMMrf6Hk){#Gk@yx5RE$@>1j15+h93kr)*e^vdk$KBz= zUon<%b8kPpQ*>skW#S>0^ZMD$>)f7v-2U-`GylqdyI&n9!aL#&j@@B9wk)}47w?ZX zf%6ZsePS}nyE9`;!G!#SQ#Ui1s{i{u|Nn%vO*uD>uC5BzzElyjukOko4_{x^vrlH7 zFIyqGTYHtVYq{=Jt4CkjJw3N$!PCdH4sA_c zT=ewRyxF(dJ|)Byom5@Y+I)d2T(2u?Yu3ux-DMNgghb|l`ti7b^~=l4C#Nx)=g)gA zGeP3f^!Pf(vn+O@Kf-cdq@Fi0XxiA`a5&JeyYl3rR_?vr2iclhuC0&vk9sI9^1R^g zp1ZG_-)=mbB_og(yg$iuYQ)MpwrO+=WuE7qO07`Z#BF~h7QGdE^^cxkw*cI~HCdu$cLEI0)o7ffpY{r^kW)2naKCSU3GU369cwad!- zSO0I6XF3=9`5FqS8z;&56-9-#CU%5{tGlfHabxkc1zshVU#C|EW*x{hTs2psW3reb zpSq#vErTx?+ZNtld2v-)%nsHSV!?iYx7JPBWvuBoH`H00BTL**?#Z0_OH9@;nlCZS zb#=DH;RnH?_KttHI!=*VF<;@<3w4gl28*2*iN_Q*#11}P#^PxA*fjfE#X5DregA%C z&+2ABoV>Gc-vQNQTc1z6}C(;1G(lb`(2r~pSkmhOZtQ5vr{EMeQF;)ew=wqWrcm4 zOZ}&l>Pu#_9Cb{1^9$4giL!|Jp1FLkThzbC^A+FU-CZ)%#VW=6#SNaQSkJv%4WGB{ zFq&P}rsx(sT_Q)KXtTcYlC<1&VaZ`>tLA@yCQ^APEG}kd0srn-UJv%L{`?(#y+q*c zt*yb`;`(Z5SvERfk16KOe7GR5WvyI0|DD$zQ#U-k^=*OT?Yp~5gN4=oRC=G^n9Lj| zqN2CsXR-HGt8)}5^6e+HIBv*NG>XBsz54Kruzrl4$#LQP~`sY1DcRznP zQeboStMC!gFI|k=(><%2HVBG}9+i84M~Kt$y3Qnq!q+Vis3B+2+OB}n9Di>=Mei77bz z;IXaamHwdb+j>4eQa<7^CH9ELltQVHEB-vbi$m8hGhCHt7q6NVa+Ph-=DMaI`@;-h zZHZx9G_fvsr`5x)9{LFF6%jjKFUIhSeWb`)wXzH-^0^T+z$_dKQC zHw}I$@ICr(+7rZ|#oVx?ljZNPrp9Q_TN+QrDm+#lk>R~6=Ea}5*vPQ$cB?k8`KcSb zL4yy|)$X#jrR+X-{rmg-_lso<6P`%B&Ahqqm-4YgPbW6m-+F4;u>5FgPQX9;mv3_q z%zY+x$#(6p)~sLKUN{w}9ujU;XRA=#kKbW5RYIn_( zlP%i9&fC`C`)Rp##)Dbaf4^S$G(IT)Gd!-c)nuN>y3CY$WjpUL_nDbgx<8p^)9Jr` z*`IzspC4|Tb!EY{f~IvVzu&9&ul;b4eaXy(BhL>sGJ8JOG<4o(xb9wQbLjfGyOw`U zCn?@8xV-W6%g?F!==hH`}%;aswHjr_tjcwsyRAu6P*2cRl3lWS@&4irN&%l zWpzt_e{b)SnRCkTRld5NzhCvNOIyzTlq*e%8B%Xujc@TV-R9`@rx&(jCikelvYor#;H@bCAXMNl+UGKfCdp(w!3BhIkpUk^UhDfQrSnd#EwtjTW3UXkQ*rZHb4 zfoa04-1a&tiN(AOi&z}rkF1&P+>$EH?IIT4;Mu2bb5qT4>q^0;LEoROxGVX;T1yZVR$hh`$KPMI zh~mwaeQay{BZ12*PFF5!*2;Sk^Pk%4KH+ORbhf|0?#G22LHB>$TjBke<>Rq7o%0n5 zJgoaX6+U{i=JH%%TlMOvdFJ8fwvp6-<==2U$mXY|Ee(NA5kq`e5Li4!IQGwUnRaBf%Vt+ z*YDGf-nuI6UUx^2iAuD_Oqt@;J}J{v+P8C=HMhm8?w%}<72(&ie46&?)!Ys zddbW=#pf(dQk#A;eh9y7JdpH(w3@b@kDvs=!t8lWGrYlvw{b&XfP*yZx5J6Q?I1-n`oA>Vnzw}Ca;TVDtvtG;uOx9nG%Ztv}bge3|OU zl9!iS-#$P6hHx%ru1P1UzIC)Zt`eymq|^V@wf=K19-E?KiX z2VQPs{rokiLZ!r_JM3M;(SL6OzW;o=FnYiD_b-1XEf=QOpXIpv^FhFhU)Ai+=6W9d zYprLz{mZuR#x&7gpH6SNU|c0PVR7oYYij~d-PscGDZSnK|4&=(Da)?^KHIk@V7Whk z`@N|7lBcd1ueF;Xyel)Y>P64Z_PzV6`xc~INLHEpuRp;NdinL!Z?7v3*4^9l;zgPG z6wTme8;;+eK4pr?tjW)Jy;oj*kdb{(kCNNKBEma8MZQi3Hb@Br^kd(9}@a=$sx_x(v*{->j8∓qLziA1dQm31A zWkT3SBaYQm6pk&N(y+YV=8*p-b5lE|XQFqOWd0Im^8Xyc_HWUu@7t|6UrqeG`1MqV zoAVR6f?l~+uYB}@smMV4&${#HI9J9>Dqi)JX6yNPS_E-@SclUpC+pFzAI-=I}p6dG&aAW(#PLu}U1 z{`Deq6dthg%my_S^MbGyn1PSEkB|iie($f7CXF zOmJ@(@{X8uc>n*u`RgYp-``hz&g6mp-!GGYosR$KH0$J>o152)wlcO|+m?B`%|v)f z+xDfO+`i@>2@F{qH8tu`}i|6O;dSNo$k@9m#={PA(ikm}d*R}MY4($|pua5X%BVp>z;uj}#k zwwY?{_x)OB^>j+Ghp}Ug@AGqWJ&hgDvEHsc{d(|#?>#q2I zaA82L&=E_OvsN-yDK{rx*w^#_sLGSR;uF`-9&gADsv)Le zXgD$Vd+M(*FSn?w_S@C|+Hm_#_Yun}fqqy0dFFmu{BJ_M(UldVGw->dyuWV63bX%v zC#l;n4GFm`?Yi>%wba#x`_n4#wLLyqU)*!S#@Al+L9@mpO~YSC&i1tl8QcHgkBj3v zrI#dgO5y9)!UGy-r{6lha%1}Os`dOk@4q!zVH9j`$l^w&er_8DCG96gYrtPm%1g| zjks)hPrUAsnGiQk{qxyIqmbhjje+^E)j5thJ?K2Z{@r$w;Y4Gm^%6g1oA{?`ob6At zWaw^te(ZoqMC^z3iQQf;aEk+Lwy&?d#VsyBIgqTBYb` zWqe-!p+KY3%O<%t_pbO$)L-&f-k#k&>9#@6rS&rMxhI3ckFs;YF^_xth>4nNu>do%0EB_GqE(_McROPwEQ=ofAJRQR5= zWBRQ{y(gAOZ{8d5%KmESrbm{^dvKe%NzH>vJbTY9Zr;#x$v-0N*Ll0& zXR^0^{PDQ|@)XV@sW*3aPBsyKVjQt0LoidVnVo-`rreJ?+M%mlqAXUFH*S2tt%h&K zbjJCcc%QKDc)4u0$K%4c)fcwy|8R)=oY#YN|6gkHKV=v$?K%6unDwf|{SC*PmpAd( zpAVXREoJ5JsACn6HMT8zT9J5xqcd)g`f+d0>24DzRj@j)ve;9na73zk%?Dei`wD7n zP6e$GuI-*>ctTY$&nBV6OzvFZ;@d|mcS=p@7Ls=|T&8T!aUyDiw)%O0(H}Q%ZguSY z^{j34%A+c_ws%+WPhj4tzwU$Hg|_iWy7l+H=yATWt913M@bz+;YO%Y^w#J#C zc;0a3yWTRtxmmr%4zXR+H8_e#{!y;rP^#f5(_}nzn4Dw2i)!Q z@bK{o@!$yxQK)!xT7SRF*(D04+A+r%)kXeal>cFQ__Wnt^T!#6cLmdxX68&07cq6c zf8=fU)bDv!D!qGGyY{532(Yx4eC=4_QnhnsRKQs-dDWf${X1@^XErk?d?@#pMVb)?;$=TlSP!lwcY-zXeg$iwWsAtQX- z_lX~x?{D7gw7uf2!|x-eGviL}c-(op+@fjzudKrj`Mf(me$-XkzQ$JKQ=I#;^{2kr zl|3?b(WnOXjvth{TH4sCD!BZ*pKW|q_}A1&6aM-4zvq@vd?~)|?B9t3Kb5SHBWX_!O;kYop@UK>&^0qnVhc5j$V>oP>p1m!6_oKcg8KnxrFT*D+{wZViYB_s_ zvB8H`#myO|X}$lK);T*$r_4B3G~vk8#xTQIdxGyg*5Q(R{^9<*Z#L2v&!=kNj-8vC znYqfbnQh5T8Ox$0k>0F@*4wh#4#;-SQZxH=OIW7hVA<xlOFTJA zb;-;#vrN5X>wZ36BC7WxbvIMcq{)+&jri29&K+b=*4}q(;$pjt>sf!i=$I|DQ@M7- zKK(Df|IV!P2{nE8;zQ0Jtp`(?*2`z}wb}Ty9g9=AJBNRdY;no{Qug8y>%T5dQ@-r1 zf3DGI!G33khf>Po$jxczq%|}(PfDr_iacjH{IKin!LNDV4K4R$7~_7p_txJqQ#tog zMg7FI2b~OzzK_rK1v^I{Zm59` z?CY=jGaHDczr5bzHuZF-?U8S%`xVyS@%wvBxoE)+hn}O^>-V}zo##CLT;NL~`%(LVKS$Yw_AaRK4P-bRdh3(r zl+py53BiwAi{CnWO*ATb5n!5hgyWKrz!mevLoAm*@!NI0yRor(%b}%tcX!2Yl9b*d zUg&@8#l^+$3ko;bCHz~u zLS?g9`IEF(C+wg2MJ(AY@7i(Zuvv|wZgLW@}cQSDHB_}Q2*>x&8E8l*9bJIE4 zrhuP4)v|2qS^ZZ}pD$Zf*L@)4a`BeN0}D1TTDD@ZsK)(%qyMgqUbcV0OBf!+9kI=P z{A%_3u*b)Gm3x<8I2QKj=lS}u^Y;I3GSzIWzm@!&(%i%RXO~2MK6BnozqwWu#rTdL zJG{Zw{r3FZiF>-ZrU$Y-pI^W4vTkdQ@Q0nnI?>yFw&vZPWg`5p^spMtQwtoFv78~B~%XsYI z{pclR5-(TgMp^up!Ud{xYhKh>yfOTkdUBGg$#T!EEUnwer%Rb;ZFsv6H0-uTWge*U z`?u!eF-4X9Qe4aKP8)`+X(ik*zh4`t%>HMU-gcf#K|BAfD(GGCWM}V!r-ylGPMzvH z>tu4j?J}#fHy*P%xADLH%-y8zcJTS4ZoNe-{}jJkx!mgRkq?pEb8b%BQZRul2Q-I2 zQ=xWe_6>uWi4uAFk7uUO`?$*WcE+tOE2DOo>1L`iv-5?7$5pCks(rY6J7_7RjPz`4 z!xwv4bgf^M3!JPxy22-X4zt%Ee|8*6JWnycS_ z*S*j1W?B5NZ*RS`wQ9b-y?uP0@%o_cd9hJK=T`(SzEQXD%ZrQ6&$cM97sVCCkVT?> zM=g7#Oiz8Tz2(r;p#7ZL>*~(7e@PzSY`@=eo^{fh-}Z@)<~yzJ^XATd+qu^MlLL>m z*_kN6+JjB3=Oj3PG}r(8nSRN~Kt{&-Qf|%Puh(BSaqB6ZWpUngdwafrl*Ns*A9HdK zpWS#rbXCa4V+9X(*nT>pykus=iht$zYu#sYw*A}p_uK6yPvv>k%a2AbIJ5Wd9!1;V zXU*@gfvmUv^Xv8cxS3Vb8m;X%3VJcBUnU+>m{h~3XK?4&oZ@qeXP-Pc*nDs2)~V4B zznYfMubX8eeB{`UqA3ddls3f7dw$`HNa-AB$j}XFtje>sf8WoWrN4ya<XJ!#Gy zAMZyO+Zx|K_l7Jw%euPi=(gvfUwGZ3lNRwx-#$Klf#Pjf-edW?p@3nTxOOCk9v~geGw>|Un zvCrPOZ|p8#pLKnmY^IvV(>Tza|Dl5~58kK^QG9-&N^o&wrG)@@!`Isl`Z7;%d*XiPt4m@{t%HJsKs|8KFqen1yJVeAdBFOT;;-nF@LhH|*Apv?5!dAoZ}gx|+C z7BU>2dtR|4>*}hFr*(NWUs<$6RyMoNoMm^ZhR^Y%rsK+&udlB^zwLL>q|cTtZ>y7% zly1NC4vgD8@mVp)Epty_eY|UMPj9qf?|J?IK{NjzH|cd7-+sSezyI>}exYCMY6Knc z6dso~Nu4xp+Owy&8-5-ZtZxLhKxFL(LcCq0uJ{D1n_{}RqrTM^hW zhsP{n`lDmg`6{|HQy!Gh6+dFQBX;k2J)T-z{A-ljZ55hW&DuvN;Ow9!^e7 znGYXSIq5XKy`j(s8mCz@^TD3mk2o5#?=qOGuiN`A>XMIu{o^iE2V4FtUmrNk^k-Pt?ba(bF|A2P_4p$hzqET5k9iNDpB+8RB=gX= z-Qi*D!oaS#YeecR{WD+YYPJ zR}<2jN^d_h5a4c*5RN{}cZBIq_4~a`W-hqbrhS|vEA2*61H%!An$Ks=m(1Mp$eJOk ze%-EDT9*#}{QLd>)t}Gj`_JOcsx`^Da3HFm<@nQU(fO)pQ%+6My!UZ)<1C5t`?cno zVm8ZHs%gx3&YZ?4Yjq(`jHkAE)9J6O8s{88S$^2>*!0PAhsvd0q7#o*H5F` zUnR3HtLPW<>2;dSZ@#itfMwwUq;;@}<`|>^JoHFLZ}%Ic^%EES&$pYmfA5k!R{a5scRh-Rt<*9A?{A`|Zum zB{TQ@|M%M@^|_4>i zw-R^_jS<%6h)XZ@9RBzBcXvz|S6=MSqfdpH^vWd~qEE55-7k9Gs$gn!C+<$Zz@KNo zWS!T=y6k%ExPN!hKXG%%-+hYb?-+hPT6#xu-Tm!3H=VLIB^2Ac#ayc53eP^*K9~6D zNavE732%N0``b81J!~%ge|1%;vaZY>hlxil73MpCe|Oi@IP?0txzjHnWIJ|rv0nr4 zl`RL%ZaLU7hx@g9tccpWDtz{#V>dP?Up=kA-)ENR-m0%xuC0ysH0JyzYv~m8(Qw7> zhKiP^28o6%S3)lN1YBR_+AX$ZW{-rS)86m*s+Y`EQBzyRD{Tg9Vq32ZmuOnVaQ)1l z%FiCglE!I0Rp2R4$5^iEjh~W&6J#8g6!JB*@h1HiopwM!M;Uoh^7(nTH|zGfL~U0R z(tK9~T9YKed8=UK>9498_qT%DPZy6h=IZl+QX6PdVn71caCYr!W_x-fR$;HzskOIR z3b|6JE|BP7aNj)d&Ivi=cqhhxzZ7<){xZtFWwPzAc=)X{cc%XQ^fV_vKBnOF^J&p} zJ6mIy>zCiF1no4LrW?KKZ_U98MeF2Qjvp<}0j=zSEQj6qWRmxdr@xB6UJc*;w?@(| zN8c0(#s-p z`r)+K+>^fSc<}3q*!69@o4(Z;@L#_!F1~T&Mw6qDt={d!lVej(Y)d=Pz<4h4^p8*3 z-hzDREFSkPUb#oMiw@8C53zNuu zCw1-4GGFPd>*MV&`8-&;ap|(-m8HDu?)zE`A0LafRogMo8q^3q*)CtV;@O#*&a+M~ zogTL;uKKO%C7lBn43eA9es2Y>=~%(Czx&IVlCqhVQx_=Sb_DHKiCw1;8WObRc8iqT z^j7Pjyh~P=)^4Bprl8C7%kNcgS<*zX3CCr6ZlQ*xQHsaJWy|Wn-Awm1KDgqn{=Oeg zCc;yWmoJ^?{{Pd9%J9{1a?+I-HcdrQHC=ku*fSDAbDDeLN`O|Fl-3&CPc+@K4f6kid?S0EWoxiZq z`ReldbzZYho||iZRde|qr&%YvM77skSIM}vr1RJ7`2SwBJiWZSzV1;LZ+$)QQ03al z&1#u86J@0NCD;{Ie{lN+E?*YEB|~tn=6{h=&VL?{Wnyyf?3ft4Tz}#*OBb$n+IhQc zIK6!Lr<@dun$!u-T}gNvSx>ajEmSeiyreSAh_kD>V@llKs+DKX_+0WyJ3sI38Kqtc z&Xfty4!pGddL{TOXvK}LnzDOe$j6T#m$Vw^+%VAhJH+-$bVrfnf+IFwXKx>rpdmtU#~NV|W`TYv8oG2JMaS)9}QB}Ao99Di_1 zyzuYW>z76Nv&tUaxw+%}z3R1vd@ELed@|X8Qkqan+=SPQYJY!Q@^QzbX*LNm4gqtV zGp8-;E9Q8#s9P^-`|0T(-XGrI**W=IO3!{&1u$N?zrT4*d#~Zna=He6ORdR$v@uXoT)si zciVJJK^ANE+os`bjx7ubn{&N&PQzwpx8+ZiugAm%zWUD`aci%Zh)mLX1_lNOPgg&e IbxsLQ09kBBhX4Qo literal 0 HcmV?d00001 diff --git a/homeassistant/const.py b/homeassistant/const.py index 94c932b1fd1..262457de436 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -37,6 +37,7 @@ class Platform(StrEnum): FAN = "fan" GEO_LOCATION = "geo_location" HUMIDIFIER = "humidifier" + IMAGE = "image" IMAGE_PROCESSING = "image_processing" LIGHT = "light" LOCK = "lock" diff --git a/mypy.ini b/mypy.ini index 8628353ef6a..df689b5fc9d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1482,6 +1482,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.image.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.image_processing.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/image/__init__.py b/tests/components/image/__init__.py new file mode 100644 index 00000000000..eacf56cc206 --- /dev/null +++ b/tests/components/image/__init__.py @@ -0,0 +1 @@ +"""The tests for the image integration.""" diff --git a/tests/components/image/conftest.py b/tests/components/image/conftest.py new file mode 100644 index 00000000000..3dad2932928 --- /dev/null +++ b/tests/components/image/conftest.py @@ -0,0 +1,160 @@ +"""Test helpers for image.""" +from collections.abc import Generator + +import pytest + +from homeassistant.components import image +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockImageEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + async def async_added_to_hass(self): + """Set the update time.""" + self._attr_image_last_updated = dt_util.utcnow() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return b"Test" + + +class MockImageNoStateEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return b"Test" + + +class MockImageSyncEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + async def async_added_to_hass(self): + """Set the update time.""" + self._attr_image_last_updated = dt_util.utcnow() + + def image(self) -> bytes | None: + """Return bytes of image.""" + return b"Test" + + +class MockImageConfigEntry: + """A mock image config entry.""" + + def __init__(self, entities: list[image.ImageEntity]) -> None: + """Initialize.""" + self._entities = entities + + async def async_setup_entry( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test image platform via config entry.""" + async_add_entities([self._entities]) + + +class MockImagePlatform: + """A mock image platform.""" + + PLATFORM_SCHEMA = image.PLATFORM_SCHEMA + + def __init__(self, entities: list[image.ImageEntity]) -> None: + """Initialize.""" + self._entities = entities + + async def async_setup_platform( + self, + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up the mock image platform.""" + async_add_entities(self._entities) + + +@pytest.fixture(name="config_flow") +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + + class MockFlow(ConfigFlow): + """Test flow.""" + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(name="mock_image_config_entry") +async def mock_image_config_entry_fixture(hass: HomeAssistant, config_flow): + """Initialize a mock image config_entry.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, image.DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, image.DOMAIN) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + mock_platform( + hass, f"{TEST_DOMAIN}.{image.DOMAIN}", MockImageConfigEntry(MockImageEntity()) + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="mock_image_platform") +async def mock_image_platform_fixture(hass: HomeAssistant): + """Initialize a mock image platform.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockImageEntity()])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py new file mode 100644 index 00000000000..5be9eefa0cc --- /dev/null +++ b/tests/components/image/test_init.py @@ -0,0 +1,169 @@ +"""The tests for the image component.""" +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp import hdrs +import pytest + +from homeassistant.components import image +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import ( + MockImageEntity, + MockImageNoStateEntity, + MockImagePlatform, + MockImageSyncEntity, +) + +from tests.common import MockModule, mock_integration, mock_platform +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_state( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image_platform +) -> None: + """Test image state.""" + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_config_entry( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image_config_entry +) -> None: + """Test setting up an image platform from a config entry.""" + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_state_attr( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test image state with entity picture from attr.""" + mock_integration(hass, MockModule(domain="test")) + entity = MockImageEntity() + entity._attr_entity_picture = "abcd" + mock_platform(hass, "test.image", MockImagePlatform([entity])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": "abcd", + "friendly_name": "Test", + } + + +async def test_no_state( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test image state.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockImageNoStateEntity()])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("image.test") + assert state.state == "unknown" + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + +async def test_fetch_image_authenticated( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image_platform +) -> None: + """Test fetching an image with an authenticated client.""" + client = await hass_client() + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + + resp = await client.get("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND + + +async def test_fetch_image_fail( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image_platform +) -> None: + """Test fetching an image with an authenticated client.""" + client = await hass_client() + + with patch.object(MockImageEntity, "async_image", side_effect=TimeoutError): + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_fetch_image_sync( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test fetching an image with an authenticated client.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockImageSyncEntity()])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + + +async def test_fetch_image_unauthenticated( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_image_platform, +) -> None: + """Test fetching an image with an unauthenticated client.""" + client = await hass_client_no_auth() + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.FORBIDDEN + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.FORBIDDEN + + resp = await client.get( + "/api/image_proxy/image.test", headers={hdrs.AUTHORIZATION: "blabla"} + ) + assert resp.status == HTTPStatus.UNAUTHORIZED + + state = hass.states.get("image.test") + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + + resp = await client.get("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND diff --git a/tests/components/image/test_recorder.py b/tests/components/image/test_recorder.py new file mode 100644 index 00000000000..f0ecc43e6dc --- /dev/null +++ b/tests/components/image/test_recorder.py @@ -0,0 +1,40 @@ +"""The tests for image recorder.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed +from tests.components.recorder.common import async_wait_recording_done + + +async def test_exclude_attributes( + recorder_mock: Recorder, hass: HomeAssistant, mock_image_platform +) -> None: + """Test camera registered attributes to be excluded.""" + now = dt_util.utcnow() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) + assert len(states) == 1 + for entity_states in states.values(): + for state in entity_states: + assert "access_token" not in state.attributes + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_ATTRIBUTION not in state.attributes + assert ATTR_SUPPORTED_FEATURES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/kitchen_sink/test_image.py b/tests/components/kitchen_sink/test_image.py new file mode 100644 index 00000000000..4c64bd77eb2 --- /dev/null +++ b/tests/components/kitchen_sink/test_image.py @@ -0,0 +1,60 @@ +"""The tests for the kitchen_sink image platform.""" +from http import HTTPStatus +from pathlib import Path +from unittest.mock import patch + +import pytest + +from homeassistant.components.kitchen_sink import DOMAIN, image +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +async def image_only() -> None: + """Enable only the image platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.IMAGE], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, image_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_states(hass: HomeAssistant) -> None: + """Test the expected image entities are added.""" + states = hass.states.async_all() + assert len(states) == 1 + state = states[0] + + access_token = state.attributes["access_token"] + assert state.entity_id == "image.qr_code" + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.qr_code?token={access_token}", + "friendly_name": "QR Code", + } + + +async def test_fetch_image( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test fetching an image with an authenticated client.""" + client = await hass_client() + + image_path = Path(image.__file__).parent / "qr_code.png" + expected_data = await hass.async_add_executor_job(image_path.read_bytes) + + resp = await client.get("/api/image_proxy/image.qr_code") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == expected_data From ce8217acf5f5e669b90db08c6eb465fa7b9b9bbb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 19 Jun 2023 19:58:41 +0200 Subject: [PATCH 343/857] Explicitly opt-in to device name in the imap integration (#94861) --- homeassistant/components/imap/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 929ce6a9f61..cd6da667ccb 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -43,6 +43,7 @@ class ImapSensor( _attr_icon = "mdi:email-outline" _attr_has_entity_name = True + _attr_name = None def __init__( self, From 8b6d2fc3ce8149e978b7f54d4b39a69fc2656c52 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 19 Jun 2023 20:20:10 +0200 Subject: [PATCH 344/857] Remove unreachable template validation for imap config flow (#94862) --- homeassistant/components/imap/config_flow.py | 7 ------- homeassistant/components/imap/strings.json | 3 +-- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 92c34e5cc78..00be545fb67 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult -from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( BooleanSelector, @@ -29,7 +28,6 @@ from homeassistant.helpers.selector import ( TemplateSelector, TemplateSelectorConfig, ) -from homeassistant.helpers.template import Template from homeassistant.util.ssl import SSLCipherList from .const import ( @@ -122,11 +120,6 @@ async def validate_input( errors[CONF_CHARSET] = "invalid_charset" else: errors[CONF_SEARCH] = "invalid_search" - if template := user_input.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE): - try: - Template(template, hass=hass).ensure_valid() - except TemplateError: - errors[CONF_CUSTOM_EVENT_DATA_TEMPLATE] = "invalid_template" return errors diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 1e237f72b44..6fad8895931 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -52,8 +52,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", - "invalid_search": "[%key:component::imap::config::error::invalid_search%]", - "invalid_template": "Invalid template" + "invalid_search": "[%key:component::imap::config::error::invalid_search%]" } }, "selector": { From 51326bd8c7caa65c651be40ea9b9a78183b2ea11 Mon Sep 17 00:00:00 2001 From: boozer2 <50851629+boozer2@users.noreply.github.com> Date: Mon, 19 Jun 2023 14:35:44 -0400 Subject: [PATCH 345/857] Add Switchbot Indoor/Outdoor Meter (#94836) Co-authored-by: J. Nick Koston --- homeassistant/components/switchbot/const.py | 1 + homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index a21bd859efc..17e95486298 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -42,6 +42,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.METER: SupportedModels.HYGROMETER, + SwitchbotModel.IO_METER: SupportedModels.HYGROMETER, SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, } diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index c90a1a64289..e45ea1f893e 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.6"] + "requirements": ["PySwitchbot==0.38.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a68df43a1e9..adddc713d52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -99,7 +99,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.6 +PySwitchbot==0.38.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84841def92e..9db0f3bac76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -86,7 +86,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.6 +PySwitchbot==0.38.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From 5d138b64d95ddbaffe1fdbeefba04f23c6d77658 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 22:40:49 +0200 Subject: [PATCH 346/857] Improve test coverage of script (#94883) --- tests/components/script/test_init.py | 88 +++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index dc88ae2f0f2..c64f5a974ba 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -229,7 +229,7 @@ async def test_bad_config_validation( in caplog.text ) - # Make sure one bad automation does not prevent other automations from setting up + # Make sure one bad script does not prevent other scripts from setting up assert hass.states.async_entity_ids("script") == ["script.good_script"] @@ -592,6 +592,27 @@ async def test_async_get_descriptions_script(hass: HomeAssistant) -> None: ) +async def test_extraction_functions_not_setup(hass: HomeAssistant) -> None: + """Test extraction functions when script is not setup.""" + assert script.scripts_with_area(hass, "area-in-both") == [] + assert script.areas_in_script(hass, "script.test") == [] + assert script.scripts_with_blueprint(hass, "blabla.yaml") == [] + assert script.blueprint_in_script(hass, "script.test") is None + assert script.scripts_with_device(hass, "device-in-both") == [] + assert script.devices_in_script(hass, "script.test") == [] + assert script.scripts_with_entity(hass, "light.in_both") == [] + assert script.entities_in_script(hass, "script.test") == [] + + +async def test_extraction_functions_unknown_script(hass: HomeAssistant) -> None: + """Test extraction functions for an unknown script.""" + assert await async_setup_component(hass, DOMAIN, {}) + assert script.areas_in_script(hass, "script.unknown") == [] + assert script.blueprint_in_script(hass, "script.unknown") is None + assert script.devices_in_script(hass, "script.unknown") == [] + assert script.entities_in_script(hass, "script.unknown") == [] + + async def test_extraction_functions(hass: HomeAssistant) -> None: """Test extraction functions.""" assert await async_setup_component( @@ -615,6 +636,10 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "type": "turn_on", "device_id": "device-in-both", }, + { + "service": "test.test", + "target": {"area_id": "area-in-both"}, + }, ] }, "test2": { @@ -643,6 +668,28 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: }, ], }, + "test3": { + "sequence": [ + { + "service": "test.script", + "data": {"entity_id": "light.in_both"}, + }, + { + "condition": "state", + "entity_id": "sensor.condition", + "state": "100", + }, + {"scene": "scene.hello"}, + { + "service": "test.test", + "target": {"area_id": "area-in-both"}, + }, + { + "service": "test.test", + "target": {"area_id": "area-in-last"}, + }, + ], + }, } }, ) @@ -650,6 +697,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: assert set(script.scripts_with_entity(hass, "light.in_both")) == { "script.test1", "script.test2", + "script.test3", } assert set(script.entities_in_script(hass, "script.test1")) == { "light.in_both", @@ -663,6 +711,15 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "device-in-both", "device-in-last", } + assert set(script.scripts_with_area(hass, "area-in-both")) == { + "script.test1", + "script.test3", + } + assert set(script.areas_in_script(hass, "script.test3")) == { + "area-in-both", + "area-in-last", + } + assert script.blueprint_in_script(hass, "script.test3") is None async def test_config_basic(hass: HomeAssistant) -> None: @@ -1334,6 +1391,35 @@ async def test_script_service_changed_entity_id(hass: HomeAssistant) -> None: assert calls[1].data["entity_id"] == "script.custom_entity_id_2" +async def test_blueprint_automation(hass: HomeAssistant, calls) -> None: + """Test blueprint script.""" + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test_script": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "test.script", + }, + } + } + } + }, + ) + await hass.services.async_call( + "script", "test_script", {"var_from_service": "hello"}, blocking=True + ) + await hass.async_block_till_done() + assert len(calls) == 1 + assert script.blueprint_in_script(hass, "script.test_script") == "test_service.yaml" + assert script.scripts_with_blueprint(hass, "test_service.yaml") == [ + "script.test_script" + ] + + @pytest.mark.parametrize( ("blueprint_inputs", "problem", "details"), ( From a7d327afa210fbe19d4a16849444226a2f07c4a3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 23:05:20 +0200 Subject: [PATCH 347/857] Improve test coverage of automation extraction functions (#94878) --- tests/components/automation/test_init.py | 93 +++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 3859e0c857c..d1eb93771da 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1537,6 +1537,27 @@ async def test_automation_restore_last_triggered_with_initial_state( assert state.attributes["last_triggered"] == time +async def test_extraction_functions_not_setup(hass: HomeAssistant) -> None: + """Test extraction functions when automation is not setup.""" + assert automation.automations_with_area(hass, "area-in-both") == [] + assert automation.areas_in_automation(hass, "automation.test") == [] + assert automation.automations_with_blueprint(hass, "blabla.yaml") == [] + assert automation.blueprint_in_automation(hass, "automation.test") is None + assert automation.automations_with_device(hass, "device-in-both") == [] + assert automation.devices_in_automation(hass, "automation.test") == [] + assert automation.automations_with_entity(hass, "light.in_both") == [] + assert automation.entities_in_automation(hass, "automation.test") == [] + + +async def test_extraction_functions_unknown_automation(hass: HomeAssistant) -> None: + """Test extraction functions for an unknown automation.""" + assert await async_setup_component(hass, DOMAIN, {}) + assert automation.areas_in_automation(hass, "automation.unknown") == [] + assert automation.blueprint_in_automation(hass, "automation.unknown") is None + assert automation.devices_in_automation(hass, "automation.unknown") == [] + assert automation.entities_in_automation(hass, "automation.unknown") == [] + + async def test_extraction_functions(hass: HomeAssistant) -> None: """Test extraction functions.""" await async_setup_component(hass, "homeassistant", {}) @@ -1604,6 +1625,10 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "entity_id": "light.bla", "type": "turn_on", }, + { + "service": "test.test", + "target": {"area_id": "area-in-both"}, + }, ], }, { @@ -1635,7 +1660,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: { "platform": "event", "event_type": "esphome.button_pressed", - "event_data": {"device_id": ["device-trigger-event"]}, + "event_data": {"device_id": ["device-trigger-event2"]}, }, # device_id is not a string { @@ -1676,6 +1701,55 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: }, ], }, + { + "alias": "test3", + "trigger": [ + { + "platform": "event", + "event_type": "esphome.button_pressed", + "event_data": {"area_id": "area-trigger-event"}, + }, + # area_id is a list of strings (not supported) + { + "platform": "event", + "event_type": "esphome.button_pressed", + "event_data": {"area_id": ["area-trigger-event2"]}, + }, + # area_id is not a string + { + "platform": "event", + "event_type": "esphome.button_pressed", + "event_data": {"area_id": 123}, + }, + ], + "condition": { + "condition": "device", + "device_id": "condition-device", + "domain": "light", + "type": "is_on", + "entity_id": "light.bla", + }, + "action": [ + { + "service": "test.script", + "data": {"entity_id": "light.in_both"}, + }, + { + "condition": "state", + "entity_id": "sensor.condition", + "state": "100", + }, + {"scene": "scene.hello"}, + { + "service": "test.test", + "target": {"area_id": "area-in-both"}, + }, + { + "service": "test.test", + "target": {"area_id": "area-in-last"}, + }, + ], + }, ] }, ) @@ -1683,6 +1757,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: assert set(automation.automations_with_entity(hass, "light.in_both")) == { "automation.test1", "automation.test2", + "automation.test3", } assert set(automation.entities_in_automation(hass, "automation.test1")) == { "calendar.trigger_calendar", @@ -1707,6 +1782,15 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "device-trigger-tag2", "device-trigger-tag3", } + assert set(automation.automations_with_area(hass, "area-in-both")) == { + "automation.test1", + "automation.test3", + } + assert set(automation.areas_in_automation(hass, "automation.test3")) == { + "area-in-both", + "area-in-last", + } + assert automation.blueprint_in_automation(hass, "automation.test3") is None async def test_logbook_humanify_automation_triggered_event(hass: HomeAssistant) -> None: @@ -1980,6 +2064,13 @@ async def test_blueprint_automation(hass: HomeAssistant, calls) -> None: assert automation.entities_in_automation(hass, "automation.automation_0") == [ "light.kitchen" ] + assert ( + automation.blueprint_in_automation(hass, "automation.automation_0") + == "test_event_service.yaml" + ) + assert automation.automations_with_blueprint(hass, "test_event_service.yaml") == [ + "automation.automation_0" + ] @pytest.mark.parametrize( From 1206f2c1da0f4e82cfd44cbccdf25f123b0c86dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Jun 2023 18:27:22 -0500 Subject: [PATCH 348/857] Fix memory leaks in websocket api (#94780) Co-authored-by: Martin Hjelmare --- .../components/websocket_api/connection.py | 23 ++- .../components/websocket_api/const.py | 6 - .../components/websocket_api/http.py | 192 +++++++++++------- tests/components/websocket_api/conftest.py | 10 +- tests/components/websocket_api/test_http.py | 97 ++++++++- 5 files changed, 244 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 1f6fd302968..a91a5178830 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -56,6 +56,10 @@ class ActiveConnection: self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) + def __repr__(self) -> str: + """Return the representation.""" + return f"" + def set_supported_features(self, features: dict[str, float]) -> None: """Set supported features.""" self.supported_features = features @@ -193,7 +197,24 @@ class ActiveConnection: def async_handle_close(self) -> None: """Handle closing down connection.""" for unsub in self.subscriptions.values(): - unsub() + try: + unsub() + except Exception: # pylint: disable=broad-except + # If one fails, make sure we still try the rest + self.logger.exception( + "Error unsubscribing from subscription: %s", unsub + ) + self.subscriptions.clear() + self.send_message = self._connect_closed_error + current_request.set(None) + current_connection.set(None) + + @callback + def _connect_closed_error( + self, msg: str | dict[str, Any] | Callable[[], str] + ) -> None: + """Send a message when the connection is closed.""" + self.logger.debug("Tried to send message %s on closed connection", msg) @callback def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 9eb04ecbc51..4b9a0943d9a 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -1,9 +1,7 @@ """Websocket constants.""" from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable -from concurrent import futures from typing import TYPE_CHECKING, Any, Final from homeassistant.core import HomeAssistant @@ -42,10 +40,6 @@ ERR_TEMPLATE_ERROR: Final = "template_error" TYPE_RESULT: Final = "result" -# Define the possible errors that occur when connections are cancelled. -# Originally, this was just asyncio.CancelledError, but issue #9546 showed -# that futures.CancelledErrors can also occur in some situations. -CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError) # Event types SIGNAL_WEBSOCKET_CONNECTED: Final = "websocket_connected" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 5ca5ea62578..54daf89d8dd 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections import deque from collections.abc import Callable -from contextlib import suppress import datetime as dt import logging from typing import TYPE_CHECKING, Any, Final @@ -21,7 +20,6 @@ from homeassistant.util.json import json_loads from .auth import AuthPhase, auth_required_message from .const import ( - CANCELLATION_ERRORS, DATA_CONNECTIONS, MAX_PENDING_MSG, PENDING_MSG_PEAK, @@ -68,15 +66,16 @@ class WebSocketHandler: def __init__(self, hass: HomeAssistant, request: web.Request) -> None: """Initialize an active connection.""" - self.hass = hass - self.request = request - self.wsock = web.WebSocketResponse(heartbeat=55) + self._hass = hass + self._request: web.Request = request + self._wsock = web.WebSocketResponse(heartbeat=55) self._handle_task: asyncio.Task | None = None self._writer_task: asyncio.Task | None = None self._closing: bool = False + self._authenticated: bool = False self._logger = WebSocketAdapter(_WS_LOGGER, {"connid": id(self)}) self._peak_checker_unsub: Callable[[], None] | None = None - self.connection: ActiveConnection | None = None + self._connection: ActiveConnection | None = None # The WebSocketHandler has a single consumer and path # to where messages are queued. This allows the implementation @@ -85,61 +84,81 @@ class WebSocketHandler: self._message_queue: deque = deque() self._ready_future: asyncio.Future[None] | None = None + def __repr__(self) -> str: + """Return the representation.""" + return ( + "" + ) + @property def description(self) -> str: """Return a description of the connection.""" - if self.connection is not None: - return self.connection.get_description(self.request) - return describe_request(self.request) + if connection := self._connection: + return connection.get_description(self._request) + if request := self._request: + return describe_request(request) + return "finished connection" async def _writer(self) -> None: """Write outgoing messages.""" # Variables are set locally to avoid lookups in the loop message_queue = self._message_queue logger = self._logger - send_str = self.wsock.send_str - loop = self.hass.loop + wsock = self._wsock + send_str = wsock.send_str + loop = self._hass.loop debug = logger.debug + is_enabled_for = logger.isEnabledFor + logging_debug = logging.DEBUG # Exceptions if Socket disconnected or cancelled by connection handler try: - with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS): - while not self.wsock.closed: - if (messages_remaining := len(message_queue)) == 0: - self._ready_future = loop.create_future() - await self._ready_future - messages_remaining = len(message_queue) + while not wsock.closed: + if (messages_remaining := len(message_queue)) == 0: + self._ready_future = loop.create_future() + await self._ready_future + messages_remaining = len(message_queue) + # A None message is used to signal the end of the connection + if (process := message_queue.popleft()) is None: + return + + debug_enabled = is_enabled_for(logging_debug) + messages_remaining -= 1 + message = process if isinstance(process, str) else process() + + if ( + not messages_remaining + or not (connection := self._connection) + or not connection.can_coalesce + ): + if debug_enabled: + debug("%s: Sending %s", self.description, message) + await send_str(message) + continue + + messages: list[str] = [message] + while messages_remaining: # A None message is used to signal the end of the connection if (process := message_queue.popleft()) is None: return - + messages.append(process if isinstance(process, str) else process()) messages_remaining -= 1 - message = process if isinstance(process, str) else process() - if ( - not messages_remaining - or not self.connection - or not self.connection.can_coalesce - ): - debug("Sending %s", message) - await send_str(message) - continue - - messages: list[str] = [message] - while messages_remaining: - # A None message is used to signal the end of the connection - if (process := message_queue.popleft()) is None: - return - messages.append( - process if isinstance(process, str) else process() - ) - messages_remaining -= 1 - - joined_messages = ",".join(messages) - coalesced_messages = f"[{joined_messages}]" - debug("Sending %s", coalesced_messages) - await send_str(coalesced_messages) + joined_messages = ",".join(messages) + coalesced_messages = f"[{joined_messages}]" + if debug_enabled: + debug("%s: Sending %s", self.description, coalesced_messages) + await send_str(coalesced_messages) + except asyncio.CancelledError: + debug("%s: Writer cancelled", self.description) + raise + except (RuntimeError, ConnectionResetError) as ex: + debug("%s: Unexpected error in writer: %s", self.description, ex) finally: + debug("%s: Writer done", self.description) # Clean up the peak checker when we shut down the writer self._cancel_peak_checker() @@ -195,7 +214,7 @@ class WebSocketHandler: if not peak_checker_active: self._peak_checker_unsub = async_call_later( - self.hass, PENDING_MSG_PEAK_TIME, self._check_write_peak + self._hass, PENDING_MSG_PEAK_TIME, self._check_write_peak ) @callback @@ -231,8 +250,14 @@ class WebSocketHandler: async def async_handle(self) -> web.WebSocketResponse: """Handle a websocket response.""" - request = self.request - wsock = self.wsock + request = self._request + wsock = self._wsock + logger = self._logger + debug = logger.debug + hass = self._hass + is_enabled_for = logger.isEnabledFor + logging_debug = logging.DEBUG + try: async with async_timeout.timeout(10): await wsock.prepare(request) @@ -240,7 +265,7 @@ class WebSocketHandler: self._logger.warning("Timeout preparing request from %s", request.remote) return wsock - self._logger.debug("Connected from %s", request.remote) + debug("%s: Connected from %s", self.description, request.remote) self._handle_task = asyncio.current_task() @callback @@ -248,17 +273,13 @@ class WebSocketHandler: """Cancel this connection.""" self._cancel() - unsub_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, handle_hass_stop - ) + unsub_stop = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_hass_stop) # As the webserver is now started before the start # event we do not want to block for websocket responses self._writer_task = asyncio.create_task(self._writer()) - auth = AuthPhase( - self._logger, self.hass, self._send_message, self._cancel, request - ) + auth = AuthPhase(logger, hass, self._send_message, self._cancel, request) connection = None disconnect_warn = None @@ -286,13 +307,14 @@ class WebSocketHandler: disconnect_warn = "Received invalid JSON." raise Disconnect from err - self._logger.debug("Received %s", msg_data) - self.connection = connection = await auth.async_handle(msg_data) - self.hass.data[DATA_CONNECTIONS] = ( - self.hass.data.get(DATA_CONNECTIONS, 0) + 1 - ) - async_dispatcher_send(self.hass, SIGNAL_WEBSOCKET_CONNECTED) + if is_enabled_for(logging_debug): + debug("%s: Received %s", self.description, msg_data) + connection = await auth.async_handle(msg_data) + self._connection = connection + hass.data[DATA_CONNECTIONS] = hass.data.get(DATA_CONNECTIONS, 0) + 1 + async_dispatcher_send(hass, SIGNAL_WEBSOCKET_CONNECTED) + self._authenticated = True # # # Our websocket implementation is backed by an asyncio.Queue @@ -356,7 +378,9 @@ class WebSocketHandler: disconnect_warn = "Received invalid JSON." break - self._logger.debug("Received %s", msg_data) + if is_enabled_for(logging_debug): + debug("%s: Received %s", self.description, msg_data) + if not isinstance(msg_data, list): connection.async_handle(msg_data) continue @@ -365,17 +389,22 @@ class WebSocketHandler: connection.async_handle(split_msg) except asyncio.CancelledError: - self._logger.info("Connection closed by client") + debug("%s: Connection cancelled", self.description) + raise - except Disconnect: - pass + except Disconnect as ex: + debug("%s: Connection closed by client: %s", self.description, ex) except Exception: # pylint: disable=broad-except - self._logger.exception("Unexpected error inside websocket API") + self._logger.exception( + "%s: Unexpected error inside websocket API", self.description + ) finally: unsub_stop() + self._cancel_peak_checker() + if connection is not None: connection.async_handle_close() @@ -385,20 +414,37 @@ class WebSocketHandler: if self._ready_future and not self._ready_future.done(): self._ready_future.set_result(None) + # If the writer gets canceled we still need to close the websocket + # so we have another finally block to make sure we close the websocket + # if the writer gets canceled. try: - # Make sure all error messages are written before closing await self._writer_task - await wsock.close() finally: - if disconnect_warn is None: - self._logger.debug("Disconnected") - else: - self._logger.warning("Disconnected: %s", disconnect_warn) + try: + # Make sure all error messages are written before closing + await wsock.close() + finally: + if disconnect_warn is None: + debug("%s: Disconnected", self.description) + else: + self._logger.warning( + "%s: Disconnected: %s", self.description, disconnect_warn + ) - if connection is not None: - self.hass.data[DATA_CONNECTIONS] -= 1 - self.connection = None + if connection is not None: + hass.data[DATA_CONNECTIONS] -= 1 + self._connection = None - async_dispatcher_send(self.hass, SIGNAL_WEBSOCKET_DISCONNECTED) + async_dispatcher_send(hass, SIGNAL_WEBSOCKET_DISCONNECTED) + + # Break reference cycles to make sure GC can happen sooner + self._wsock = None # type: ignore[assignment] + self._request = None # type: ignore[assignment] + self._hass = None # type: ignore[assignment] + self._logger = None # type: ignore[assignment] + self._message_queue = None # type: ignore[assignment] + self._handle_task = None + self._writer_task = None + self._ready_future = None return wsock diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py index 53569c3fa6a..69adf890584 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -3,11 +3,19 @@ import pytest from homeassistant.components.websocket_api.auth import TYPE_AUTH_REQUIRED from homeassistant.components.websocket_api.http import URL +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import ( + MockHAClientWebSocket, + WebSocketGenerator, +) + @pytest.fixture -async def websocket_client(hass, hass_ws_client): +async def websocket_client( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> MockHAClientWebSocket: """Create a websocket client.""" return await hass_ws_client(hass) diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 02384aace89..3205d40b52d 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -18,7 +18,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.typing import WebSocketGenerator +from tests.typing import ( + MockHAClientWebSocket, + WebSocketGenerator, +) @pytest.fixture @@ -36,15 +39,103 @@ def mock_low_peak(): async def test_pending_msg_overflow( - hass: HomeAssistant, mock_low_queue, websocket_client + hass: HomeAssistant, mock_low_queue, websocket_client: MockHAClientWebSocket ) -> None: - """Test get_panels command.""" + """Test pending messages overflows.""" for idx in range(10): await websocket_client.send_json({"id": idx + 1, "type": "ping"}) msg = await websocket_client.receive() assert msg.type == WSMsgType.close +async def test_cleanup_on_cancellation( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test cleanup on cancellation.""" + + subscriptions = None + + # Register a handler that registers a subscription + @callback + @websocket_command( + { + "type": "fake_subscription", + } + ) + def fake_subscription( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ) -> None: + nonlocal subscriptions + msg_id: int = msg["id"] + connection.subscriptions[msg_id] = callback(lambda: None) + connection.send_result(msg_id) + subscriptions = connection.subscriptions + + async_register_command(hass, fake_subscription) + + # Register a handler that raises on cancel + @callback + @websocket_command( + { + "type": "subscription_that_raises_on_cancel", + } + ) + def subscription_that_raises_on_cancel( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ) -> None: + nonlocal subscriptions + msg_id: int = msg["id"] + + @callback + def _raise(): + raise ValueError() + + connection.subscriptions[msg_id] = _raise + connection.send_result(msg_id) + subscriptions = connection.subscriptions + + async_register_command(hass, subscription_that_raises_on_cancel) + + # Register a handler that cancels in handler + @callback + @websocket_command( + { + "type": "cancel_in_handler", + } + ) + def cancel_in_handler( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ) -> None: + raise asyncio.CancelledError() + + async_register_command(hass, cancel_in_handler) + + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert not subscriptions + await websocket_client.send_json({"id": 2, "type": "fake_subscription"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert len(subscriptions) == 2 + await websocket_client.send_json( + {"id": 3, "type": "subscription_that_raises_on_cancel"} + ) + msg = await websocket_client.receive_json() + assert msg["id"] == 3 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert len(subscriptions) == 3 + await websocket_client.send_json({"id": 4, "type": "cancel_in_handler"}) + await hass.async_block_till_done() + msg = await websocket_client.receive() + assert msg.type == WSMsgType.close + assert len(subscriptions) == 0 + + async def test_pending_msg_peak( hass: HomeAssistant, mock_low_peak, From b1288a885d975f08c404c1cc388a37e8e9bc74f0 Mon Sep 17 00:00:00 2001 From: quthla Date: Tue, 20 Jun 2023 01:38:42 +0200 Subject: [PATCH 349/857] Bump yeelight to 0.7.11 (#94879) Co-authored-by: J. Nick Koston --- homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index c6f54b45f1e..cf1bafe24fb 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.10", "async-upnp-client==0.33.2"], + "requirements": ["yeelight==0.7.11", "async-upnp-client==0.33.2"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index adddc713d52..33f289e4425 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2715,7 +2715,7 @@ yalexs-ble==2.1.18 yalexs==1.5.1 # homeassistant.components.yeelight -yeelight==0.7.10 +yeelight==0.7.11 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9db0f3bac76..e05e840f165 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1988,7 +1988,7 @@ yalexs-ble==2.1.18 yalexs==1.5.1 # homeassistant.components.yeelight -yeelight==0.7.10 +yeelight==0.7.11 # homeassistant.components.yolink yolink-api==0.2.9 From cecdc3bd47f268a157cde5f63c42bf289f4f1faf Mon Sep 17 00:00:00 2001 From: Graham Brown Date: Tue, 20 Jun 2023 02:19:17 +0200 Subject: [PATCH 350/857] ESPHome Alarm Control Panel (#92357) --- .coveragerc | 1 + .../components/esphome/alarm_control_panel.py | 160 ++++++++++++++++++ .../components/esphome/entry_data.py | 2 + .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/esphome/alarm_control_panel.py diff --git a/.coveragerc b/.coveragerc index 242833b1262..944b2a5f838 100644 --- a/.coveragerc +++ b/.coveragerc @@ -305,6 +305,7 @@ omit = homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py homeassistant/components/esphome/__init__.py + homeassistant/components/esphome/alarm_control_panel.py homeassistant/components/esphome/binary_sensor.py homeassistant/components/esphome/bluetooth/* homeassistant/components/esphome/button.py diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py new file mode 100644 index 00000000000..efa95dda710 --- /dev/null +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -0,0 +1,160 @@ +"""Support for ESPHome Alarm Control Panel.""" +from __future__ import annotations + +from aioesphomeapi import ( + AlarmControlPanelCommand, + AlarmControlPanelEntityState, + AlarmControlPanelInfo, + AlarmControlPanelState, + APIIntEnum, +) + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_DISARMING, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EsphomeEntity, EsphomeEnumMapper, platform_async_setup_entry + +_ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ + AlarmControlPanelState, str +] = EsphomeEnumMapper( + { + AlarmControlPanelState.DISARMED: STATE_ALARM_DISARMED, + AlarmControlPanelState.ARMED_HOME: STATE_ALARM_ARMED_HOME, + AlarmControlPanelState.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + AlarmControlPanelState.ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, + AlarmControlPanelState.ARMED_VACATION: STATE_ALARM_ARMED_VACATION, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS: STATE_ALARM_ARMED_CUSTOM_BYPASS, + AlarmControlPanelState.PENDING: STATE_ALARM_PENDING, + AlarmControlPanelState.ARMING: STATE_ALARM_ARMING, + AlarmControlPanelState.DISARMING: STATE_ALARM_DISARMING, + AlarmControlPanelState.TRIGGERED: STATE_ALARM_TRIGGERED, + } +) + + +class EspHomeACPFeatures(APIIntEnum): + """ESPHome AlarmCintolPanel feature numbers.""" + + ARM_HOME = 1 + ARM_AWAY = 2 + ARM_NIGHT = 4 + TRIGGER = 8 + ARM_CUSTOM_BYPASS = 16 + ARM_VACATION = 32 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up ESPHome switches based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="alarm_control_panel", + info_type=AlarmControlPanelInfo, + entity_type=EsphomeAlarmControlPanel, + state_type=AlarmControlPanelEntityState, + ) + + +class EsphomeAlarmControlPanel( + EsphomeEntity[AlarmControlPanelInfo, AlarmControlPanelEntityState], + AlarmControlPanelEntity, +): + """An Alarm Control Panel implementation for ESPHome.""" + + @property + def state(self) -> str | None: + """Return the state of the device.""" + return _ESPHOME_ACP_STATE_TO_HASS_STATE.from_esphome(self._state.state) + + @property + def supported_features(self) -> AlarmControlPanelEntityFeature: + """Return the list of supported features.""" + feature = 0 + if self._static_info.supported_features & EspHomeACPFeatures.ARM_HOME: + feature |= AlarmControlPanelEntityFeature.ARM_HOME + if self._static_info.supported_features & EspHomeACPFeatures.ARM_AWAY: + feature |= AlarmControlPanelEntityFeature.ARM_AWAY + if self._static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT: + feature |= AlarmControlPanelEntityFeature.ARM_NIGHT + if self._static_info.supported_features & EspHomeACPFeatures.TRIGGER: + feature |= AlarmControlPanelEntityFeature.TRIGGER + if self._static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS: + feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + if self._static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: + feature |= AlarmControlPanelEntityFeature.ARM_VACATION + return AlarmControlPanelEntityFeature(feature) + + @property + def code_format(self) -> CodeFormat | None: + """Return code format for disarm.""" + if self._static_info.requires_code: + return CodeFormat.NUMBER + return None + + @property + def code_arm_required(self) -> bool: + """Whether the code is required for arm actions.""" + return bool(self._static_info.requires_code_to_arm) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + await self._client.alarm_control_panel_command( + self._static_info.key, AlarmControlPanelCommand.DISARM, code + ) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self._client.alarm_control_panel_command( + self._static_info.key, AlarmControlPanelCommand.ARM_HOME, code + ) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._static_info.key, AlarmControlPanelCommand.ARM_AWAY, code + ) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._static_info.key, AlarmControlPanelCommand.ARM_NIGHT, code + ) + + async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._static_info.key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code + ) + + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._static_info.key, AlarmControlPanelCommand.ARM_VACATION, code + ) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + await self._client.alarm_control_panel_command( + self._static_info.key, AlarmControlPanelCommand.TRIGGER, code + ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 225ae3961e8..9c78e69709e 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -9,6 +9,7 @@ from typing import Any, cast from aioesphomeapi import ( COMPONENT_TYPE_TO_INFO, + AlarmControlPanelInfo, APIClient, APIVersion, BinarySensorInfo, @@ -46,6 +47,7 @@ _LOGGER = logging.getLogger(__name__) # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { + AlarmControlPanelInfo: Platform.ALARM_CONTROL_PANEL, BinarySensorInfo: Platform.BINARY_SENSOR, ButtonInfo: Platform.BUTTON, CameraInfo: Platform.CAMERA, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index bc049153b8f..5a064e9b802 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==14.0.0", + "aioesphomeapi==14.1.0", "bluetooth-data-tools==1.2.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 33f289e4425..aa907b6a834 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==14.0.0 +aioesphomeapi==14.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e05e840f165..edc6f989b60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==14.0.0 +aioesphomeapi==14.1.0 # homeassistant.components.flo aioflo==2021.11.0 From 643583706195051b298705d6a339480b55cf9466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 20 Jun 2023 02:23:24 +0200 Subject: [PATCH 351/857] Update aioairzone to v0.6.4 (#94873) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 637066629db..88b918f699c 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.3"] + "requirements": ["aioairzone==0.6.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa907b6a834..d2e3a939288 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -194,7 +194,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.8 # homeassistant.components.airzone -aioairzone==0.6.3 +aioairzone==0.6.4 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edc6f989b60..bb37244ac54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -172,7 +172,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.8 # homeassistant.components.airzone -aioairzone==0.6.3 +aioairzone==0.6.4 # homeassistant.components.ambient_station aioambient==2023.04.0 From 5d40edcf02e5fe7ee40d91ed1201bbaa6848e849 Mon Sep 17 00:00:00 2001 From: Mariano Faraco Date: Tue, 20 Jun 2023 01:40:58 -0300 Subject: [PATCH 352/857] Bump ha-philipsjs to 3.1.0 (#94811) --- homeassistant/components/philips_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 705acefa60f..46b1340a28d 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/philips_js", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.0.0"] + "requirements": ["ha-philipsjs==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d2e3a939288..20e397d2485 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -942,7 +942,7 @@ ha-ffmpeg==3.1.0 ha-iotawattpy==0.1.1 # homeassistant.components.philips_js -ha-philipsjs==3.0.0 +ha-philipsjs==3.1.0 # homeassistant.components.habitica habitipy==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb37244ac54..99ab6ab5df1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -737,7 +737,7 @@ ha-ffmpeg==3.1.0 ha-iotawattpy==0.1.1 # homeassistant.components.philips_js -ha-philipsjs==3.0.0 +ha-philipsjs==3.1.0 # homeassistant.components.habitica habitipy==0.2.0 From a262cd2b969895f6007f9f299ac19b7157cdb232 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 20 Jun 2023 08:02:13 +0200 Subject: [PATCH 353/857] Add source address early for KNX services (#94889) --- homeassistant/components/knx/__init__.py | 2 ++ homeassistant/components/knx/websocket.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8a8e87b893f..0d039ca2c61 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -683,6 +683,7 @@ class KNXModule: payload=GroupValueResponse(payload) if attr_response else GroupValueWrite(payload), + source_address=self.xknx.current_address, ) await self.xknx.telegrams.put(telegram) @@ -692,5 +693,6 @@ class KNXModule: telegram = Telegram( destination_address=parse_device_group_address(address), payload=GroupValueRead(), + source_address=self.xknx.current_address, ) await self.xknx.telegrams.put(telegram) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index a9da5036857..feb53ddc908 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -34,7 +34,7 @@ async def register_panel(hass: HomeAssistant) -> None: hass.http.register_static_path( URL_BASE, path, - cache_headers=not is_dev_build, + cache_headers=not is_dev_build(), ) await panel_custom.async_register_panel( hass=hass, From 99cbf21c57cc034cbad7412a09f44d40a1ba4ce3 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 19 Jun 2023 23:28:27 -0700 Subject: [PATCH 354/857] Add `homeassistant.components.text` to `.strict-typing` (#94890) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 39480601388..500dd076767 100644 --- a/.strict-typing +++ b/.strict-typing @@ -309,6 +309,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.text.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/mypy.ini b/mypy.ini index df689b5fc9d..68095329374 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2853,6 +2853,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.text.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.threshold.*] check_untyped_defs = true disallow_incomplete_defs = true From 3cf88ffddeee6c0ee4754de2b15fdc40a2f8b781 Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Tue, 20 Jun 2023 08:40:57 +0200 Subject: [PATCH 355/857] Bump boschshcpy to 0.2.57 (#94686) --- homeassistant/components/bosch_shc/manifest.json | 2 +- homeassistant/components/bosch_shc/sensor.py | 2 +- homeassistant/components/bosch_shc/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 90558936592..9fd1055dd60 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.35"], + "requirements": ["boschshcpy==0.2.57"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index b310799323a..73307d9ea0a 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -117,7 +117,7 @@ async def async_setup_entry( ) for sensor in ( - session.device_helper.smart_plugs + session.device_helper.light_switches + session.device_helper.smart_plugs + session.device_helper.light_switches_bsm ): entities.append( PowerSensor( diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 6fe06213d75..3b3b6e2ffd4 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -111,7 +111,7 @@ async def async_setup_entry( ) ) - for switch in session.device_helper.light_switches: + for switch in session.device_helper.light_switches_bsm: entities.append( SHCSwitch( device=switch, diff --git a/requirements_all.txt b/requirements_all.txt index 20e397d2485..40c3b78cda0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -546,7 +546,7 @@ bluetooth-data-tools==1.2.0 bond-async==0.1.23 # homeassistant.components.bosch_shc -boschshcpy==0.2.35 +boschshcpy==0.2.57 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99ab6ab5df1..71f25415549 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -457,7 +457,7 @@ bluetooth-data-tools==1.2.0 bond-async==0.1.23 # homeassistant.components.bosch_shc -boschshcpy==0.2.35 +boschshcpy==0.2.57 # homeassistant.components.broadlink broadlink==0.18.3 From 2be5bab5e1deb648d2091a57fd4e28fd092bbe07 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Tue, 20 Jun 2023 08:47:34 +0200 Subject: [PATCH 356/857] Ezviz library bump 0.2.1.2 (#94823) --- homeassistant/components/ezviz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index e9f11f4cd39..53976bf3002 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.0.17"] + "requirements": ["pyezviz==0.2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 40c3b78cda0..cdfaf7dd2ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1664,7 +1664,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.17 +pyezviz==0.2.1.2 # homeassistant.components.fibaro pyfibaro==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71f25415549..bc2391a769c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1222,7 +1222,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.17 +pyezviz==0.2.1.2 # homeassistant.components.fibaro pyfibaro==0.7.1 From 185aaa9e079021a50971d10bbafe4bacdbac202c Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 20 Jun 2023 10:43:33 +0300 Subject: [PATCH 357/857] Humidifier current humidity (#94874) --- homeassistant/components/demo/humidifier.py | 4 ++++ homeassistant/components/humidifier/__init__.py | 10 ++++++++++ homeassistant/components/humidifier/const.py | 1 + homeassistant/components/humidifier/strings.json | 3 +++ tests/components/demo/test_humidifier.py | 2 ++ 5 files changed, 20 insertions(+) diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 772726ac1d5..75c2cf3120a 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -29,12 +29,14 @@ async def async_setup_platform( name="Humidifier", mode=None, target_humidity=68, + current_humidity=45, device_class=HumidifierDeviceClass.HUMIDIFIER, ), DemoHumidifier( name="Dehumidifier", mode=None, target_humidity=54, + current_humidity=59, device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), DemoHumidifier( @@ -66,6 +68,7 @@ class DemoHumidifier(HumidifierEntity): name: str, mode: str | None, target_humidity: int, + current_humidity: int | None = None, available_modes: list[str] | None = None, is_on: bool = True, device_class: HumidifierDeviceClass | None = None, @@ -77,6 +80,7 @@ class DemoHumidifier(HumidifierEntity): if mode is not None: self._attr_supported_features |= HumidifierEntityFeature.MODES self._attr_target_humidity = target_humidity + self._attr_current_humidity = current_humidity self._attr_mode = mode self._attr_available_modes = available_modes self._attr_device_class = device_class diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 0bc7e242d55..49c3f4681a8 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -30,6 +30,7 @@ from homeassistant.loader import bind_hass from .const import ( # noqa: F401 ATTR_AVAILABLE_MODES, + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, @@ -133,6 +134,7 @@ class HumidifierEntity(ToggleEntity): entity_description: HumidifierEntityDescription _attr_available_modes: list[str] | None + _attr_current_humidity: int | None = None _attr_device_class: HumidifierDeviceClass | None _attr_max_humidity: int = DEFAULT_MAX_HUMIDITY _attr_min_humidity: int = DEFAULT_MIN_HUMIDITY @@ -168,6 +170,9 @@ class HumidifierEntity(ToggleEntity): """Return the optional state attributes.""" data: dict[str, int | str | None] = {} + if self.current_humidity is not None: + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity @@ -176,6 +181,11 @@ class HumidifierEntity(ToggleEntity): return data + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self._attr_current_humidity + @property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 1f802f7fa36..27e181fe63c 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -12,6 +12,7 @@ MODE_AUTO = "auto" MODE_BABY = "baby" ATTR_AVAILABLE_MODES = "available_modes" +ATTR_CURRENT_HUMIDITY = "current_humidity" ATTR_HUMIDITY = "humidity" ATTR_MAX_HUMIDITY = "max_humidity" ATTR_MIN_HUMIDITY = "min_humidity" diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 015b3c08e9a..e4f5961a3cf 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -31,6 +31,9 @@ "available_modes": { "name": "Available modes" }, + "current_humidity": { + "name": "Current humidity" + }, "humidity": { "name": "Target humidity" }, diff --git a/tests/components/demo/test_humidifier.py b/tests/components/demo/test_humidifier.py index d0b1e15ea61..501362d6533 100644 --- a/tests/components/demo/test_humidifier.py +++ b/tests/components/demo/test_humidifier.py @@ -4,6 +4,7 @@ import pytest import voluptuous as vol from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, @@ -43,6 +44,7 @@ def test_setup_params(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_ON assert state.attributes.get(ATTR_HUMIDITY) == 54 + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 59 def test_default_setup_params(hass: HomeAssistant) -> None: From 2b1660c0f72f90b87d5f9d3815305f0a25969f3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jun 2023 02:45:30 -0500 Subject: [PATCH 358/857] Dispatch when esphome static info changes (#94876) --- homeassistant/components/esphome/__init__.py | 53 +++++++++++++------ .../components/esphome/entry_data.py | 4 ++ homeassistant/components/esphome/number.py | 8 +-- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 70c026614c6..d774a1fc663 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -45,7 +45,10 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -709,7 +712,7 @@ async def platform_async_setup_entry( old_infos.pop(info.key) else: # Create new entity - entity = entity_type(entry_data, component_key, info.key, state_type) + entity = entity_type(entry_data, component_key, info, state_type) add_entities.append(entity) new_infos[info.key] = info @@ -722,8 +725,15 @@ async def platform_async_setup_entry( # Then update the actual info entry_data.info[component_key] = new_infos - # Add entities to Home Assistant - async_add_entities(add_entities) + async_dispatcher_send( + hass, + entry_data.signal_component_static_info_updated(component_key), + new_infos, + ) + + if add_entities: + # Add entities to Home Assistant + async_add_entities(add_entities) entry_data.cleanup_callbacks.append( async_dispatcher_connect( @@ -774,18 +784,20 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Define a base esphome entity.""" _attr_should_poll = False + _static_info: _InfoT def __init__( self, entry_data: RuntimeEntryData, component_key: str, - key: int, + entity_info: EntityInfo, state_type: type[_StateT], ) -> None: """Initialize.""" self._entry_data = entry_data self._component_key = component_key - self._key = key + self._key = entity_info.key + self._static_info = cast(_InfoT, entity_info) self._state_type = state_type if entry_data.device_info is not None and entry_data.device_info.friendly_name: self._attr_has_entity_name = True @@ -814,6 +826,25 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._entry_data.signal_component_static_info_updated( + self._component_key + ), + self._on_static_info_update, + ) + ) + + @callback + def _on_static_info_update(self, static_infos: dict[int, EntityInfo]) -> None: + """Save the static info for this entity when it changes. + + This method can be overridden in child classes to know + when the static info changes. + """ + self._static_info = cast(_InfoT, static_infos[self._key]) + @callback def _on_state_update(self) -> None: # Behavior can be changed in child classes @@ -837,16 +868,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): def _api_version(self) -> APIVersion: return self._entry_data.api_version - @property - def _static_info(self) -> _InfoT: - # Check if value is in info database. Use a single lookup. - info = self._entry_data.info[self._component_key].get(self._key) - if info is not None: - return cast(_InfoT, info) - # This entity is in the removal project and has been removed from .info - # already, look in old_info - return cast(_InfoT, self._entry_data.old_info[self._component_key][self._key]) - @property def _device_info(self) -> EsphomeDeviceInfo: assert self._entry_data.device_info is not None diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 9c78e69709e..e4daa524088 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -129,6 +129,10 @@ class RuntimeEntryData: """Return the signal to listen to for updates on static info.""" return f"esphome_{self.entry_id}_on_list" + def signal_component_static_info_updated(self, component_key: str) -> str: + """Return the signal to listen to for updates on static info for a specific component_key.""" + return f"esphome_{self.entry_id}_static_info_updated_{component_key}" + @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: """Update the BLE connection limits.""" diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 3ca8e0b9728..4e27d45bf61 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -52,22 +52,22 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): @property def native_min_value(self) -> float: """Return the minimum value.""" - return super()._static_info.min_value + return self._static_info.min_value @property def native_max_value(self) -> float: """Return the maximum value.""" - return super()._static_info.max_value + return self._static_info.max_value @property def native_step(self) -> float: """Return the increment/decrement step.""" - return super()._static_info.step + return self._static_info.step @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" - return super()._static_info.unit_of_measurement + return self._static_info.unit_of_measurement @property def mode(self) -> NumberMode: From 609a573b555fd5c6b943182cfd785703e5a0521f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 11:16:51 +0200 Subject: [PATCH 359/857] Regenerate instance ID on error (#94898) --- homeassistant/helpers/instance_id.py | 24 ++++++++++++--- tests/helpers/test_instance_id.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/instance_id.py b/homeassistant/helpers/instance_id.py index 8561d10794c..5bb8be5a9fe 100644 --- a/homeassistant/helpers/instance_id.py +++ b/homeassistant/helpers/instance_id.py @@ -1,6 +1,7 @@ """Helper to create a unique instance ID.""" from __future__ import annotations +import logging import uuid from homeassistant.core import HomeAssistant @@ -12,17 +13,30 @@ DATA_VERSION = 1 LEGACY_UUID_FILE = ".uuid" +_LOGGER = logging.getLogger(__name__) + @singleton.singleton(DATA_KEY) async def async_get(hass: HomeAssistant) -> str: """Get unique ID for the hass instance.""" store = storage.Store[dict[str, str]](hass, DATA_VERSION, DATA_KEY, True) - data: dict[str, str] | None = await storage.async_migrator( - hass, - hass.config.path(LEGACY_UUID_FILE), - store, - ) + data: dict[str, str] | None = None + try: + data = await storage.async_migrator( + hass, + hass.config.path(LEGACY_UUID_FILE), + store, + ) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception( + ( + "Could not read hass instance ID from '%s' or '%s', a new instance ID " + "will be generated" + ), + DATA_KEY, + LEGACY_UUID_FILE, + ) if data is not None: return data["uuid"] diff --git a/tests/helpers/test_instance_id.py b/tests/helpers/test_instance_id.py index 453d18b1d2a..8fb6bfd8d7e 100644 --- a/tests/helpers/test_instance_id.py +++ b/tests/helpers/test_instance_id.py @@ -1,7 +1,10 @@ """Tests for instance ID helper.""" +from json import JSONDecodeError from typing import Any from unittest.mock import patch +import pytest + from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id @@ -14,6 +17,25 @@ async def test_get_id_empty(hass: HomeAssistant, hass_storage: dict[str, Any]) - assert hass_storage["core.uuid"]["data"]["uuid"] == uuid +async def test_get_id_load_fail( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """Migrate existing file with error.""" + hass_storage["core.uuid"] = None # Invalid, will make store.async_load raise + + uuid = await instance_id.async_get(hass) + + assert uuid is not None + + # Assert it's stored + assert hass_storage["core.uuid"]["data"]["uuid"] == uuid + + assert ( + "Could not read hass instance ID from 'core.uuid' or '.uuid', a " + "new instance ID will be generated" in caplog.text + ) + + async def test_get_id_migrate( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -30,3 +52,27 @@ async def test_get_id_migrate( # assert old deleted assert len(mock_remove.mock_calls) == 1 + + +async def test_get_id_migrate_fail( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """Migrate existing file with error.""" + with patch( + "homeassistant.util.json.load_json", + side_effect=JSONDecodeError("test_error", "test", 1), + ), patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove: + uuid = await instance_id.async_get(hass) + + assert uuid is not None + + # Assert it's stored + assert hass_storage["core.uuid"]["data"]["uuid"] == uuid + + # assert old not deleted + assert len(mock_remove.mock_calls) == 0 + + assert ( + "Could not read hass instance ID from 'core.uuid' or '.uuid', a " + "new instance ID will be generated" in caplog.text + ) From 2bc5198390853444f1498d561a2a9a4e04ded194 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 13:52:21 +0200 Subject: [PATCH 360/857] Bump hass-nabucassa to 0.68.0 (#94910) * Bump hass-nabucassa to 0.68.0 * Add implementation of new abstract methods --- homeassistant/components/cloud/client.py | 6 ++++++ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index dff3bdcdbdd..65be6a5e2c2 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -182,6 +182,12 @@ class CloudClient(Interface): if tasks: await asyncio.gather(*(task(None) for task in tasks)) + async def cloud_connected(self) -> None: + """When cloud connected.""" + + async def cloud_disconnected(self) -> None: + """When cloud disconnected.""" + async def cloud_started(self) -> None: """When cloud is started.""" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d352b7226f0..1c340216b73 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.67.1"] + "requirements": ["hass-nabucasa==0.68.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7802a76f7a8..4f97ec243c0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ cryptography==41.0.1 dbus-fast==1.86.0 fnv-hash-fast==0.3.1 ha-av==10.1.0 -hass-nabucasa==0.67.1 +hass-nabucasa==0.68.0 hassil==1.0.6 home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230608.0 diff --git a/requirements_all.txt b/requirements_all.txt index cdfaf7dd2ed..65187363de2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -948,7 +948,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.67.1 +hass-nabucasa==0.68.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc2391a769c..954bf8b519e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -743,7 +743,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.67.1 +hass-nabucasa==0.68.0 # homeassistant.components.conversation hassil==1.0.6 From 4a8adae1464a919fc03af7a7df0efefa5af92852 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 14:27:31 +0200 Subject: [PATCH 361/857] Teach alarm_control_panel device trigger about entity registry ids (#60977) * Teach alarm_control_panel device trigger about entity registry ids * Lint code * Address review comment --- .../alarm_control_panel/device_trigger.py | 4 +- homeassistant/helpers/entity_registry.py | 8 +- .../test_device_trigger.py | 74 +++++++++---------- tests/helpers/test_entity_registry.py | 10 +++ 4 files changed, 52 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 9106942c5e5..fc3850dce30 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -46,7 +46,7 @@ TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -72,7 +72,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } triggers += [ diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 15f05e2bd42..29a9def5673 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -470,13 +470,15 @@ class EntityRegistry: return entity_id in self.entities @callback - def async_get(self, entity_id: str) -> RegistryEntry | None: - """Get EntityEntry for an entity_id. + def async_get(self, entity_id_or_uuid: str) -> RegistryEntry | None: + """Get EntityEntry for an entity_id or entity entry id. We retrieve the RegistryEntry from the underlying dict to avoid the overhead of the UserDict __getitem__. """ - return self._entities_data.get(entity_id) + return self._entities_data.get(entity_id_or_uuid) or self.entities.get_entry( + entity_id_or_uuid + ) @callback def async_get_entity_id( diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index d81c83702d8..99270f8747a 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -96,7 +96,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -105,7 +105,7 @@ async def test_get_triggers( ) if set_state: hass.states.async_set( - "alarm_control_panel.test_5678", + entry.entity_id, "attributes", {"supported_features": features_state}, ) @@ -117,7 +117,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entry.id, "metadata": {"secondary": False}, } for trigger in expected_trigger_types @@ -151,7 +151,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -165,7 +165,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entry.id, "metadata": {"secondary": True}, } for trigger in ["triggered", "disarmed", "arming"] @@ -211,10 +211,12 @@ async def test_get_trigger_capabilities( async def test_if_fires_on_state_change( - hass: HomeAssistant, calls: list[ServiceCall] -) -> None: + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +): """Test for turn_on and turn_off triggers firing.""" - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_PENDING) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ALARM_PENDING) assert await async_setup_component( hass, @@ -226,7 +228,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "triggered", }, "action": { @@ -248,7 +250,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "disarmed", }, "action": { @@ -270,7 +272,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "armed_home", }, "action": { @@ -292,7 +294,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "armed_away", }, "action": { @@ -314,7 +316,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "armed_night", }, "action": { @@ -336,7 +338,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "armed_vacation", }, "action": { @@ -358,72 +360,67 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is triggered. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_TRIGGERED) + hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) await hass.async_block_till_done() assert len(calls) == 1 assert ( calls[0].data["some"] - == "triggered - device - alarm_control_panel.entity - pending - triggered -" - " None" + == f"triggered - device - {entry.entity_id} - pending - triggered - None" ) # Fake that the entity is disarmed. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_DISARMED) + hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) await hass.async_block_till_done() assert len(calls) == 2 assert ( calls[1].data["some"] - == "disarmed - device - alarm_control_panel.entity - triggered - disarmed -" - " None" + == f"disarmed - device - {entry.entity_id} - triggered - disarmed - None" ) # Fake that the entity is armed home. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_HOME) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_HOME) await hass.async_block_till_done() assert len(calls) == 3 assert ( calls[2].data["some"] - == "armed_home - device - alarm_control_panel.entity - disarmed - armed_home -" - " None" + == f"armed_home - device - {entry.entity_id} - disarmed - armed_home - None" ) # Fake that the entity is armed away. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_AWAY) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_AWAY) await hass.async_block_till_done() assert len(calls) == 4 assert ( calls[3].data["some"] - == "armed_away - device - alarm_control_panel.entity - armed_home - armed_away" - " - None" + == f"armed_away - device - {entry.entity_id} - armed_home - armed_away - None" ) # Fake that the entity is armed night. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_NIGHT) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_NIGHT) await hass.async_block_till_done() assert len(calls) == 5 assert ( calls[4].data["some"] - == "armed_night - device - alarm_control_panel.entity - armed_away -" - " armed_night - None" + == f"armed_night - device - {entry.entity_id} - armed_away - armed_night - None" ) # Fake that the entity is armed vacation. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_VACATION) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_VACATION) await hass.async_block_till_done() assert len(calls) == 6 assert ( calls[5].data["some"] - == "armed_vacation - device - alarm_control_panel.entity - armed_night -" - " armed_vacation - None" + == f"armed_vacation - device - {entry.entity_id} - armed_night - armed_vacation - None" ) async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] ) -> None: """Test for triggers firing with delay.""" - entity_id = f"{DOMAIN}.entity" - hass.states.async_set(entity_id, STATE_ALARM_DISARMED) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) assert await async_setup_component( hass, @@ -435,7 +432,7 @@ async def test_if_fires_on_state_change_with_for( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.id, "type": "triggered", "for": {"seconds": 5}, }, @@ -459,10 +456,9 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED assert len(calls) == 0 - hass.states.async_set(entity_id, STATE_ALARM_TRIGGERED) + hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -471,5 +467,5 @@ async def test_if_fires_on_state_change_with_for( await hass.async_block_till_done() assert ( calls[0].data["some"] - == f"turn_off device - {entity_id} - disarmed - triggered - 0:00:05" + == f"turn_off device - {entry.entity_id} - disarmed - triggered - 0:00:05" ) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index a0425065775..f1801f181cf 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -34,6 +34,16 @@ def update_events(hass): return events +async def test_get(hass: HomeAssistant, entity_registry: er.EntityRegistry): + """Test we can get an item.""" + entry = entity_registry.async_get_or_create("light", "hue", "1234") + + assert entity_registry.async_get(entry.entity_id) is entry + assert entity_registry.async_get(entry.id) is entry + assert entity_registry.async_get("blah") is None + assert entity_registry.async_get("blah.blah") is None + + async def test_get_or_create_returns_same_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, update_events ) -> None: From 30e8f806c18f058de8760231a03bf32a6145a1d3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 20 Jun 2023 06:24:31 -0700 Subject: [PATCH 362/857] Improve service response data APIs (#94819) * Improve service response data APIs Make the API naming more consistent, and require registration that a service supports response data so that we can better integrate with the UI and avoid user confusion with better error messages. * Improve test coverage * Add an enum for registering response values * Assign enum values * Convert SupportsResponse to StrEnum * Update service call test docstrings * Add tiny missing full stop in comment --------- Co-authored-by: Franck Nijhof --- homeassistant/core.py | 77 ++++++++++++------ homeassistant/helpers/script.py | 2 +- tests/helpers/test_script.py | 10 +-- tests/test_core.py | 136 +++++++++++++++++++++----------- 4 files changed, 147 insertions(+), 78 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 6405b0860e1..ad5fb44a514 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -131,7 +131,7 @@ DOMAIN = "homeassistant" # How long to wait to log tasks that are blocking BLOCK_LOG_TIMEOUT = 60 -ServiceResult = JsonObjectType | None +ServiceResponse = JsonObjectType | None class ConfigSource(StrEnum): @@ -1655,28 +1655,43 @@ class StateMachine: ) +class SupportsResponse(StrEnum): + """Service call response configuration.""" + + NONE = "none" + """The service does not support responses (the default).""" + + OPTIONAL = "optional" + """The service optionally returns response data when asked by the caller.""" + + ONLY = "only" + """The service is read-only and the caller must always ask for response data.""" + + class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema", "domain", "service"] + __slots__ = ["job", "schema", "domain", "service", "supports_response"] def __init__( self, - func: Callable[[ServiceCall], Coroutine[Any, Any, ServiceResult] | None], + func: Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse] | None], schema: vol.Schema | None, domain: str, service: str, context: Context | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Initialize a service.""" self.job = HassJob(func, f"service {domain}.{service}") self.schema = schema + self.supports_response = supports_response class ServiceCall: """Representation of a call to a service.""" - __slots__ = ["domain", "service", "data", "context", "return_values"] + __slots__ = ["domain", "service", "data", "context", "return_response"] def __init__( self, @@ -1684,14 +1699,14 @@ class ServiceCall: service: str, data: dict[str, Any] | None = None, context: Context | None = None, - return_values: bool = False, + return_response: bool = False, ) -> None: """Initialize a service call.""" self.domain = domain.lower() self.service = service.lower() self.data = ReadOnlyDict(data or {}) self.context = context or Context() - self.return_values = return_values + self.return_response = return_response def __repr__(self) -> str: """Return the representation of the service.""" @@ -1738,7 +1753,7 @@ class ServiceRegistry: service: str, service_func: Callable[ [ServiceCall], - Coroutine[Any, Any, ServiceResult] | None, + Coroutine[Any, Any, ServiceResponse] | None, ], schema: vol.Schema | None = None, ) -> None: @@ -1756,9 +1771,10 @@ class ServiceRegistry: domain: str, service: str, service_func: Callable[ - [ServiceCall], Coroutine[Any, Any, ServiceResult] | None + [ServiceCall], Coroutine[Any, Any, ServiceResponse] | None ], schema: vol.Schema | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register a service. @@ -1768,7 +1784,9 @@ class ServiceRegistry: """ domain = domain.lower() service = service.lower() - service_obj = Service(service_func, schema, domain, service) + service_obj = Service( + service_func, schema, domain, service, supports_response=supports_response + ) if domain in self._services: self._services[domain][service] = service_obj @@ -1815,8 +1833,8 @@ class ServiceRegistry: blocking: bool = False, context: Context | None = None, target: dict[str, Any] | None = None, - return_values: bool = False, - ) -> ServiceResult: + return_response: bool = False, + ) -> ServiceResponse: """Call a service. See description of async_call for details. @@ -1829,7 +1847,7 @@ class ServiceRegistry: blocking, context, target, - return_values, + return_response, ), self._hass.loop, ).result() @@ -1842,13 +1860,13 @@ class ServiceRegistry: blocking: bool = False, context: Context | None = None, target: dict[str, Any] | None = None, - return_values: bool = False, - ) -> ServiceResult: + return_response: bool = False, + ) -> ServiceResponse: """Call a service. Specify blocking=True to wait until service is executed. - If return_values=True, indicates that the caller can consume return values + If return_response=True, indicates that the caller can consume return values from the service, if any. Return values are a dict that can be returned by the standard JSON serialization process. Return values can only be used with blocking=True. @@ -1864,14 +1882,25 @@ class ServiceRegistry: context = context or Context() service_data = service_data or {} - if return_values and not blocking: - raise ValueError("Invalid argument return_values=True when blocking=False") - try: handler = self._services[domain][service] except KeyError: raise ServiceNotFound(domain, service) from None + if return_response: + if not blocking: + raise ValueError( + "Invalid argument return_response=True when blocking=False" + ) + if handler.supports_response == SupportsResponse.NONE: + raise ValueError( + "Invalid argument return_response=True when handler does not support responses" + ) + elif handler.supports_response == SupportsResponse.ONLY: + raise ValueError( + "Service call requires responses but caller did not ask for responses" + ) + if target: service_data.update(target) @@ -1890,7 +1919,7 @@ class ServiceRegistry: processed_data = service_data service_call = ServiceCall( - domain, service, processed_data, context, return_values + domain, service, processed_data, context, return_response ) self._hass.bus.async_fire( @@ -1909,7 +1938,7 @@ class ServiceRegistry: return None response_data = await coro - if not return_values: + if not return_response: return None if not isinstance(response_data, dict): raise HomeAssistantError( @@ -1945,19 +1974,19 @@ class ServiceRegistry: async def _execute_service( self, handler: Service, service_call: ServiceCall - ) -> ServiceResult: + ) -> ServiceResponse: """Execute a service.""" if handler.job.job_type == HassJobType.Coroutinefunction: return await cast( - Callable[[ServiceCall], Awaitable[ServiceResult]], + Callable[[ServiceCall], Awaitable[ServiceResponse]], handler.job.target, )(service_call) if handler.job.job_type == HassJobType.Callback: - return cast(Callable[[ServiceCall], ServiceResult], handler.job.target)( + return cast(Callable[[ServiceCall], ServiceResponse], handler.job.target)( service_call ) return await self._hass.async_add_executor_job( - cast(Callable[[ServiceCall], ServiceResult], handler.job.target), + cast(Callable[[ServiceCall], ServiceResponse], handler.job.target), service_call, ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index b876affb9e6..ee4346ff388 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -674,7 +674,7 @@ class _ScriptRun: **params, blocking=True, context=self._context, - return_values=(response_variable is not None), + return_response=(response_variable is not None), ) ), ) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 0868bb5a0cc..de13557024a 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -27,7 +27,7 @@ from homeassistant.core import ( CoreState, HomeAssistant, ServiceCall, - ServiceResult, + ServiceResponse, callback, ) from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound @@ -330,19 +330,19 @@ async def test_calling_service_template(hass: HomeAssistant) -> None: ) -async def test_calling_service_return_values( +async def test_calling_service_response_data( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test the calling of a service with return values.""" context = Context() - def mock_service(call: ServiceCall) -> ServiceResult: + def mock_service(call: ServiceCall) -> ServiceResponse: """Mock service call.""" - if call.return_values: + if call.return_response: return {"data": "value-12345"} return None - hass.services.async_register("test", "script", mock_service) + hass.services.async_register("test", "script", mock_service, supports_response=True) sequence = cv.SCRIPT_SCHEMA( [ { diff --git a/tests/test_core.py b/tests/test_core.py index ebc5718c7cb..8b63eab7b42 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -33,7 +33,14 @@ from homeassistant.const import ( __version__, ) import homeassistant.core as ha -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, ServiceResult, State +from homeassistant.core import ( + HassJob, + HomeAssistant, + ServiceCall, + ServiceResponse, + State, + SupportsResponse, +) from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError, @@ -1083,58 +1090,44 @@ async def test_serviceregistry_callback_service_raise_exception( await hass.async_block_till_done() -async def test_serviceregistry_return_values(hass: HomeAssistant) -> None: - """Test service call for a service that has return values.""" +@pytest.mark.parametrize( + "supports_response", + [ + SupportsResponse.ONLY, + SupportsResponse.OPTIONAL, + ], +) +async def test_serviceregistry_async_return_response( + hass: HomeAssistant, supports_response: SupportsResponse +) -> None: + """Test service call for a service that returns response data.""" - def service_handler(call: ServiceCall) -> ServiceResult: + async def service_handler(call: ServiceCall) -> ServiceResponse: """Service handler coroutine.""" - assert call.return_values + assert call.return_response return {"test-reply": "test-value1"} hass.services.async_register( "test_domain", "test_service", service_handler, + supports_response=supports_response, ) result = await hass.services.async_call( "test_domain", "test_service", service_data={}, blocking=True, - return_values=True, + return_response=True, ) await hass.async_block_till_done() assert result == {"test-reply": "test-value1"} -async def test_serviceregistry_async_return_values(hass: HomeAssistant) -> None: - """Test service call for an async service that has return values.""" - - async def service_handler(call: ServiceCall) -> ServiceResult: - """Service handler coroutine.""" - assert call.return_values - return {"test-reply": "test-value1"} - - hass.services.async_register( - "test_domain", - "test_service", - service_handler, - ) - result = await hass.services.async_call( - "test_domain", - "test_service", - service_data={}, - blocking=True, - return_values=True, - ) - await hass.async_block_till_done() - assert result == {"test-reply": "test-value1"} - - -async def test_services_call_return_values_requires_blocking( +async def test_services_call_return_response_requires_blocking( hass: HomeAssistant, ) -> None: - """Test that non-blocking service calls cannot return values.""" + """Test that non-blocking service calls cannot ask for response data.""" async_mock_service(hass, "test_domain", "test_service") with pytest.raises(ValueError, match="when blocking=False"): await hass.services.async_call( @@ -1142,12 +1135,12 @@ async def test_services_call_return_values_requires_blocking( "test_service", service_data={}, blocking=False, - return_values=True, + return_response=True, ) @pytest.mark.parametrize( - ("return_value", "expected_error"), + ("response_data", "expected_error"), [ (True, "expected a dictionary"), (False, "expected a dictionary"), @@ -1156,20 +1149,21 @@ async def test_services_call_return_values_requires_blocking( (["some-list"], "expected a dictionary"), ], ) -async def test_serviceregistry_return_values_invalid( - hass: HomeAssistant, return_value: Any, expected_error: str +async def test_serviceregistry_return_response_invalid( + hass: HomeAssistant, response_data: Any, expected_error: str ) -> None: - """Test service call return values are not returned when there is no result schema.""" + """Test service call response data must be json serializable objects.""" - def service_handler(call: ServiceCall) -> ServiceResult: + def service_handler(call: ServiceCall) -> ServiceResponse: """Service handler coroutine.""" - assert call.return_values - return return_value + assert call.return_response + return response_data hass.services.async_register( "test_domain", "test_service", service_handler, + supports_response=SupportsResponse.ONLY, ) with pytest.raises(HomeAssistantError, match=expected_error): await hass.services.async_call( @@ -1177,32 +1171,78 @@ async def test_serviceregistry_return_values_invalid( "test_service", service_data={}, blocking=True, - return_values=True, + return_response=True, ) await hass.async_block_till_done() -async def test_serviceregistry_no_return_values(hass: HomeAssistant) -> None: - """Test service call data when not asked for return values.""" +@pytest.mark.parametrize( + ("supports_response", "return_response", "expected_error"), + [ + (SupportsResponse.NONE, True, "not support responses"), + (SupportsResponse.ONLY, False, "caller did not ask for responses"), + ], +) +async def test_serviceregistry_return_response_arguments( + hass: HomeAssistant, + supports_response: SupportsResponse, + return_response: bool, + expected_error: str, +) -> None: + """Test service call response data invalid arguments.""" - def service_handler(call: ServiceCall) -> None: + hass.services.async_register( + "test_domain", + "test_service", + "service_handler", + supports_response=supports_response, + ) + + with pytest.raises(ValueError, match=expected_error): + await hass.services.async_call( + "test_domain", + "test_service", + service_data={}, + blocking=True, + return_response=return_response, + ) + + +@pytest.mark.parametrize( + ("return_response", "expected_response_data"), + [ + (True, {"key": "value"}), + (False, None), + ], +) +async def test_serviceregistry_return_response_optional( + hass: HomeAssistant, + return_response: bool, + expected_response_data: Any, +) -> None: + """Test optional service call response data.""" + + def service_handler(call: ServiceCall) -> ServiceResponse: """Service handler coroutine.""" - assert not call.return_values - return + if call.return_response: + return {"key": "value"} + return None hass.services.async_register( "test_domain", "test_service", service_handler, + supports_response=SupportsResponse.OPTIONAL, ) - result = await hass.services.async_call( + response_data = await hass.services.async_call( "test_domain", "test_service", service_data={}, blocking=True, + return_response=return_response, ) await hass.async_block_till_done() - assert not result + assert response_data == expected_response_data async def test_config_defaults() -> None: From b51dcb600ea7f8abd123c4ababc2a816e36c77c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jun 2023 14:48:28 +0100 Subject: [PATCH 363/857] Avoid enumerating the whole state machine to find zone entities (#94866) --- homeassistant/components/zone/__init__.py | 37 +++++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index b7212e91091..35d835c8f16 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -99,6 +99,8 @@ STORAGE_VERSION = 1 ENTITY_ID_SORTER = attrgetter("entity_id") +ZONE_ENTITY_IDS = "zone_entity_ids" + @bind_hass def async_active_zone( @@ -111,9 +113,15 @@ def async_active_zone( # Sort entity IDs so that we are deterministic if equal distance to 2 zones min_dist = None closest = None - - for zone in sorted(hass.states.async_all(DOMAIN), key=ENTITY_ID_SORTER): - if zone.state == STATE_UNAVAILABLE or zone.attributes.get(ATTR_PASSIVE): + # This can be called before async_setup by device tracker + zone_entity_ids: list[str] = hass.data.get(ZONE_ENTITY_IDS, []) + for entity_id in zone_entity_ids: + zone = hass.states.get(entity_id) + if ( + not zone + or zone.state == STATE_UNAVAILABLE + or zone.attributes.get(ATTR_PASSIVE) + ): continue zone_dist = distance( @@ -141,6 +149,27 @@ def async_active_zone( return closest +@callback +def async_setup_track_zone_entity_ids(hass: HomeAssistant) -> None: + """Set up track of entity IDs for zones.""" + zone_entity_ids: list[str] = hass.states.async_entity_ids(DOMAIN) + hass.data[ZONE_ENTITY_IDS] = zone_entity_ids + + @callback + def _async_add_zone_entity_id(event_: Event) -> None: + """Add zone entity ID.""" + zone_entity_ids.append(event_.data[ATTR_ENTITY_ID]) + zone_entity_ids.sort() + + @callback + def _async_remove_zone_entity_id(event_: Event) -> None: + """Remove zone entity ID.""" + zone_entity_ids.remove(event_.data[ATTR_ENTITY_ID]) + + event.async_track_state_added_domain(hass, DOMAIN, _async_add_zone_entity_id) + event.async_track_state_removed_domain(hass, DOMAIN, _async_remove_zone_entity_id) + + def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) -> bool: """Test if given latitude, longitude is in given zone. @@ -184,6 +213,8 @@ class ZoneStorageCollection(collection.DictStorageCollection): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up configured zones as well as Home Assistant zone if necessary.""" + async_setup_track_zone_entity_ids(hass) + component = entity_component.EntityComponent[Zone](_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() From 3f18f515e7baf70ba46d8d6048f3f8ff8e8aa5dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jun 2023 15:21:24 +0100 Subject: [PATCH 364/857] Improve websocket api coverage and typing (#94891) --- .../components/websocket_api/auth.py | 28 ++-- .../components/websocket_api/connection.py | 8 +- .../components/websocket_api/http.py | 29 ++-- tests/components/websocket_api/test_auth.py | 77 ++++++++++ tests/components/websocket_api/test_http.py | 135 ++++++++++++++++++ 5 files changed, 246 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 9c074588a17..d0831f2e90e 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -12,6 +12,7 @@ from homeassistant.auth.models import RefreshToken, User from homeassistant.components.http.ban import process_success_login, process_wrong_login from homeassistant.const import __version__ from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.util.json import JsonValueType from .connection import ActiveConnection from .error import Disconnect @@ -67,10 +68,10 @@ class AuthPhase: self._logger = logger self._request = request - async def async_handle(self, msg: dict[str, str]) -> ActiveConnection: + async def async_handle(self, msg: JsonValueType) -> ActiveConnection: """Handle authentication.""" try: - msg = AUTH_MESSAGE_SCHEMA(msg) + valid_msg = AUTH_MESSAGE_SCHEMA(msg) except vol.Invalid as err: error_msg = ( f"Auth message incorrectly formatted: {humanize_error(msg, err)}" @@ -79,20 +80,19 @@ class AuthPhase: self._send_message(auth_invalid_message(error_msg)) raise Disconnect from err - if "access_token" in msg: - self._logger.debug("Received access_token") - refresh_token = await self._hass.auth.async_validate_access_token( - msg["access_token"] + if (access_token := valid_msg.get("access_token")) and ( + refresh_token := await self._hass.auth.async_validate_access_token( + access_token + ) + ): + conn = await self._async_finish_auth(refresh_token.user, refresh_token) + conn.subscriptions[ + "auth" + ] = self._hass.auth.async_register_revoke_token_callback( + refresh_token.id, self._cancel_ws ) - if refresh_token is not None: - conn = await self._async_finish_auth(refresh_token.user, refresh_token) - conn.subscriptions[ - "auth" - ] = self._hass.auth.async_register_revoke_token_callback( - refresh_token.id, self._cancel_ws - ) - return conn + return conn self._send_message(auth_invalid_message("Invalid access token or password")) await process_wrong_login(self._request) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index a91a5178830..c07661893f7 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -13,6 +13,7 @@ from homeassistant.auth.models import RefreshToken, User from homeassistant.components.http import current_request from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.util.json import JsonValueType from . import const, messages from .util import describe_request @@ -144,7 +145,7 @@ class ActiveConnection: self.binary_handlers[index] = None @callback - def async_handle(self, msg: dict[str, Any]) -> None: + def async_handle(self, msg: JsonValueType) -> None: """Handle a single incoming message.""" if ( # Not using isinstance as we don't care about children @@ -157,10 +158,11 @@ class ActiveConnection: or type(type_) is not str # pylint: disable=unidiomatic-typecheck ) ): - self.logger.error("Received invalid command", msg) + self.logger.error("Received invalid command: %s", msg) + id_ = msg.get("id") if isinstance(msg, dict) else 0 self.send_message( messages.error_message( - msg.get("id"), + id_, # type: ignore[arg-type] const.ERR_INVALID_FORMAT, "Message incorrectly formatted.", ) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 54daf89d8dd..6ac0e10a76c 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -56,8 +56,7 @@ class WebSocketAdapter(logging.LoggerAdapter): def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: """Add connid to websocket log messages.""" - if not self.extra or "connid" not in self.extra: - return msg, kwargs + assert self.extra is not None return f'[{self.extra["connid"]}] {msg}', kwargs @@ -81,7 +80,7 @@ class WebSocketHandler: # to where messages are queued. This allows the implementation # to use a deque and an asyncio.Future to avoid the overhead of # an asyncio.Queue. - self._message_queue: deque = deque() + self._message_queue: deque[str | Callable[[], str] | None] = deque() self._ready_future: asyncio.Future[None] | None = None def __repr__(self) -> str: @@ -302,14 +301,14 @@ class WebSocketHandler: raise Disconnect try: - msg_data = msg.json(loads=json_loads) + auth_msg_data = json_loads(msg.data) except ValueError as err: disconnect_warn = "Received invalid JSON." raise Disconnect from err if is_enabled_for(logging_debug): - debug("%s: Received %s", self.description, msg_data) - connection = await auth.async_handle(msg_data) + debug("%s: Received %s", self.description, auth_msg_data) + connection = await auth.async_handle(auth_msg_data) self._connection = connection hass.data[DATA_CONNECTIONS] = hass.data.get(DATA_CONNECTIONS, 0) + 1 async_dispatcher_send(hass, SIGNAL_WEBSOCKET_CONNECTED) @@ -317,7 +316,7 @@ class WebSocketHandler: self._authenticated = True # # - # Our websocket implementation is backed by an asyncio.Queue + # Our websocket implementation is backed by a deque # # As back-pressure builds, the queue will back up and use more memory # until we disconnect the client when the queue size reaches @@ -351,6 +350,8 @@ class WebSocketHandler: # reach the code to set the limit, so we have to set it directly. # wsock._writer._limit = 2**20 # type: ignore[union-attr] # pylint: disable=protected-access + async_handle_str = connection.async_handle + async_handle_binary = connection.async_handle_binary # Command phase while not wsock.closed: @@ -365,7 +366,7 @@ class WebSocketHandler: break handler = msg.data[0] payload = msg.data[1:] - connection.async_handle_binary(handler, payload) + async_handle_binary(handler, payload) continue if msg.type != WSMsgType.TEXT: @@ -373,20 +374,20 @@ class WebSocketHandler: break try: - msg_data = msg.json(loads=json_loads) + command_msg_data = json_loads(msg.data) except ValueError: disconnect_warn = "Received invalid JSON." break if is_enabled_for(logging_debug): - debug("%s: Received %s", self.description, msg_data) + debug("%s: Received %s", self.description, command_msg_data) - if not isinstance(msg_data, list): - connection.async_handle(msg_data) + if not isinstance(command_msg_data, list): + async_handle_str(command_msg_data) continue - for split_msg in msg_data: - connection.async_handle(split_msg) + for split_msg in command_msg_data: + async_handle_str(split_msg) except asyncio.CancelledError: debug("%s: Connection cancelled", self.description) diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 070bd68d44a..51bff1af0d7 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -2,6 +2,7 @@ from unittest.mock import patch import aiohttp +from aiohttp import WSMsgType import pytest from homeassistant.auth.providers.legacy_api_password import ( @@ -223,3 +224,79 @@ async def test_auth_close_after_revoke( msg = await websocket_client.receive() assert msg.type == aiohttp.WSMsgType.CLOSE assert websocket_client.closed + + +async def test_auth_sending_invalid_json_disconnects( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test sending invalid json during auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_str("[--INVALID--JSON--]") + + auth_msg = await ws.receive() + assert auth_msg.type == WSMsgType.close + + +async def test_auth_sending_binary_disconnects( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test sending bytes during auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_bytes(b"[INVALID]") + + auth_msg = await ws.receive() + assert auth_msg.type == WSMsgType.close + + +async def test_auth_close_disconnects( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test closing during auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.close() + + auth_msg = await ws.receive() + assert auth_msg.type == WSMsgType.CLOSED + + +async def test_auth_sending_unknown_type_disconnects( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test sending unknown type during auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + # pylint: disable-next=protected-access + await ws._writer._send_frame(b"1" * 130, 0x30) + auth_msg = await ws.receive() + assert auth_msg.type == WSMsgType.close diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 3205d40b52d..b94df47213e 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -136,6 +136,89 @@ async def test_cleanup_on_cancellation( assert len(subscriptions) == 0 +async def test_delayed_response_handler( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a handler that responds after a connection has already been closed.""" + + subscriptions = None + + # Register a handler that responds after it returns + @callback + @websocket_command( + { + "type": "late_responder", + } + ) + def async_late_responder( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ) -> None: + msg_id: int = msg["id"] + nonlocal subscriptions + subscriptions = connection.subscriptions + connection.subscriptions[msg_id] = lambda: None + connection.send_result(msg_id) + + async def _async_late_send_message(): + await asyncio.sleep(0.05) + connection.send_event(msg_id, {"event": "any"}) + + hass.async_create_task(_async_late_send_message()) + + async_register_command(hass, async_late_responder) + + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert not subscriptions + await websocket_client.send_json({"id": 2, "type": "late_responder"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "result" + assert len(subscriptions) == 2 + assert await websocket_client.close() + await hass.async_block_till_done() + assert len(subscriptions) == 0 + + assert "Tried to send message" in caplog.text + assert "on closed connection" in caplog.text + + +async def test_ensure_disconnect_invalid_json( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we get disconnected when sending invalid JSON.""" + + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + await websocket_client.send_str("[--INVALID-JSON--]") + msg = await websocket_client.receive() + assert msg.type == WSMsgType.CLOSE + + +async def test_ensure_disconnect_invalid_binary( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we get disconnected when sending invalid bytes.""" + + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + await websocket_client.send_bytes(b"") + msg = await websocket_client.receive() + assert msg.type == WSMsgType.CLOSE + + async def test_pending_msg_peak( hass: HomeAssistant, mock_low_peak, @@ -299,6 +382,58 @@ async def test_prepare_fail( assert "Timeout preparing request" in caplog.text +async def test_enable_coalesce( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enabling coalesce.""" + websocket_client = await hass_ws_client(hass) + + await websocket_client.send_json( + { + "id": 1, + "type": "supported_features", + "features": {const.FEATURE_COALESCE_MESSAGES: 1}, + } + ) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["success"] is True + send_tasks: list[asyncio.Future] = [] + ids: set[int] = set() + start_id = 2 + + for idx in range(10): + id_ = idx + start_id + ids.add(id_) + send_tasks.append(websocket_client.send_json({"id": id_, "type": "ping"})) + + await asyncio.gather(*send_tasks) + returned_ids: set[int] = set() + for _ in range(10): + msg = await websocket_client.receive_json() + assert msg["type"] == "pong" + returned_ids.add(msg["id"]) + + assert ids == returned_ids + + # Now close + send_tasks_with_close: list[asyncio.Future] = [] + start_id = 12 + for idx in range(10): + id_ = idx + start_id + send_tasks_with_close.append( + websocket_client.send_json({"id": id_, "type": "ping"}) + ) + + send_tasks_with_close.append(websocket_client.close()) + send_tasks_with_close.append(websocket_client.send_json({"id": 50, "type": "ping"})) + + with pytest.raises(ConnectionResetError): + await asyncio.gather(*send_tasks_with_close) + + async def test_binary_message( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: From 50605e62bd05b8f753c1db7c64e39470e16a2540 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 20 Jun 2023 07:32:03 -0700 Subject: [PATCH 365/857] Bump ical to 4.5.4 (#94894) --- homeassistant/components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 049f9de03ea..b56acffe4e2 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.5.1"] + "requirements": ["ical==4.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65187363de2..d6573ea8936 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1023,7 +1023,7 @@ ibeacon-ble==1.0.1 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.5.1 +ical==4.5.4 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 954bf8b519e..23885e2c5e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -797,7 +797,7 @@ iaqualink==0.5.0 ibeacon-ble==1.0.1 # homeassistant.components.local_calendar -ical==4.5.1 +ical==4.5.4 # homeassistant.components.ping icmplib==3.0 From b600c2cd8545a9f731c3518a45b10ef783620d9d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 17:15:13 +0200 Subject: [PATCH 366/857] Add cloud_connected method to CloudClient (#91997) * Add cloud_connected method to CloudClient * Add cloud_disconnected method * Update client.py --- homeassistant/components/cloud/__init__.py | 1 - homeassistant/components/cloud/client.py | 5 +---- tests/components/cloud/test_client.py | 4 ++-- tests/components/cloud/test_init.py | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 620e650315a..40e5f264caf 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -234,7 +234,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websession = async_get_clientsession(hass) client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) - cloud.iot.register_on_connect(client.on_cloud_connected) async def _shutdown(event: Event) -> None: """Shutdown event.""" diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 65be6a5e2c2..76e5268fb89 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -135,7 +135,7 @@ class CloudClient(Interface): return self._google_config - async def on_cloud_connected(self) -> None: + async def cloud_connected(self) -> None: """When cloud is connected.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) @@ -182,9 +182,6 @@ class CloudClient(Interface): if tasks: await asyncio.gather(*(task(None) for task in tasks)) - async def cloud_connected(self) -> None: - """When cloud connected.""" - async def cloud_disconnected(self) -> None: """When cloud disconnected.""" diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 534456896b4..5ea8d79729b 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -306,7 +306,7 @@ async def test_set_username(hass: HomeAssistant) -> None: ) client = CloudClient(hass, prefs, None, {}, {}) client.cloud = MagicMock(is_logged_in=True, username="mock-username") - await client.on_cloud_connected() + await client.cloud_connected() assert len(prefs.async_set_username.mock_calls) == 1 assert prefs.async_set_username.mock_calls[0][1][0] == "mock-username" @@ -326,7 +326,7 @@ async def test_login_recovers_bad_internet( client._alexa_config = Mock( async_enable_proactive_mode=Mock(side_effect=aiohttp.ClientError) ) - await client.on_cloud_connected() + await client.cloud_connected() assert len(client._alexa_config.async_enable_proactive_mode.mock_calls) == 1 assert "Unable to activate Alexa Report State" in caplog.text diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index f56789729d8..28b531b608c 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -137,7 +137,7 @@ async def test_on_connect(hass: HomeAssistant, mock_cloud_fixture) -> None: """Test cloud on connect triggers.""" cl: Cloud[cloud.client.CloudClient] = hass.data["cloud"] - assert len(cl.iot._on_connect) == 4 + assert len(cl.iot._on_connect) == 3 assert len(hass.states.async_entity_ids("binary_sensor")) == 0 From 45616b8127242a7e6d44b45739043f20b7220406 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 20:07:12 +0200 Subject: [PATCH 367/857] Follow redirects in generic camera (#94931) --- homeassistant/components/generic/camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index b039b32d73d..234795e9014 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -200,7 +200,7 @@ class GenericCamera(Camera): try: async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) response = await async_client.get( - url, auth=self._auth, timeout=GET_IMAGE_TIMEOUT + url, auth=self._auth, follow_redirects=True, timeout=GET_IMAGE_TIMEOUT ) response.raise_for_status() self._last_image = response.content From 1d2a9732890820da1dbafd55d80ca15bd1d84b60 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 20 Jun 2023 21:10:21 +0300 Subject: [PATCH 368/857] Generic hygrostat current humidity (#94912) --- .../components/generic_hygrostat/humidifier.py | 5 +++++ .../generic_hygrostat/test_humidifier.py | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index a6e76330f29..be36da643e9 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -248,6 +248,11 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): """Return true if the hygrostat is on.""" return self._state + @property + def current_humidity(self): + """Return the measured humidity.""" + return self._cur_humidity + @property def target_humidity(self): """Return the humidity we try to reach.""" diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index a87f2472fd3..341571fe9ad 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -414,16 +414,26 @@ async def test_set_away_mode_twice_and_restore_prev_humidity( assert state.attributes.get("humidity") == 44 +async def test_sensor_affects_attribute(hass: HomeAssistant, setup_comp_2) -> None: + """Test that the sensor changes are reflected in the current_humidity attribute.""" + state = hass.states.get(ENTITY) + assert state.attributes.get("current_humidity") == 45 + + _setup_sensor(hass, 47) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY) + assert state.attributes.get("current_humidity") == 47 + + async def test_sensor_bad_value(hass: HomeAssistant, setup_comp_2) -> None: """Test sensor that have None as state.""" - state = hass.states.get(ENTITY) - humidity = state.attributes.get("current_humidity") + assert hass.states.get(ENTITY).state == STATE_ON _setup_sensor(hass, None) await hass.async_block_till_done() - state = hass.states.get(ENTITY) - assert humidity == state.attributes.get("current_humidity") + assert hass.states.get(ENTITY).state == STATE_UNAVAILABLE async def test_set_target_humidity_humidifier_on( From c4d7695173d29e2b20d014639c2d641d15ee7470 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 20 Jun 2023 20:15:42 +0200 Subject: [PATCH 369/857] Add current_humidity attribute to xiaomi_miio humidifiers (#94934) --- homeassistant/components/xiaomi_miio/humidifier.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 6fde33309e4..82ede87848e 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -14,6 +14,7 @@ from miio.integrations.humidifier.zhimi.airhumidifier_miot import ( ) from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -46,6 +47,7 @@ ATTR_TARGET_HUMIDITY = "target_humidity" AVAILABLE_ATTRIBUTES = { ATTR_MODE: "mode", ATTR_TARGET_HUMIDITY: "target_humidity", + ATTR_HUMIDITY: "humidity", } AVAILABLE_MODES_CA1_CB1 = [ @@ -199,6 +201,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): } ) self._target_humidity = self._attributes[ATTR_TARGET_HUMIDITY] + self._attr_current_humidity = self._attributes[ATTR_HUMIDITY] self._mode = self._attributes[ATTR_MODE] @property @@ -217,6 +220,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): } ) self._target_humidity = self._attributes[ATTR_TARGET_HUMIDITY] + self._attr_current_humidity = self._attributes[ATTR_HUMIDITY] self._mode = self._attributes[ATTR_MODE] self.async_write_ha_state() From fd822bce56ca43a58a10db66e6395c337136ba4b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 20:16:25 +0200 Subject: [PATCH 370/857] Replace assert_lists_same with pytest_unordered in integrations h-m (#94901) --- tests/components/homeassistant_alerts/test_init.py | 13 ++++++------- .../homekit_controller/test_device_trigger.py | 8 ++++---- tests/components/hue/test_device_trigger_v1.py | 8 +++++--- tests/components/hue/test_device_trigger_v2.py | 4 ++-- tests/components/humidifier/test_device_action.py | 6 +++--- .../components/humidifier/test_device_condition.py | 6 +++--- tests/components/humidifier/test_device_trigger.py | 6 +++--- tests/components/lcn/test_device_trigger.py | 5 +++-- tests/components/light/test_device_action.py | 6 +++--- tests/components/light/test_device_condition.py | 4 ++-- tests/components/light/test_device_trigger.py | 6 +++--- tests/components/lock/test_device_action.py | 6 +++--- tests/components/lock/test_device_condition.py | 6 +++--- tests/components/lock/test_device_trigger.py | 6 +++--- .../lutron_caseta/test_device_trigger.py | 4 ++-- .../media_player/test_device_condition.py | 6 +++--- .../components/media_player/test_device_trigger.py | 6 +++--- tests/components/mqtt/test_device_trigger.py | 14 +++++++------- 18 files changed, 61 insertions(+), 59 deletions(-) diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index 7f060b09cf9..c772c088505 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, patch from freezegun.api import FrozenDateTimeFactory import pytest +from pytest_unordered import unordered from homeassistant.components.homeassistant_alerts import ( COMPONENT_LOADED_COOLDOWN, @@ -19,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import ATTR_COMPONENT, async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_lists_same, async_fire_time_changed, load_fixture +from tests.common import async_fire_time_changed, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -613,8 +614,7 @@ async def test_alerts_change( await client.send_json({"id": 1, "type": "repairs/list_issues"}) msg = await client.receive_json() assert msg["success"] - assert_lists_same( - msg["result"]["issues"], + assert msg["result"]["issues"] == unordered( [ { "breaks_in_ha_version": None, @@ -634,7 +634,7 @@ async def test_alerts_change( }, } for alert_id, integration in expected_alerts_1 - ], + ] ) fixture_2_content = load_fixture(fixture_2, "homeassistant_alerts") @@ -653,8 +653,7 @@ async def test_alerts_change( await client.send_json({"id": 2, "type": "repairs/list_issues"}) msg = await client.receive_json() assert msg["success"] - assert_lists_same( - msg["result"]["issues"], + assert msg["result"]["issues"] == unordered( [ { "breaks_in_ha_version": None, @@ -674,5 +673,5 @@ async def test_alerts_change( }, } for alert_id, integration in expected_alerts_2 - ], + ] ) diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index d98c07a1728..4588941bcbf 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -2,6 +2,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -14,7 +15,6 @@ from homeassistant.setup import async_setup_component from .common import setup_test_component from tests.common import ( - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -131,7 +131,7 @@ async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) - assert_lists_same(triggers, expected) + assert triggers == unordered(expected) async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: @@ -178,7 +178,7 @@ async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) - assert_lists_same(triggers, expected) + assert triggers == unordered(expected) async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: @@ -225,7 +225,7 @@ async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) - assert_lists_same(triggers, expected) + assert triggers == unordered(expected) async def test_handle_events(hass: HomeAssistant, utcnow, calls) -> None: diff --git a/tests/components/hue/test_device_trigger_v1.py b/tests/components/hue/test_device_trigger_v1.py index 01add6a7dfd..283215be328 100644 --- a/tests/components/hue/test_device_trigger_v1.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -1,4 +1,6 @@ """The tests for Philips Hue device triggers for V1 bridge.""" +from pytest_unordered import unordered + from homeassistant.components import automation, hue from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.hue.v1 import device_trigger @@ -8,7 +10,7 @@ from homeassistant.setup import async_setup_component from .conftest import setup_platform from .test_sensor_v1 import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1 -from tests.common import assert_lists_same, async_get_device_automations +from tests.common import async_get_device_automations REMOTES_RESPONSE = {"7": HUE_TAP_REMOTE_1, "8": HUE_DIMMER_REMOTE_1} @@ -41,7 +43,7 @@ async def test_get_triggers(hass: HomeAssistant, mock_bridge_v1, device_reg) -> } for t_type, t_subtype in device_trigger.HUE_TAP_REMOTE ] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) # Get triggers for specific dimmer switch hue_dimmer_device = device_reg.async_get_device( @@ -73,7 +75,7 @@ async def test_get_triggers(hass: HomeAssistant, mock_bridge_v1, device_reg) -> for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE ), ] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_state_change( diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index 81410b0658f..a15324a5c8f 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -1,5 +1,6 @@ """The tests for Philips Hue device triggers for V2 bridge.""" from aiohue.v2.models.button import ButtonEvent +from pytest_unordered import unordered from homeassistant.components import hue from homeassistant.components.device_automation import DeviceAutomationType @@ -10,7 +11,6 @@ from homeassistant.core import HomeAssistant from .conftest import setup_platform from tests.common import ( - assert_lists_same, async_capture_events, async_get_device_automations, ) @@ -96,4 +96,4 @@ async def test_get_triggers( ), ] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 3312c263458..9c6de7adffe 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Humidifier device actions.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -89,7 +89,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -138,7 +138,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index dcd4245e401..06356c64260 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Humidifier device conditions.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -97,7 +97,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -145,7 +145,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index e6e0d4bdb4d..350f3d64a11 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -2,6 +2,7 @@ import datetime import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -26,7 +27,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automations, async_mock_service, @@ -89,7 +89,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -142,7 +142,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index a4fb396c700..637aeec1b0b 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -2,6 +2,7 @@ from pypck.inputs import ModSendKeysHost, ModStatusAccessControl from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand +from pytest_unordered import unordered import voluptuous_serialize from homeassistant.components import automation @@ -15,7 +16,7 @@ from homeassistant.setup import async_setup_component from .conftest import get_device -from tests.common import assert_lists_same, async_get_device_automations +from tests.common import async_get_device_automations async def test_get_triggers_module_device( @@ -44,7 +45,7 @@ async def test_get_triggers_module_device( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_non_module_device( diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index ef1d653fbd9..46967d17f87 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -1,5 +1,6 @@ """The test for light device automation.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -19,7 +20,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -78,7 +78,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -128,7 +128,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_get_action_capabilities( diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 42790cc91cb..b57a2fe8dfe 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -3,6 +3,7 @@ from datetime import timedelta from freezegun import freeze_time import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -16,7 +17,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -111,7 +111,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_get_condition_capabilities( diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 18f3555aaf7..d0b6eaec2aa 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -15,7 +16,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -63,7 +63,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 682467bf475..ed0ce279498 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Lock device actions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -84,7 +84,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -133,7 +133,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 32341d15e7b..765e5d3b5a7 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Lock device conditions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -19,7 +20,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -71,7 +71,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -125,7 +125,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 13340fbe668..54d2afcacb6 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -22,7 +23,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -70,7 +70,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -118,7 +118,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 325cb79e1a8..3fafbb8a57f 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType @@ -39,7 +40,6 @@ from . import MockBridge from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -165,7 +165,7 @@ async def test_get_triggers(hass: HomeAssistant) -> None: hass, DeviceAutomationType.TRIGGER, device_id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_for_invalid_device_id( diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index d70a9a90893..59626701812 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Media player device conditions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -20,7 +21,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -73,7 +73,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -128,7 +128,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index d8d91c3495b..c899bf2ce75 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -23,7 +24,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -81,7 +81,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -137,7 +137,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 9954b0f9ba4..9f3b7565332 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -3,6 +3,7 @@ import json from unittest.mock import patch import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from .test_common import help_test_unload_config_entry from tests.common import ( - assert_lists_same, async_fire_mqtt_message, async_get_device_automations, async_mock_service, @@ -79,7 +79,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_unknown_triggers( @@ -126,7 +126,7 @@ async def test_get_unknown_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, []) + assert triggers == [] async def test_get_non_existing_triggers( @@ -149,7 +149,7 @@ async def test_get_non_existing_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, []) + assert triggers == [] @pytest.mark.no_fail_on_log_exception @@ -200,7 +200,7 @@ async def test_discover_bad_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_update_remove_triggers( @@ -253,7 +253,7 @@ async def test_update_remove_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers1) + assert triggers == unordered(expected_triggers1) # Update trigger async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data2) @@ -262,7 +262,7 @@ async def test_update_remove_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers2) + assert triggers == unordered(expected_triggers2) # Remove trigger async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", "") From 6a29ed8caa41ac99bac71e04c44b524d51e30763 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 20:22:31 +0200 Subject: [PATCH 371/857] Replace assert_lists_same with pytest_unordered in integrations a-f (#94900) --- .../components/alarm_control_panel/test_device_action.py | 8 ++++---- .../alarm_control_panel/test_device_condition.py | 6 +++--- .../components/alarm_control_panel/test_device_trigger.py | 6 +++--- tests/components/binary_sensor/test_device_condition.py | 6 +++--- tests/components/binary_sensor/test_device_trigger.py | 8 ++++---- tests/components/button/test_device_action.py | 6 +++--- tests/components/button/test_device_trigger.py | 6 +++--- tests/components/climate/test_device_action.py | 6 +++--- tests/components/climate/test_device_condition.py | 6 +++--- tests/components/climate/test_device_trigger.py | 6 +++--- tests/components/cover/test_device_action.py | 6 +++--- tests/components/cover/test_device_condition.py | 6 +++--- tests/components/cover/test_device_trigger.py | 6 +++--- tests/components/deconz/test_device_trigger.py | 8 ++++---- tests/components/device_tracker/test_device_condition.py | 6 +++--- tests/components/device_tracker/test_device_trigger.py | 6 +++--- tests/components/fan/test_device_action.py | 6 +++--- tests/components/fan/test_device_condition.py | 6 +++--- tests/components/fan/test_device_trigger.py | 6 +++--- 19 files changed, 60 insertions(+), 60 deletions(-) diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index c587d94f3ed..822076240c6 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Alarm control panel device actions.""" import pytest +from pytest_unordered import unordered from homeassistant.components.alarm_control_panel import ( DOMAIN, @@ -24,7 +25,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, ) @@ -127,7 +127,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -176,7 +176,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_get_actions_arm_night_only( @@ -216,7 +216,7 @@ async def test_get_actions_arm_night_only( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_get_action_capabilities( diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index b1687a385b3..e1ff9cd90e3 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Alarm control panel device conditions.""" import pytest +from pytest_unordered import unordered from homeassistant.components.alarm_control_panel import ( DOMAIN, @@ -23,7 +24,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -133,7 +133,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -181,7 +181,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 99270f8747a..1956c4ac55e 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered from homeassistant.components.alarm_control_panel import ( DOMAIN, @@ -26,7 +27,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -125,7 +125,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -173,7 +173,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index b1a0296d0d3..d19a761ef35 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -3,6 +3,7 @@ from datetime import timedelta from freezegun import freeze_time import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass @@ -16,7 +17,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -75,7 +75,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -123,7 +123,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_get_conditions_no_state( diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 49ae9e017ca..a2e9fefaa41 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass @@ -15,7 +16,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -75,7 +75,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -123,7 +123,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_no_state( @@ -168,7 +168,7 @@ async def test_get_triggers_no_state( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py index 4c84faf18af..81d8d2971d7 100644 --- a/tests/components/button/test_device_action.py +++ b/tests/components/button/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Button device actions.""" import pytest +from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.button import DOMAIN @@ -11,7 +12,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -44,7 +44,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -92,7 +92,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py index 0987a7753be..4e3be3eba67 100644 --- a/tests/components/button/test_device_trigger.py +++ b/tests/components/button/test_device_trigger.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.button import DOMAIN @@ -13,7 +14,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -53,7 +53,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -101,7 +101,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index 30fe9c92dc6..3f3f9b407b6 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Climate device actions.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -91,7 +91,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -140,7 +140,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 2feb8073f07..20bbe05386f 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Climate device conditions.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -95,7 +95,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -143,7 +143,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index 0cfb0a896b3..6cc304c4955 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -1,5 +1,6 @@ """The tests for Climate device triggers.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -23,7 +24,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -83,7 +83,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -145,7 +145,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 17f6e2185e9..ac798e8b3d4 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Cover device actions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.cover import DOMAIN, CoverEntityFeature @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -88,7 +88,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -137,7 +137,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_get_action_capabilities( diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index f1afe6c6d6b..534c2b027a1 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Cover device conditions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.cover import DOMAIN, CoverEntityFeature @@ -20,7 +21,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -117,7 +117,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -166,7 +166,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_get_condition_capabilities( diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index 6d70acd7f01..aede85fa63c 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.cover import DOMAIN, CoverEntityFeature @@ -22,7 +23,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -118,7 +118,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -167,7 +167,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index e26a22b02e4..efe97a84d37 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import pytest +from pytest_unordered import unordered from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -32,7 +33,6 @@ from homeassistant.setup import async_setup_component from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration from tests.common import ( - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -148,7 +148,7 @@ async def test_get_triggers( }, ] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_for_alarm_event( @@ -240,7 +240,7 @@ async def test_get_triggers_for_alarm_event( }, ] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_manage_unsupported_remotes( @@ -283,7 +283,7 @@ async def test_get_triggers_manage_unsupported_remotes( expected_triggers = [] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_functional_device_trigger( diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 4ef22f77ca3..de5d373fa00 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Device tracker device conditions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -58,7 +58,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -106,7 +106,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index 677e5e1d547..b48ff93bb4b 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -1,5 +1,6 @@ """The tests for Device Tracker device triggers.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -18,7 +19,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -90,7 +90,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -138,7 +138,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_zone_change(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 80d3dfd2b50..1fa3b3a1370 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Fan device actions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -52,7 +52,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -100,7 +100,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index acb206741b3..2d4633f8bc5 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Fan device conditions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -58,7 +58,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -106,7 +106,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index 61c49e7e6ea..aa26dd8f07d 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -15,7 +16,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -63,7 +63,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( From eba04824a486f1be79a12b7bb5d7706cffa66321 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 20:23:14 +0200 Subject: [PATCH 372/857] Replace assert_lists_same with pytest_unordered in integrations n-s (#94902) --- tests/components/nest/test_device_trigger.py | 4 ++-- tests/components/netatmo/test_device_trigger.py | 4 ++-- tests/components/number/test_device_action.py | 8 ++++---- tests/components/nut/test_device_action.py | 5 +++-- tests/components/philips_js/test_device_trigger.py | 4 ++-- tests/components/remote/test_device_action.py | 6 +++--- tests/components/remote/test_device_condition.py | 6 +++--- tests/components/remote/test_device_trigger.py | 6 +++--- tests/components/rfxtrx/test_device_action.py | 4 ++-- tests/components/rfxtrx/test_device_trigger.py | 4 ++-- tests/components/select/test_device_action.py | 6 +++--- tests/components/select/test_device_condition.py | 6 +++--- tests/components/select/test_device_trigger.py | 6 +++--- tests/components/sensor/test_device_condition.py | 10 +++++----- tests/components/sensor/test_device_trigger.py | 8 ++++---- tests/components/shelly/test_device_trigger.py | 10 +++++----- tests/components/switch/test_device_action.py | 6 +++--- tests/components/switch/test_device_condition.py | 6 +++--- tests/components/switch/test_device_trigger.py | 6 +++--- 19 files changed, 58 insertions(+), 57 deletions(-) diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index ea888d32fd6..f659568c674 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -1,6 +1,7 @@ """The tests for Nest device triggers.""" from google_nest_sdm.event import EventMessage import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -17,7 +18,6 @@ from homeassistant.util.dt import utcnow from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup from tests.common import ( - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -124,7 +124,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_multiple_devices( diff --git a/tests/components/netatmo/test_device_trigger.py b/tests/components/netatmo/test_device_trigger.py index 7d29ccb794a..29a0b46a97c 100644 --- a/tests/components/netatmo/test_device_trigger.py +++ b/tests/components/netatmo/test_device_trigger.py @@ -1,5 +1,6 @@ """The tests for Netatmo device triggers.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -18,7 +19,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_capture_events, async_get_device_automations, async_mock_service, @@ -92,7 +92,7 @@ async def test_get_triggers( ) if trigger["domain"] == NETATMO_DOMAIN ] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 9cd8d3d4943..70422974422 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Number device actions.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -56,7 +56,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -104,7 +104,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_get_action_no_state( @@ -134,7 +134,7 @@ async def test_get_action_no_state( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/nut/test_device_action.py b/tests/components/nut/test_device_action.py index 0664b0de5c8..c15a2157343 100644 --- a/tests/components/nut/test_device_action.py +++ b/tests/components/nut/test_device_action.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from pynut2.nut2 import PyNUTError import pytest +from pytest_unordered import unordered from homeassistant.components import automation, device_automation from homeassistant.components.device_automation import DeviceAutomationType @@ -14,7 +15,7 @@ from homeassistant.setup import async_setup_component from .util import async_init_integration -from tests.common import assert_lists_same, async_get_device_automations +from tests.common import async_get_device_automations async def test_get_all_actions_for_specified_user( @@ -47,7 +48,7 @@ async def test_get_all_actions_for_specified_user( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_no_actions_for_anonymous_user( diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py index 339b30d6355..897bc5ebc70 100644 --- a/tests/components/philips_js/test_device_trigger.py +++ b/tests/components/philips_js/test_device_trigger.py @@ -1,5 +1,6 @@ """The tests for Philips TV device triggers.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -8,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import ( - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -40,7 +40,7 @@ async def test_get_triggers(hass: HomeAssistant, mock_device) -> None: hass, DeviceAutomationType.TRIGGER, mock_device.id ) triggers = [trigger for trigger in triggers if trigger["domain"] == DOMAIN] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_turn_on_request( diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index d652f4d869d..563136e5d6d 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -1,5 +1,6 @@ """The test for remote device automation.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -57,7 +57,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -105,7 +105,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action( diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index a0899daf0af..51ca928b39c 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -3,6 +3,7 @@ from datetime import timedelta from freezegun import freeze_time import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -16,7 +17,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -63,7 +63,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_get_condition_capabilities( diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index fdd7b9e73ed..d45d15b67ee 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -15,7 +16,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -63,7 +63,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py index c2c50cbca8c..d53ef6a7a02 100644 --- a/tests/components/rfxtrx/test_device_action.py +++ b/tests/components/rfxtrx/test_device_action.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, NamedTuple import pytest +from pytest_unordered import unordered import RFXtrx import homeassistant.components.automation as automation @@ -17,7 +18,6 @@ from .conftest import create_rfx_test_cfg from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, ) @@ -107,7 +107,7 @@ async def test_get_actions( for action_type in expected ] - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py index c33e6c94f64..02e9ec87630 100644 --- a/tests/components/rfxtrx/test_device_trigger.py +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, NamedTuple import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -16,7 +17,6 @@ from .conftest import create_rfx_test_cfg from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -113,7 +113,7 @@ async def test_get_triggers( hass, DeviceAutomationType.TRIGGER, device_entry.id ) triggers = [value for value in triggers if value["domain"] == "rfxtrx"] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index 168e47d5d68..a517d16ad9e 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Select device actions.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize from homeassistant.components import automation @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -57,7 +57,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize("action_type", ("select_first", "select_last")) diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index 738089e7f9b..1ff237e2641 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from pytest_unordered import unordered import voluptuous_serialize from homeassistant.components import automation @@ -21,7 +22,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -61,7 +61,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -109,7 +109,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_selected_option( diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index ed33884bff9..45522892c6b 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from pytest_unordered import unordered import voluptuous_serialize from homeassistant.components import automation @@ -21,7 +22,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -61,7 +61,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -109,7 +109,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index dcd7e16a514..1989f95c789 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -1,5 +1,6 @@ """The test for sensor device automation.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -21,7 +22,6 @@ from homeassistant.util.json import load_json from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -124,7 +124,7 @@ async def test_get_conditions( hass, DeviceAutomationType.CONDITION, device_entry.id ) assert len(conditions) == 27 - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -173,7 +173,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_get_conditions_no_state( @@ -218,7 +218,7 @@ async def test_get_conditions_no_state( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -268,7 +268,7 @@ async def test_get_conditions_no_unit_or_stateclass( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 010c947e7ab..d2d3da7e8ff 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -24,7 +25,6 @@ from homeassistant.util.json import load_json from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -126,7 +126,7 @@ async def test_get_triggers( hass, DeviceAutomationType.TRIGGER, device_entry.id ) assert len(triggers) == 27 - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -175,7 +175,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -225,7 +225,7 @@ async def test_get_triggers_no_unit_or_stateclass( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 30a7cfe75d8..6e8c3bf8005 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -1,5 +1,6 @@ """The tests for Shelly device triggers.""" import pytest +from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType @@ -26,7 +27,6 @@ from . import init_integration from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, ) @@ -74,7 +74,7 @@ async def test_get_triggers_block_device( hass, DeviceAutomationType.TRIGGER, device.id ) triggers = [value for value in triggers if value["domain"] == DOMAIN] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_rpc_device(hass: HomeAssistant, mock_rpc_device) -> None: @@ -106,7 +106,7 @@ async def test_get_triggers_rpc_device(hass: HomeAssistant, mock_rpc_device) -> hass, DeviceAutomationType.TRIGGER, device.id ) triggers = [value for value in triggers if value["domain"] == DOMAIN] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_button(hass: HomeAssistant, mock_block_device) -> None: @@ -131,7 +131,7 @@ async def test_get_triggers_button(hass: HomeAssistant, mock_block_device) -> No hass, DeviceAutomationType.TRIGGER, device.id ) triggers = [value for value in triggers if value["domain"] == DOMAIN] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_non_initialized_devices( @@ -149,7 +149,7 @@ async def test_get_triggers_non_initialized_devices( hass, DeviceAutomationType.TRIGGER, device.id ) triggers = [value for value in triggers if value["domain"] == DOMAIN] - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_for_invalid_device_id( diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 623629e4b91..1acea0b107a 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -1,5 +1,6 @@ """The test for switch device automation.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -58,7 +58,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -106,7 +106,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action( diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 22f5a562daf..e2512624c15 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -3,6 +3,7 @@ from datetime import timedelta from freezegun import freeze_time import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -16,7 +17,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, @@ -63,7 +63,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_get_condition_capabilities( diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 58ffa7e7c18..7ca2e480f4d 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -15,7 +16,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -63,7 +63,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( From 6183a36fcea68c787d4e11e7a689c8e6d20207ee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 20:23:49 +0200 Subject: [PATCH 373/857] Replace assert_lists_same with pytest_unordered in integrations t-z (#94903) --- .../components/tasmota/test_device_trigger.py | 16 +++++++-------- tests/components/text/test_device_action.py | 8 ++++---- tests/components/trace/test_websocket_api.py | 6 +++--- .../components/update/test_device_trigger.py | 6 +++--- tests/components/vacuum/test_device_action.py | 6 +++--- .../vacuum/test_device_condition.py | 6 +++--- .../components/vacuum/test_device_trigger.py | 6 +++--- .../water_heater/test_device_action.py | 6 +++--- tests/components/wemo/test_device_trigger.py | 4 ++-- .../components/yolink/test_device_trigger.py | 6 +++--- tests/components/zha/test_device_action.py | 6 +++--- .../zwave_js/test_device_trigger.py | 20 +++++++++---------- 12 files changed, 47 insertions(+), 49 deletions(-) diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index fe3240790dd..880f4ed0e75 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch from hatasmota.switch import TasmotaSwitchTriggerConfig import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -18,7 +19,6 @@ from homeassistant.setup import async_setup_component from .test_common import DEFAULT_CONFIG, remove_device from tests.common import ( - assert_lists_same, async_fire_mqtt_message, async_get_device_automations, ) @@ -74,7 +74,7 @@ async def test_get_triggers_btn( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_swc( @@ -109,7 +109,7 @@ async def test_get_triggers_swc( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_unknown_triggers( @@ -158,7 +158,7 @@ async def test_get_unknown_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, []) + assert triggers == [] async def test_get_non_existing_triggers( @@ -183,7 +183,7 @@ async def test_get_non_existing_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, []) + assert triggers == [] @pytest.mark.no_fail_on_log_exception @@ -215,7 +215,7 @@ async def test_discover_bad_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, []) + assert triggers == [] # Trigger an exception when the entity is discovered class FakeTrigger(TasmotaSwitchTriggerConfig): @@ -251,7 +251,7 @@ async def test_discover_bad_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, []) + assert triggers == [] # Rediscover without exception async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", json.dumps(config)) @@ -271,7 +271,7 @@ async def test_discover_bad_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_update_remove_triggers( diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index 523a39c5640..09a1a3176f5 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Text device actions.""" import pytest +from pytest_unordered import unordered import voluptuous_serialize import homeassistant.components.automation as automation @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -56,7 +56,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -104,7 +104,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_get_action_no_state( @@ -134,7 +134,7 @@ async def test_get_action_no_state( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index 8b3bca86565..1041208fa61 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import patch import pytest +from pytest_unordered import unordered from homeassistant.bootstrap import async_setup_component from homeassistant.components.trace.const import DEFAULT_STORED_TRACES @@ -14,7 +15,7 @@ from homeassistant.core import Context, CoreState, HomeAssistant, callback from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.uuid import random_uuid_hex -from tests.common import assert_lists_same, load_fixture +from tests.common import load_fixture from tests.typing import WebSocketGenerator @@ -1086,8 +1087,7 @@ async def test_breakpoints( await client.send_json({"id": next_id(), "type": "trace/debug/breakpoint/list"}) response = await client.receive_json() assert response["success"] - assert_lists_same( - response["result"], + assert response["result"] == unordered( [ {"node": f"{prefix}/1", "run_id": "*", "domain": domain, "item_id": "sun"}, {"node": f"{prefix}/5", "run_id": "*", "domain": domain, "item_id": "sun"}, diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index bddcefa07c5..a9abed935fb 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -14,7 +15,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -62,7 +62,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -110,7 +110,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index 643d9ad0130..1a4aa1455a7 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Vacuum device actions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -52,7 +52,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -100,7 +100,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index b1ed65d689f..6d1f0e5ad75 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -1,5 +1,6 @@ """The tests for Vacuum device conditions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -17,7 +18,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -63,7 +63,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_conditions_hidden_auxiliary( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 84519a80545..2ab0c80af14 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -15,7 +16,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - assert_lists_same, async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, @@ -63,7 +63,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_get_triggers_hidden_auxiliary( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_trigger_capabilities( diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index b1e12dcab94..35b78a3d926 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -1,5 +1,6 @@ """The tests for Water Heater device actions.""" import pytest +from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType @@ -12,7 +13,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -52,7 +52,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -100,7 +100,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/tests/components/wemo/test_device_trigger.py b/tests/components/wemo/test_device_trigger.py index ad649fb3a24..e7a1c11e6c8 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -1,5 +1,6 @@ """Verify that WeMo device triggers work as expected.""" import pytest +from pytest_unordered import unordered from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN @@ -17,7 +18,6 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import ( - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -96,7 +96,7 @@ async def test_get_triggers(hass: HomeAssistant, wemo_entity) -> None: triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, wemo_entity.device_id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_fires_on_long_press(hass: HomeAssistant) -> None: diff --git a/tests/components/yolink/test_device_trigger.py b/tests/components/yolink/test_device_trigger.py index f5679ca19c9..e0ef37c1b75 100644 --- a/tests/components/yolink/test_device_trigger.py +++ b/tests/components/yolink/test_device_trigger.py @@ -1,5 +1,6 @@ """The tests for YoLink device triggers.""" import pytest +from pytest_unordered import unordered from yolink.const import ATTR_DEVICE_DIMMER, ATTR_DEVICE_SMART_REMOTER from homeassistant.components import automation @@ -11,7 +12,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -96,7 +96,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_get_triggers_exception( @@ -115,7 +115,7 @@ async def test_get_triggers_exception( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entity.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_event( diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 0dda0b56e23..5b6d7c94539 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -2,6 +2,7 @@ from unittest.mock import call, patch import pytest +from pytest_unordered import unordered from zhaquirks.inovelli.VZM31SN import InovelliVZM31SNv11 import zigpy.profiles.zha import zigpy.zcl.clusters.general as general @@ -19,7 +20,6 @@ from homeassistant.setup import async_setup_component from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import ( - assert_lists_same, async_get_device_automations, async_mock_service, mock_coro, @@ -153,7 +153,7 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: ] ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> None: @@ -233,7 +233,7 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non }, ] - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index a8f5ff98fdf..8209564579c 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from pytest_unordered import unordered import voluptuous_serialize from zwave_js_server.const import CommandClass from zwave_js_server.event import Event @@ -25,7 +26,6 @@ from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg from homeassistant.setup import async_setup_component from tests.common import ( - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -193,16 +193,15 @@ async def test_get_trigger_capabilities_notification_notification( ) assert capabilities and "extra_fields" in capabilities - assert_lists_same( - voluptuous_serialize.convert( - capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ), + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == unordered( [ {"name": "type.", "optional": True, "type": "string"}, {"name": "label", "optional": True, "type": "string"}, {"name": "event", "optional": True, "type": "string"}, {"name": "event_label", "optional": True, "type": "string"}, - ], + ] ) @@ -323,14 +322,13 @@ async def test_get_trigger_capabilities_entry_control_notification( ) assert capabilities and "extra_fields" in capabilities - assert_lists_same( - voluptuous_serialize.convert( - capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ), + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == unordered( [ {"name": "event_type", "optional": True, "type": "string"}, {"name": "data_type", "optional": True, "type": "string"}, - ], + ] ) From 252c0e8ad98a04ab93e30edc85710f09d38c5ce9 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Tue, 20 Jun 2023 13:12:11 -0700 Subject: [PATCH 374/857] Fix Totalconnect BinarySensorDeviceClass logic (#94772) * handle temperature * test for temperature * test for unknown --- .../components/totalconnect/binary_sensor.py | 12 +++--------- tests/components/totalconnect/common.py | 13 +++++++++++-- .../totalconnect/test_binary_sensor.py | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index ef252d54e4e..9caa642b5f4 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -81,15 +81,9 @@ class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor): return BinarySensorDeviceClass.MOTION if self._zone.is_type_medical(): return BinarySensorDeviceClass.SAFETY - # "security" type is a generic category so test for it last - if self._zone.is_type_security(): - return BinarySensorDeviceClass.DOOR - - _LOGGER.error( - "TotalConnect zone %s reported an unexpected device class", - self._zone.zoneid, - ) - return None + if self._zone.is_type_temperature(): + return BinarySensorDeviceClass.PROBLEM + return BinarySensorDeviceClass.DOOR def update(self): """Return the state of the device.""" diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 54f321c6770..ccee4c43781 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -189,14 +189,23 @@ ZONE_5 = { # 99 is an unknown ZoneType ZONE_6 = { "ZoneID": "6", - "ZoneDescription": "Medical", + "ZoneDescription": "Unknown", "ZoneStatus": ZoneStatus.NORMAL, "ZoneTypeId": 99, "PartitionId": "1", "CanBeBypassed": 0, } -ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6] +ZONE_7 = { + "ZoneID": 7, + "ZoneDescription": "Temperature", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": ZoneType.MONITOR, + "PartitionId": "1", + "CanBeBypassed": 0, +} + +ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6, ZONE_7] ZONES = {"ZoneInfo": ZONE_INFO} METADATA_DISARMED = { diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py index 966daeb5a63..8f9cabe670c 100644 --- a/tests/components/totalconnect/test_binary_sensor.py +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -84,3 +84,21 @@ async def test_state_and_attributes(hass: HomeAssistant) -> None: assert state.state == STATE_OFF state = hass.states.get("binary_sensor.gas_tamper") assert state.state == STATE_ON + + # Zone 6 is unknown type, assume it is a security (door) sensor + state = hass.states.get("binary_sensor.unknown") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR + state = hass.states.get("binary_sensor.unknown_low_battery") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.unknown_tamper") + assert state.state == STATE_OFF + + # Zone 7 is temperature + state = hass.states.get("binary_sensor.temperature") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.PROBLEM + state = hass.states.get("binary_sensor.temperature_low_battery") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.temperature_tamper") + assert state.state == STATE_OFF From 18d0fe994d8cb5a4536986327eb2d189b017a310 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 20 Jun 2023 22:19:17 +0200 Subject: [PATCH 375/857] Add entity translations for AirQ (#94280) --- homeassistant/components/airq/sensor.py | 98 ++++++------- homeassistant/components/airq/strings.json | 151 +++++++++++++++++++++ 2 files changed, 200 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 7f0d51fcaa8..dca06be67af 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -53,63 +53,63 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="c2h4o", - name="Acetaldehyde", + translation_key="acetaldehyde", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c2h4o"), ), AirQEntityDescription( key="nh3_MR100", - name="Ammonia", + translation_key="ammonia", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("nh3_MR100"), ), AirQEntityDescription( key="ash3", - name="Arsine", + translation_key="arsine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ash3"), ), AirQEntityDescription( key="br2", - name="Bromine", + translation_key="bromine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("br2"), ), AirQEntityDescription( key="ch4s", - name="CH4S", + translation_key="methanethiol", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch4s"), ), AirQEntityDescription( key="cl2_M20", - name="Chlorine", + translation_key="chlorine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cl2_M20"), ), AirQEntityDescription( key="clo2", - name="ClO2", + translation_key="chlorine_dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("clo2"), ), AirQEntityDescription( key="co", - name="CO", + translation_key="carbon_monoxide", native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co"), ), AirQEntityDescription( key="co2", - name="CO2", + translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, @@ -117,14 +117,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="cs2", - name="CS2", + translation_key="carbon_disulfide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cs2"), ), AirQEntityDescription( key="dewpt", - name="Dew point", + translation_key="dew_point", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("dewpt"), @@ -132,63 +132,63 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="ethanol", - name="Ethanol", + translation_key="ethanol", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ethanol"), ), AirQEntityDescription( key="c2h4", - name="Ethylene", + translation_key="ethylene", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c2h4"), ), AirQEntityDescription( key="ch2o_M10", - name="Formaldehyde", + translation_key="formaldehyde", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch2o_M10"), ), AirQEntityDescription( key="f2", - name="Fluorine", + translation_key="fluorine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("f2"), ), AirQEntityDescription( key="h2s", - name="H2S", + translation_key="hydrogen_sulfide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2s"), ), AirQEntityDescription( key="hcl", - name="HCl", + translation_key="hydrochloric_acid", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("hcl"), ), AirQEntityDescription( key="hcn", - name="HCN", + translation_key="hydrogen_cyanide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("hcn"), ), AirQEntityDescription( key="hf", - name="HF", + translation_key="hydrogen_fluoride", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("hf"), ), AirQEntityDescription( key="health", - name="Health Index", + translation_key="health_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:heart-pulse", @@ -196,7 +196,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -204,7 +204,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity_abs", - name="Absolute humidity", + translation_key="absolute_humidity", native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), @@ -212,28 +212,28 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="h2_M1000", - name="Hydrogen", + translation_key="hydrogen", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2_M1000"), ), AirQEntityDescription( key="h2o2", - name="Hydrogen peroxide", + translation_key="hydrogen_peroxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2o2"), ), AirQEntityDescription( key="ch4_MIPEX", - name="Methane", + translation_key="methane", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch4_MIPEX"), ), AirQEntityDescription( key="n2o", - name="N2O", + translation_key="nitrous_oxide", device_class=SensorDeviceClass.NITROUS_OXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -241,7 +241,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="no_M250", - name="NO", + translation_key="nitrogen_monoxide", device_class=SensorDeviceClass.NITROGEN_MONOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -249,7 +249,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="no2", - name="NO2", + translation_key="nitrogen_dioxide", device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -257,14 +257,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="acid_M100", - name="Organic acid", + translation_key="organic_acid", native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("acid_M100"), ), AirQEntityDescription( key="oxygen", - name="Oxygen", + translation_key="oxygen", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("oxygen"), @@ -272,7 +272,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="o3", - name="Ozone", + translation_key="ozone", device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -280,7 +280,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="performance", - name="Performance Index", + translation_key="performance_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:head-check", @@ -288,14 +288,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="ph3", - name="PH3", + translation_key="hydrogen_phosphide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ph3"), ), AirQEntityDescription( key="pm1", - name="PM1", + translation_key="pm1", device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -304,7 +304,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pm2_5", - name="PM2.5", + translation_key="pm25", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -313,7 +313,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pm10", - name="PM10", + translation_key="pm10", device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -322,7 +322,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pressure", - name="Pressure", + translation_key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, @@ -330,7 +330,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pressure_rel", - name="Relative pressure", + translation_key="relative_pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pressure_rel"), @@ -338,28 +338,28 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="c3h8_MIPEX", - name="Propane", + translation_key="propane", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c3h8_MIPEX"), ), AirQEntityDescription( key="refigerant", - name="Refrigerant", + translation_key="refigerant", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("refigerant"), ), AirQEntityDescription( key="sih4", - name="SiH4", + translation_key="silicon_hydride", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("sih4"), ), AirQEntityDescription( key="so2", - name="SO2", + translation_key="sulphur_dioxide", device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -367,7 +367,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="sound", - name="Noise", + translation_key="noise", native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("sound"), @@ -375,7 +375,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="sound_max", - name="Noise (Maximum)", + translation_key="maximum_noise", native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("sound_max"), @@ -383,7 +383,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="radon", - name="Radon", + translation_key="radon", native_unit_of_measurement=ACTIVITY_BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("radon"), @@ -391,7 +391,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -399,21 +399,21 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="tvoc", - name="VOC", + translation_key="volatile_organic_compounds", native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc"), ), AirQEntityDescription( key="tvoc_ionsc", - name="VOC (Industrial)", + translation_key="industrial_volatile_organic_compounds", native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), AirQEntityDescription( key="virus", - name="Virus Index", + translation_key="virus_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:virus-off", diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 3618d9d517e..4216e4df60e 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -18,5 +18,156 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "acetaldehyde": { + "name": "Acetaldehyde" + }, + "ammonia": { + "name": "Ammonia" + }, + "arsine": { + "name": "Arsine" + }, + "bromine": { + "name": "Bromine" + }, + "methanethiol": { + "name": "Methanethiol" + }, + "chlorine": { + "name": "Chlorine" + }, + "chlorine_dioxide": { + "name": "Chlorine dioxide" + }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" + }, + "carbon_disulfide": { + "name": "Carbon disulfide" + }, + "dew_point": { + "name": "Dew point" + }, + "ethanol": { + "name": "Ethanol" + }, + "ethylene": { + "name": "Ethylene" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "fluorine": { + "name": "Fluorine" + }, + "hydrogen_sulfide": { + "name": "Hydrogen sulfide" + }, + "hydrochloric_acid": { + "name": "Hydrochloric acid" + }, + "hydrogen_cyanide": { + "name": "Hydrogen cyanide" + }, + "hydrogen_fluoride": { + "name": "Hydrogen fluoride" + }, + "health_index": { + "name": "Health Index" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "absolute_humidity": { + "name": "Absolute humidity" + }, + "hydrogen": { + "name": "Hydrogen" + }, + "hydrogen_peroxide": { + "name": "Hydrogen peroxide" + }, + "methane": { + "name": "Methane" + }, + "nitrous_oxide": { + "name": "[%key:component::sensor::entity_component::nitrous_oxide::name%]" + }, + "nitrogen_monoxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]" + }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "organic_acid": { + "name": "Organic acid" + }, + "oxygen": { + "name": "Oxygen" + }, + "ozone": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "performance_index": { + "name": "Performance Index" + }, + "hydrogen_phosphide": { + "name": "Hydrogen Phosphide" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "relative_pressure": { + "name": "Relative pressure" + }, + "propane": { + "name": "Propane" + }, + "refigerant": { + "name": "Refrigerant" + }, + "silicon_hydride": { + "name": "Silicon Hydride" + }, + "sulphur_dioxide": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "noise": { + "name": "Noise" + }, + "maximum_noise": { + "name": "Noise (Maximum)" + }, + "radon": { + "name": "Radon" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "volatile_organic_compounds": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, + "industrial_volatile_organic_compounds": { + "name": "VOCs (Industrial)" + }, + "virus_index": { + "name": "Virus Index" + } + } } } From d6dc738a124a7255dc07b3e2aca81950076f63e8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 20 Jun 2023 22:24:51 +0200 Subject: [PATCH 376/857] Add entity translations for AirNow (#94175) * Add entity translations for AirNow * Restore keys * Restore keys --- homeassistant/components/airnow/const.py | 2 - homeassistant/components/airnow/sensor.py | 86 ++++++++++++++------ homeassistant/components/airnow/strings.json | 13 +++ 3 files changed, 72 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/airnow/const.py b/homeassistant/components/airnow/const.py index 67a9289efc5..34b1f4392bc 100644 --- a/homeassistant/components/airnow/const.py +++ b/homeassistant/components/airnow/const.py @@ -17,5 +17,3 @@ ATTR_API_STATION_LATITUDE = "Latitude" ATTR_API_STATION_LONGITUDE = "Longitude" DEFAULT_NAME = "AirNow" DOMAIN = "airnow" -SENSOR_AQI_ATTR_DESCR = "description" -SENSOR_AQI_ATTR_LEVEL = "level" diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index decec74ee47..31bb3d793a1 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -1,6 +1,10 @@ """Support for the AirNow sensor service.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -12,7 +16,10 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirNowDataUpdateCoordinator @@ -22,36 +29,61 @@ from .const import ( ATTR_API_AQI_LEVEL, ATTR_API_O3, ATTR_API_PM25, + DEFAULT_NAME, DOMAIN, - SENSOR_AQI_ATTR_DESCR, - SENSOR_AQI_ATTR_LEVEL, ) ATTRIBUTION = "Data provided by AirNow" PARALLEL_UPDATES = 1 -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ATTR_DESCR = "description" +ATTR_LEVEL = "level" + + +@dataclass +class AirNowEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], StateType] + extra_state_attributes_fn: Callable[[Any], dict[str, str]] | None + + +@dataclass +class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMixin): + """Describes Airnow sensor entity.""" + + +SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( + AirNowEntityDescription( key=ATTR_API_AQI, + translation_key="aqi", icon="mdi:blur", - name=ATTR_API_AQI, native_unit_of_measurement="aqi", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get(ATTR_API_AQI), + extra_state_attributes_fn=lambda data: { + ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION], + ATTR_LEVEL: data[ATTR_API_AQI_LEVEL], + }, ), - SensorEntityDescription( + AirNowEntityDescription( key=ATTR_API_PM25, + translation_key="pm25", icon="mdi:blur", - name=ATTR_API_PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get(ATTR_API_PM25), + extra_state_attributes_fn=None, ), - SensorEntityDescription( + AirNowEntityDescription( key=ATTR_API_O3, + translation_key="o3", icon="mdi:blur", - name=ATTR_API_O3, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get(ATTR_API_O3), + extra_state_attributes_fn=None, ), ) @@ -73,38 +105,38 @@ class AirNowSensor(CoordinatorEntity[AirNowDataUpdateCoordinator], SensorEntity) """Define an AirNow sensor.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + entity_description: AirNowEntityDescription def __init__( self, coordinator: AirNowDataUpdateCoordinator, - description: SensorEntityDescription, + description: AirNowEntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) self.entity_description = description - self._state = None - self._attrs: dict[str, str] = {} - self._attr_name = f"AirNow {description.name}" self._attr_unique_id = ( f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}" ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + ) @property - def native_value(self): + def native_value(self) -> StateType: """Return the state.""" - self._state = self.coordinator.data.get(self.entity_description.key) - - return self._state + return self.entity_description.value_fn(self.coordinator.data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes.""" - if self.entity_description.key == ATTR_API_AQI: - self._attrs[SENSOR_AQI_ATTR_DESCR] = self.coordinator.data[ - ATTR_API_AQI_DESCRIPTION - ] - self._attrs[SENSOR_AQI_ATTR_LEVEL] = self.coordinator.data[ - ATTR_API_AQI_LEVEL - ] - - return self._attrs + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn( + self.coordinator.data + ) + return None diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 0e86c4531dc..ff1ba6481c8 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -20,5 +20,18 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "aqi": { + "name": "[%key:component::sensor::entity_component::aqi::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "o3": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + } + } } } From d6b89b6f7bc51cdc7cc748b268b96a78d2518cb3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 20 Jun 2023 22:46:07 +0200 Subject: [PATCH 377/857] Add current_humidity attribute on homekit_controller humidifier (#94937) --- .../components/homekit_controller/humidifier.py | 10 ++++++++++ .../homekit_controller/test_humidifier.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index e396b3c9c97..cd2cf4022e7 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -73,6 +73,11 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD ) + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) + @property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. @@ -177,6 +182,11 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD ) + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) + @property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index a1909158d33..e412fed0878 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -118,20 +118,24 @@ async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: { CharacteristicsTypes.ACTIVE: True, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: 75, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: 45, }, ) assert state.state == "on" assert state.attributes["humidity"] == 75 + assert state.attributes["current_humidity"] == 45 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, { CharacteristicsTypes.ACTIVE: False, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: 10, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: 30, }, ) assert state.state == "off" assert state.attributes["humidity"] == 10 + assert state.attributes["current_humidity"] == 30 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, @@ -140,6 +144,7 @@ async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: }, ) assert state.attributes["humidity"] == 10 + assert state.attributes["current_humidity"] == 30 assert state.state == "off" @@ -152,20 +157,24 @@ async def test_dehumidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: { CharacteristicsTypes.ACTIVE: True, CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: 75, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: 45, }, ) assert state.state == "on" assert state.attributes["humidity"] == 75 + assert state.attributes["current_humidity"] == 45 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, { CharacteristicsTypes.ACTIVE: False, CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: 40, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: 39, }, ) assert state.state == "off" assert state.attributes["humidity"] == 40 + assert state.attributes["current_humidity"] == 39 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, @@ -368,6 +377,7 @@ async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> ) assert state.attributes["mode"] == "auto" assert state.attributes["humidity"] == 37 + assert state.attributes["current_humidity"] == 51 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, @@ -377,6 +387,7 @@ async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> ) assert state.attributes["mode"] == "normal" assert state.attributes["humidity"] == 37 + assert state.attributes["current_humidity"] == 51 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, @@ -411,6 +422,7 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - ) assert state.attributes["mode"] == "auto" assert state.attributes["humidity"] == 73 + assert state.attributes["current_humidity"] == 51 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, @@ -420,6 +432,7 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - ) assert state.attributes["mode"] == "normal" assert state.attributes["humidity"] == 73 + assert state.attributes["current_humidity"] == 51 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, @@ -429,6 +442,7 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - ) assert state.attributes["mode"] == "normal" assert state.attributes["humidity"] == 73 + assert state.attributes["current_humidity"] == 51 state = await helper.async_update( ServicesTypes.HUMIDIFIER_DEHUMIDIFIER, @@ -438,6 +452,7 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - ) assert state.attributes["mode"] == "normal" assert state.attributes["humidity"] == 73 + assert state.attributes["current_humidity"] == 51 async def test_migrate_entity_ids(hass: HomeAssistant, utcnow) -> None: From 3b8feab6997ba2105fe0b721bd5bd4bd19e72830 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 20 Jun 2023 22:49:10 +0200 Subject: [PATCH 378/857] Add current_humidity to humidifiers in google_assistant (#94935) --- homeassistant/components/google_assistant/trait.py | 7 +++++-- tests/components/google_assistant/test_google_assistant.py | 2 ++ tests/components/google_assistant/test_trait.py | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index e44f1597a9b..02e9518ca5e 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1216,9 +1216,12 @@ class HumiditySettingTrait(_Trait): response["humidityAmbientPercent"] = round(float(current_humidity)) elif domain == humidifier.DOMAIN: - target_humidity = attrs.get(humidifier.ATTR_HUMIDITY) + target_humidity: int | None = attrs.get(humidifier.ATTR_HUMIDITY) if target_humidity is not None: - response["humiditySetpointPercent"] = round(float(target_humidity)) + response["humiditySetpointPercent"] = target_humidity + current_humidity: int | None = attrs.get(humidifier.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response["humidityAmbientPercent"] = current_humidity return response diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index ffcafde5502..177220cc02f 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -351,11 +351,13 @@ async def test_query_humidifier_request( "on": True, "online": True, "humiditySetpointPercent": 68, + "humidityAmbientPercent": 45, } assert devices["humidifier.dehumidifier"] == { "on": True, "online": True, "humiditySetpointPercent": 54, + "humidityAmbientPercent": 59, } assert devices["humidifier.hygrostat"] == { "on": True, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 97dc4af7c36..edf69fd0234 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1203,6 +1203,7 @@ async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None humidifier.ATTR_MIN_HUMIDITY: 20, humidifier.ATTR_MAX_HUMIDITY: 90, humidifier.ATTR_HUMIDITY: 38, + humidifier.ATTR_CURRENT_HUMIDITY: 30, }, ), BASIC_CONFIG, @@ -1212,6 +1213,7 @@ async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None } assert trt.query_attributes() == { "humiditySetpointPercent": 38, + "humidityAmbientPercent": 30, } assert trt.can_execute(trait.COMMAND_SET_HUMIDITY, {}) From d2d6389742f1cf34f4101cba1f606c737e7e21e2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 22:50:10 +0200 Subject: [PATCH 379/857] Improve storage helper typing (#94929) --- homeassistant/helpers/storage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index bd9b01cd6a6..128a36e3e14 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -34,11 +34,11 @@ _T = TypeVar("_T", bound=Mapping[str, Any] | Sequence[Any]) async def async_migrator( hass: HomeAssistant, old_path: str, - store: Store, + store: Store[_T], *, old_conf_load_func: Callable | None = None, old_conf_migrate_func: Callable | None = None, -) -> Any: +) -> _T | None: """Migrate old data to a store and then load data. async def old_conf_migrate_func(old_data) From a4399a4cb6ecee903980344f101a93150829d79f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 20 Jun 2023 22:57:44 +0200 Subject: [PATCH 380/857] Add device trigger for humidifier current_humidity (#94926) --- .../components/humidifier/device_trigger.py | 40 ++++++++++++++++--- .../humidifier/test_device_trigger.py | 2 + 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 5fbb248a8bc..79074a06e18 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -26,10 +26,23 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from . import ATTR_CURRENT_HUMIDITY, DOMAIN # mypy: disallow-any-generics +CURRENT_TRIGGER_SCHEMA = vol.All( + DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "current_humidity_changed", + vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + HUMIDIFIER_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { @@ -45,6 +58,7 @@ HUMIDIFIER_TRIGGER_SCHEMA = vol.All( TRIGGER_SCHEMA = vol.All( vol.Any( + CURRENT_TRIGGER_SCHEMA, HUMIDIFIER_TRIGGER_SCHEMA, toggle_entity.TRIGGER_SCHEMA, ), @@ -64,15 +78,31 @@ async def async_get_triggers( if entry.domain != DOMAIN: continue + state = hass.states.get(entry.entity_id) + + # Add triggers for each entity that belongs to this integration + base_trigger = { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + triggers.append( { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + **base_trigger, CONF_TYPE: "target_humidity_changed", } ) + + if state and ATTR_CURRENT_HUMIDITY in state.attributes: + triggers.append( + { + **base_trigger, + CONF_TYPE: "current_humidity_changed", + } + ) + return triggers diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 350f3d64a11..db557509463 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -65,6 +65,7 @@ async def test_get_triggers( STATE_ON, { const.ATTR_HUMIDITY: 23, + const.ATTR_CURRENT_HUMIDITY: 48, ATTR_MODE: "home", const.ATTR_AVAILABLE_MODES: ["home", "away"], ATTR_SUPPORTED_FEATURES: 1, @@ -80,6 +81,7 @@ async def test_get_triggers( "metadata": {"secondary": False}, } for trigger in [ + "current_humidity_changed", "target_humidity_changed", "turned_off", "turned_on", From 3c34e18130c25382ba65ee5c8d33fa9d2c8021d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 22:58:34 +0200 Subject: [PATCH 381/857] Correct calls to super class in ZWaveConfigParameterSensor (#94925) --- homeassistant/components/zwave_js/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f3568588287..468d8f0cbda 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -769,8 +769,9 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" - if (device_class := super(ZwaveSensor, self).device_class) is not None: - return device_class + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + if (device_class := ZwaveSensor.device_class.fget(self)) is not None: # type: ignore[attr-defined] + return device_class # type: ignore[no-any-return] if ( self._primary_value.configuration_value_type == ConfigurationValueType.ENUMERATED From 16aa4c54ecf28c2a240929dc40310457d9193628 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 22:59:13 +0200 Subject: [PATCH 382/857] Correct calls to super class in ZHADeviceScannerEntity (#94924) --- homeassistant/components/zha/device_tracker.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index d473eadeebe..885cd788f70 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -118,10 +118,12 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): # We opt ZHA device tracker back into overriding this method because # it doesn't track IP-based devices. # Call Super because ScannerEntity overrode it. - return super(ZhaEntity, self).device_info + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + return ZhaEntity.device_info.fget(self) # type: ignore[attr-defined] @property def unique_id(self) -> str: """Return unique ID.""" # Call Super because ScannerEntity overrode it. - return super(ZhaEntity, self).unique_id + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + return ZhaEntity.unique_id.fget(self) # type: ignore[attr-defined] From 3e71b1daa4b1ea164f3f037022d010ad87d7b487 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 23:00:22 +0200 Subject: [PATCH 383/857] Correct calls to super class in TriggerEntity (#94916) --- homeassistant/components/template/trigger_entity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 72165ddbf59..7d1a844fb3d 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -18,13 +18,13 @@ class TriggerEntity(TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinato config: dict, ) -> None: """Initialize the entity.""" - super(CoordinatorEntity, self).__init__(coordinator) - super().__init__(hass, config) + CoordinatorEntity.__init__(self, coordinator) + TriggerBaseEntity.__init__(self, hass, config) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" - await super().async_added_to_hass() - await super(CoordinatorEntity, self).async_added_to_hass() + await TriggerBaseEntity.async_added_to_hass(self) + await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] if self.coordinator.data is not None: self._process_data() From 4def901ecc44401b830e4b67c3c7e8891fb57690 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 20 Jun 2023 23:04:01 +0200 Subject: [PATCH 384/857] Ignore empty status update for mqtt number (#94800) --- homeassistant/components/mqtt/number.py | 3 +++ tests/components/mqtt/test_number.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index c0cb00211e3..5986eab1207 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -186,6 +186,9 @@ class MqttNumber(MqttEntity, RestoreNumber): """Handle new MQTT messages.""" num_value: int | float | None payload = str(self._value_template(msg.payload)) + if not payload.strip(): + _LOGGER.debug("Ignoring empty state update from '%s'", msg.topic) + return try: if payload == self._config[CONF_PAYLOAD_RESET]: num_value = None diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index f12f5eca8b6..f882209139c 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -189,6 +189,14 @@ async def test_value_template( state = hass.states.get("number.test_number") assert state.state == "10" + # Assert an empty value from a template is ignored + async_fire_mqtt_message(hass, topic, '{"other_val":12}') + + await hass.async_block_till_done() + + state = hass.states.get("number.test_number") + assert state.state == "10" + async_fire_mqtt_message(hass, topic, '{"val":20.5}') await hass.async_block_till_done() From b857dc8d94fc9ebf0816675a75902ce8027ffb67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jun 2023 22:06:26 +0100 Subject: [PATCH 385/857] Bump HAP-python to 4.7.0 (#94812) --- .../components/homekit/accessories.py | 4 ++-- .../components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit/test_homekit.py | 21 +++++++++++-------- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index dc8a2a7c639..a2e3f8487c6 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -626,10 +626,10 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] @pyhap_callback # type: ignore[misc] def pair( - self, client_uuid: UUID, client_public: str, client_permissions: int + self, client_username_bytes: bytes, client_public: str, client_permissions: int ) -> bool: """Override super function to dismiss setup message if paired.""" - success = super().pair(client_uuid, client_public, client_permissions) + success = super().pair(client_username_bytes, client_public, client_permissions) if success: async_dismiss_setup_message(self.hass, self._entry_id) return cast(bool, success) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 746b097e99a..245dbd0a19e 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.6.0", + "HAP-python==4.7.0", "fnv-hash-fast==0.3.1", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index d6573ea8936..042a7d04d15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.6.0 +HAP-python==4.7.0 # homeassistant.components.tasmota HATasmota==0.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23885e2c5e3..0bfbeb8024c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.6.0 +HAP-python==4.7.0 # homeassistant.components.tasmota HATasmota==0.6.5 diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 690bb7fef37..fb1191b59de 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from uuid import uuid1 from pyhap.accessory import Accessory from pyhap.const import CATEGORY_CAMERA, CATEGORY_TELEVISION @@ -868,11 +869,11 @@ async def test_homekit_unpair( homekit.driver.aio_stop_event = MagicMock() state = homekit.driver.state - state.add_paired_client("client1", "any", b"1") - state.add_paired_client("client2", "any", b"0") - state.add_paired_client("client3", "any", b"1") - state.add_paired_client("client4", "any", b"0") - state.add_paired_client("client5", "any", b"0") + state.add_paired_client(str(uuid1()).encode("utf-8"), "any", b"1") + state.add_paired_client(str(uuid1()).encode("utf-8"), "any", b"0") + state.add_paired_client(str(uuid1()).encode("utf-8"), "any", b"1") + state.add_paired_client(str(uuid1()).encode("utf-8"), "any", b"0") + state.add_paired_client(str(uuid1()).encode("utf-8"), "any", b"0") formatted_mac = dr.format_mac(state.mac) hk_bridge_dev = device_registry.async_get_device( @@ -917,7 +918,8 @@ async def test_homekit_unpair_missing_device_id( homekit.driver.aio_stop_event = MagicMock() state = homekit.driver.state - state.add_paired_client("client1", "any", b"1") + client_1 = str(uuid1()).encode("utf-8") + state.add_paired_client(client_1, "any", b"1") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -926,7 +928,7 @@ async def test_homekit_unpair_missing_device_id( blocking=True, ) await hass.async_block_till_done() - state.paired_clients = {"client1": "any"} + state.paired_clients = {client_1.decode("utf-8"): "any"} homekit.status = STATUS_STOPPED @@ -967,7 +969,8 @@ async def test_homekit_unpair_not_homekit_device( ) state = homekit.driver.state - state.add_paired_client("client1", "any", b"1") + client_1 = str(uuid1()).encode("utf-8") + state.add_paired_client(client_1, "any", b"1") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -976,7 +979,7 @@ async def test_homekit_unpair_not_homekit_device( blocking=True, ) await hass.async_block_till_done() - state.paired_clients = {"client1": "any"} + state.paired_clients = {client_1.decode("utf-8"): "any"} homekit.status = STATUS_STOPPED From 446a820cbb9ccdad30c084381ef1d3ae2a996845 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 23:09:24 +0200 Subject: [PATCH 386/857] Name unnamed sensors by their device class (#94646) --- homeassistant/components/sensor/__init__.py | 7 ++ tests/components/sensor/test_init.py | 102 ++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index c796ad55421..ad09a1b5fdb 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -257,6 +257,13 @@ class SensorEntity(Entity): self._async_read_entity_options() self._update_suggested_precision() + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For sensors this is True if the entity has a device class. + """ + return self.device_class not in (None, SensorDeviceClass.ENUM) + @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index fb079b9ff55..d1da0a8166f 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1,6 +1,7 @@ """The test for sensor entity.""" from __future__ import annotations +from collections.abc import Generator from datetime import date, datetime, timezone from decimal import Decimal from typing import Any @@ -11,10 +12,14 @@ from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import ( DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, + SensorEntity, + SensorEntityDescription, SensorStateClass, async_update_suggested_units, ) +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, @@ -30,16 +35,25 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, async_mock_restore_state_shutdown_restart, + mock_config_flow, + mock_integration, + mock_platform, mock_restore_cache_with_extra_data, ) +TEST_DOMAIN = "test" + @pytest.mark.parametrize( ("unit_system", "native_unit", "state_unit", "native_value", "state_value"), @@ -2260,3 +2274,91 @@ async def test_unit_conversion_update( state = hass.states.get(entity3.entity_id) assert state.state == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +async def test_name(hass: HomeAssistant) -> None: + """Test sensor name.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, SENSOR_DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed sensor without device class -> no name + entity1 = SensorEntity() + entity1.entity_id = "sensor.test1" + + # Unnamed sensor with device class but has_entity_name False -> no name + entity2 = SensorEntity() + entity2.entity_id = "sensor.test2" + entity2._attr_device_class = SensorDeviceClass.BATTERY + + # Unnamed sensor with device class and has_entity_name True -> named + entity3 = SensorEntity() + entity3.entity_id = "sensor.test3" + entity3._attr_device_class = SensorDeviceClass.BATTERY + entity3._attr_has_entity_name = True + + # Unnamed sensor with device class and has_entity_name True -> named + entity4 = SensorEntity() + entity4.entity_id = "sensor.test4" + entity4.entity_description = SensorEntityDescription( + "test", + SensorDeviceClass.BATTERY, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{SENSOR_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state.attributes == {} + + state = hass.states.get(entity2.entity_id) + assert state.attributes == {"device_class": "battery"} + + state = hass.states.get(entity3.entity_id) + assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} + + state = hass.states.get(entity4.entity_id) + assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} From 863b948e7c384d6ed649a28d34e77e8b31a38b2c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Jun 2023 23:26:37 +0200 Subject: [PATCH 387/857] Correct calls to super class in RecorderPool (#94923) --- homeassistant/components/recorder/pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 09b113f03eb..46f140305e3 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -92,7 +92,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] exclude_integrations={"recorder"}, error_if_core=False, ) - return super(NullPool, self)._create_connection() + return NullPool._create_connection(self) class MutexPool(StaticPool): From 1e078d5868c178ab7df4c25ae3e0dc538f319d35 Mon Sep 17 00:00:00 2001 From: Leandro Loureiro Date: Wed, 21 Jun 2023 01:43:02 +0200 Subject: [PATCH 388/857] Bump arcam_fmj lib to 1.4.0 to support Arcam ST60 (#94942) bumping arcam_fmj lib to 1.4.0 --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 9a76d4843f0..2c9b64b00ce 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.3.0"], + "requirements": ["arcam-fmj==1.4.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index 042a7d04d15..b775f40a691 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -429,7 +429,7 @@ aqualogic==2.6 aranet4==2.1.3 # homeassistant.components.arcam_fmj -arcam-fmj==1.3.0 +arcam-fmj==1.4.0 # homeassistant.components.arris_tg2492lg arris-tg2492lg==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bfbeb8024c..0755a43c1bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -389,7 +389,7 @@ aprslib==0.7.0 aranet4==2.1.3 # homeassistant.components.arcam_fmj -arcam-fmj==1.3.0 +arcam-fmj==1.4.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 31e9d95f66c3599ce56eceb258d0a48d7bfbb7a1 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 21 Jun 2023 08:23:22 +0200 Subject: [PATCH 389/857] Fix Netgear comment typo (#94927) --- homeassistant/components/netgear/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 8f81de43ebb..ef31a887691 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -208,7 +208,7 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: - """Remove a config entry from a device.""" + """Remove a device from a config entry.""" router = hass.data[DOMAIN][config_entry.entry_id][KEY_ROUTER] device_mac = None From f8bef95eb65bbc395e41931f1c1d88afa5c63ec9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 08:37:19 +0100 Subject: [PATCH 390/857] Reduce code in entity filter (#94882) --- homeassistant/helpers/entityfilter.py | 43 +++++++++++---------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index a9d3ccad138..1a449ec15f0 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -185,22 +185,6 @@ def _generate_filter_from_sets_and_pattern_lists( have_exclude = bool(exclude_e or exclude_d or exclude_eg) have_include = bool(include_e or include_d or include_eg) - def entity_included(domain: str, entity_id: str) -> bool: - """Return true if entity matches inclusion filters.""" - return ( - entity_id in include_e - or domain in include_d - or (bool(include_eg and include_eg.match(entity_id))) - ) - - def entity_excluded(domain: str, entity_id: str) -> bool: - """Return true if entity matches exclusion filters.""" - return ( - entity_id in exclude_e - or domain in exclude_d - or (bool(exclude_eg and exclude_eg.match(entity_id))) - ) - # Case 1 - No filter # - All entities included if not have_include and not have_exclude: @@ -213,12 +197,16 @@ def _generate_filter_from_sets_and_pattern_lists( # - Otherwise: exclude if have_include and not have_exclude: - def entity_filter_2(entity_id: str) -> bool: - """Return filter function for case 2.""" - domain = split_entity_id(entity_id)[0] - return entity_included(domain, entity_id) + def entity_included(entity_id: str) -> bool: + """Return true if entity matches inclusion filters.""" + return ( + entity_id in include_e + or split_entity_id(entity_id)[0] in include_d + or (bool(include_eg and include_eg.match(entity_id))) + ) - return entity_filter_2 + # Return filter function for case 2 + return entity_included # Case 3 - Only excludes # - Entity listed in exclude: exclude @@ -227,12 +215,15 @@ def _generate_filter_from_sets_and_pattern_lists( # - Otherwise: include if not have_include and have_exclude: - def entity_filter_3(entity_id: str) -> bool: - """Return filter function for case 3.""" - domain = split_entity_id(entity_id)[0] - return not entity_excluded(domain, entity_id) + def entity_not_excluded(entity_id: str) -> bool: + """Return true if entity matches exclusion filters.""" + return not ( + entity_id in exclude_e + or split_entity_id(entity_id)[0] in exclude_d + or (exclude_eg and exclude_eg.match(entity_id)) + ) - return entity_filter_3 + return entity_not_excluded # Case 4 - Domain and/or glob includes (may also have excludes) # - Entity listed in entities include: include From 933ae5198e08f8863cce54176d8f213d1ba8be4c Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 21 Jun 2023 10:21:36 +0200 Subject: [PATCH 391/857] Refactor devolo_home_network tests (#88706) * Refactor devolo_home_network tests * Reduce to snapshot introduction * Simplify * Update snapshots * Simplify further --- .../snapshots/test_binary_sensor.ambr | 45 +++++ .../snapshots/test_device_tracker.ambr | 17 ++ .../snapshots/test_switch.ambr | 173 ++++++++++++++++++ .../devolo_home_network/test_binary_sensor.py | 25 +-- .../devolo_home_network/test_config_flow.py | 2 +- .../test_device_tracker.py | 28 +-- .../devolo_home_network/test_switch.py | 24 +-- 7 files changed, 267 insertions(+), 47 deletions(-) create mode 100644 tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/devolo_home_network/snapshots/test_device_tracker.ambr create mode 100644 tests/components/devolo_home_network/snapshots/test_switch.ambr diff --git a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..f247f2dc1f0 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_update_attached_to_router + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'Mock Title Connected to router', + 'icon': 'mdi:router-network', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_title_connected_to_router', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_attached_to_router.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_title_connected_to_router', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:router-network', + 'original_name': 'Connected to router', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'connected_to_router', + 'unique_id': '1234567890_connected_to_router', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..d438aca6a4a --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_device_tracker + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'band': '5 GHz', + 'icon': 'mdi:lan-connect', + 'mac': 'AA:BB:CC:DD:EE:FF', + 'source_type': , + 'wifi': 'Main', + }), + 'context': , + 'entity_id': 'device_tracker.devolo_home_network_1234567890_aa_bb_cc_dd_ee_ff', + 'last_changed': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr new file mode 100644 index 00000000000..600c9478035 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -0,0 +1,173 @@ +# serializer version: 1 +# name: test_switches[enable_guest_wifi-async_get_wifi_guest_access-async_set_wifi_guest_access-interval0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Enable guest Wifi', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[enable_guest_wifi-async_get_wifi_guest_access-async_set_wifi_guest_access-interval0].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Enable guest Wifi', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_switch_guest_wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[enable_leds-async_get_led_setting-async_set_led_setting-interval1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Enable LEDs', + 'icon': 'mdi:led-off', + }), + 'context': , + 'entity_id': 'switch.mock_title_enable_leds', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[enable_leds-async_get_led_setting-async_set_led_setting-interval1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_enable_leds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:led-off', + 'original_name': 'Enable LEDs', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_switch_leds', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_enable_guest_wifi + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Enable guest Wifi', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_enable_guest_wifi.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Enable guest Wifi', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'switch_guest_wifi', + 'unique_id': '1234567890_switch_guest_wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_enable_leds + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Enable LEDs', + 'icon': 'mdi:led-off', + }), + 'context': , + 'entity_id': 'switch.mock_title_enable_leds', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_enable_leds.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_enable_leds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:led-off', + 'original_name': 'Enable LEDs', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'switch_leds', + 'unique_id': '1234567890_switch_leds', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index fc9cb232989..7a6395c20f1 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -3,19 +3,14 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN from homeassistant.components.devolo_home_network.const import ( CONNECTED_TO_ROUTER, LONG_UPDATE_INTERVAL, ) -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, - EntityCategory, -) +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -42,7 +37,10 @@ async def test_binary_sensor_setup(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_attached_to_router( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) @@ -52,15 +50,8 @@ async def test_update_attached_to_router( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_OFF - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Connected to router" - - assert ( - entity_registry.async_get(state_key).entity_category - == EntityCategory.DIAGNOSTIC - ) + assert hass.states.get(state_key) == snapshot + assert entity_registry.async_get(state_key) == snapshot # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 82aa983522e..91d7d6f39cf 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -31,7 +31,7 @@ from .mock import MockDevice from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, info: dict[str, Any]): +async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 4ccb16644ce..324f8b44041 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -2,20 +2,14 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable +from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as PLATFORM from homeassistant.components.devolo_home_network.const import ( DOMAIN, LONG_UPDATE_INTERVAL, - WIFI_APTYPE, - WIFI_BANDS, -) -from homeassistant.const import ( - STATE_HOME, - STATE_NOT_HOME, - STATE_UNAVAILABLE, - UnitOfFrequency, ) +from homeassistant.const import STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -31,7 +25,10 @@ SERIAL = DISCOVERY_INFO.properties["SN"] async def test_device_tracker( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" state_key = ( @@ -49,14 +46,7 @@ async def test_device_tracker( async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_HOME - assert state.attributes["wifi"] == WIFI_APTYPE[STATION.vap_type] - assert ( - state.attributes["band"] - == f"{WIFI_BANDS[STATION.band]} {UnitOfFrequency.GIGAHERTZ}" - ) + assert hass.states.get(state_key) == snapshot # Emulate state change mock_device.device.async_get_wifi_connected_station = AsyncMock( @@ -84,7 +74,9 @@ async def test_device_tracker( async def test_restoring_clients( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, ) -> None: """Test restoring existing device_tracker entities.""" state_key = ( diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index b2f0e54f971..8b84a0a9344 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from devolo_plc_api.device_api import WifiGuestAccessGet from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import ( DOMAIN, @@ -18,7 +19,6 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -72,7 +72,10 @@ async def test_update_guest_wifi_status_auth_failed( async def test_update_enable_guest_wifi( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test state change of a enable_guest_wifi switch device.""" entry = configure_integration(hass) @@ -82,9 +85,8 @@ async def test_update_enable_guest_wifi( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_OFF + assert hass.states.get(state_key) == snapshot + assert entity_registry.async_get(state_key) == snapshot # Emulate state change mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( @@ -159,7 +161,10 @@ async def test_update_enable_guest_wifi( async def test_update_enable_leds( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test state change of a enable_leds switch device.""" entry = configure_integration(hass) @@ -169,11 +174,8 @@ async def test_update_enable_leds( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_OFF - - assert entity_registry.async_get(state_key).entity_category == EntityCategory.CONFIG + assert hass.states.get(state_key) == snapshot + assert entity_registry.async_get(state_key) == snapshot # Emulate state change mock_device.device.async_get_led_setting.return_value = True From 804a8ef36a2dd81f47938d39d3afb8ce66c14385 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 10:00:21 +0100 Subject: [PATCH 392/857] Reduce overhead to update esphome entities (#94930) --- CODEOWNERS | 4 +- homeassistant/components/esphome/__init__.py | 188 +++++++----------- .../components/esphome/binary_sensor.py | 15 +- .../components/esphome/entry_data.py | 8 +- .../components/esphome/manifest.json | 2 +- homeassistant/components/esphome/sensor.py | 58 +++--- 6 files changed, 117 insertions(+), 158 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index cf747b9b69c..b41b14d46fb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -356,8 +356,8 @@ build.json @home-assistant/supervisor /homeassistant/components/eq3btsmart/ @rytilahti /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila -/homeassistant/components/esphome/ @OttoWinter @jesserockz -/tests/components/esphome/ @OttoWinter @jesserockz +/homeassistant/components/esphome/ @OttoWinter @jesserockz @bdraco +/tests/components/esphome/ @OttoWinter @jesserockz @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/evil_genius_labs/ @balloob diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index d774a1fc663..bfd023a9980 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -725,11 +725,12 @@ async def platform_async_setup_entry( # Then update the actual info entry_data.info[component_key] = new_infos - async_dispatcher_send( - hass, - entry_data.signal_component_static_info_updated(component_key), - new_infos, - ) + for key, new_info in new_infos.items(): + async_dispatcher_send( + hass, + entry_data.signal_component_key_static_info_updated(component_key, key), + new_info, + ) if add_entities: # Add entities to Home Assistant @@ -785,6 +786,8 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): _attr_should_poll = False _static_info: _InfoT + _state: _StateT + _has_state: bool def __init__( self, @@ -795,150 +798,117 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) -> None: """Initialize.""" self._entry_data = entry_data + self._on_entry_data_changed() self._component_key = component_key self._key = entity_info.key - self._static_info = cast(_InfoT, entity_info) self._state_type = state_type - if entry_data.device_info is not None and entry_data.device_info.friendly_name: - self._attr_has_entity_name = True + self._on_static_info_update(entity_info) + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info + self._attr_has_entity_name = bool(device_info.friendly_name) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + self._entry_id = entry_data.entry_id async def async_added_to_hass(self) -> None: """Register callbacks.""" + entry_data = self._entry_data + hass = self.hass + component_key = self._component_key + key = self._key + self.async_on_remove( async_dispatcher_connect( - self.hass, - f"esphome_{self._entry_id}_remove_{self._component_key}_{self._key}", + hass, + f"esphome_{self._entry_id}_remove_{component_key}_{key}", functools.partial(self.async_remove, force_remove=True), ) ) - self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_device_updated, + hass, + entry_data.signal_device_updated, self._on_device_update, ) ) - self.async_on_remove( - self._entry_data.async_subscribe_state_update( - self._state_type, self._key, self._on_state_update + entry_data.async_subscribe_state_update( + self._state_type, key, self._on_state_update ) ) - self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_component_static_info_updated( - self._component_key - ), + hass, + entry_data.signal_component_key_static_info_updated(component_key, key), self._on_static_info_update, ) ) @callback - def _on_static_info_update(self, static_infos: dict[int, EntityInfo]) -> None: + def _on_static_info_update(self, static_info: EntityInfo) -> None: """Save the static info for this entity when it changes. This method can be overridden in child classes to know when the static info changes. """ - self._static_info = cast(_InfoT, static_infos[self._key]) + static_info = cast(_InfoT, static_info) + self._static_info = static_info + self._attr_unique_id = static_info.unique_id + self._attr_entity_registry_enabled_default = not static_info.disabled_by_default + self._attr_name = static_info.name + if entity_category := static_info.entity_category: + self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) + else: + self._attr_entity_category = None + if icon := static_info.icon: + self._attr_icon = cast(str, ICON_SCHEMA(icon)) + else: + self._attr_icon = None @callback def _on_state_update(self) -> None: - # Behavior can be changed in child classes + """Call when state changed. + + Behavior can be changed in child classes + """ + state = self._entry_data.state + key = self._key + state_type = self._state_type + has_state = key in state[state_type] + if has_state: + self._state = cast(_StateT, state[state_type][key]) + self._has_state = has_state self.async_write_ha_state() + @callback + def _on_entry_data_changed(self) -> None: + entry_data = self._entry_data + self._api_version = entry_data.api_version + self._client = entry_data.client + @callback def _on_device_update(self) -> None: - """Update the entity state when device info has changed.""" - if self._entry_data.available: - # Don't update the HA state yet when the device comes online. - # Only update the HA state when the full state arrives + """Call when device updates or entry data changes.""" + self._on_entry_data_changed() + if not self._entry_data.available: + # Only write state if the device has gone unavailable + # since _on_state_update will be called if the device + # is available when the full state arrives # through the next entity state packet. - return - self._on_state_update() - - @property - def _entry_id(self) -> str: - return self._entry_data.entry_id - - @property - def _api_version(self) -> APIVersion: - return self._entry_data.api_version - - @property - def _device_info(self) -> EsphomeDeviceInfo: - assert self._entry_data.device_info is not None - return self._entry_data.device_info - - @property - def _client(self) -> APIClient: - return self._entry_data.client - - @property - def _state(self) -> _StateT: - return cast(_StateT, self._entry_data.state[self._state_type][self._key]) - - @property - def _has_state(self) -> bool: - return self._key in self._entry_data.state[self._state_type] + self.async_write_ha_state() @property def available(self) -> bool: """Return if the entity is available.""" - device = self._device_info - - if device.has_deep_sleep: + if self._device_info.has_deep_sleep: # During deep sleep the ESP will not be connectable (by design) # For these cases, show it as available return True return self._entry_data.available - @property - def unique_id(self) -> str | None: - """Return a unique id identifying the entity.""" - if not self._static_info.unique_id: - return None - return self._static_info.unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} - ) - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._static_info.name - - @property - def icon(self) -> str | None: - """Return the icon.""" - if not self._static_info.icon: - return None - - return cast(str, ICON_SCHEMA(self._static_info.icon)) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added. - - This only applies when fist added to the entity registry. - """ - return not self._static_info.disabled_by_default - - @property - def entity_category(self) -> EntityCategory | None: - """Return the category of the entity, if any.""" - if not self._static_info.entity_category: - return None - return ENTITY_CATEGORIES.from_esphome(self._static_info.entity_category) - class EsphomeAssistEntity(Entity): """Define a base entity for Assist Pipeline entities.""" @@ -949,20 +919,14 @@ class EsphomeAssistEntity(Entity): def __init__(self, entry_data: RuntimeEntryData) -> None: """Initialize the binary sensor.""" self._entry_data: RuntimeEntryData = entry_data + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info self._attr_unique_id = ( - f"{self._device_info.mac_address}-{self.entity_description.key}" + f"{device_info.mac_address}-{self.entity_description.key}" ) - - @property - def _device_info(self) -> EsphomeDeviceInfo: - assert self._entry_data.device_info is not None - return self._entry_data.device_info - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) @callback diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 77ec780acb3..6d99349a461 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -1,7 +1,7 @@ """Support for ESPHome binary sensors.""" from __future__ import annotations -from aioesphomeapi import BinarySensorInfo, BinarySensorState +from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum @@ -55,10 +55,13 @@ class EsphomeBinarySensor( return None return self._state.state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(BinarySensorDeviceClass, self._static_info.device_class) + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + self._attr_device_class = try_parse_enum( + BinarySensorDeviceClass, self._static_info.device_class + ) @property def available(self) -> bool: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index e4daa524088..4b4b359e15b 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -129,9 +129,11 @@ class RuntimeEntryData: """Return the signal to listen to for updates on static info.""" return f"esphome_{self.entry_id}_on_list" - def signal_component_static_info_updated(self, component_key: str) -> str: - """Return the signal to listen to for updates on static info for a specific component_key.""" - return f"esphome_{self.entry_id}_static_info_updated_{component_key}" + def signal_component_key_static_info_updated( + self, component_key: str, key: int + ) -> str: + """Return the signal to listen to for updates on static info for a specific component_key and key.""" + return f"esphome_{self.entry_id}_static_info_updated_{component_key}_{key}" @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5a064e9b802..a5e370aec44 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -2,7 +2,7 @@ "domain": "esphome", "name": "ESPHome", "after_dependencies": ["zeroconf", "tag"], - "codeowners": ["@OttoWinter", "@jesserockz"], + "codeowners": ["@OttoWinter", "@jesserockz", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth"], "dhcp": [ diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 7a1234341be..46b8111ddeb 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -5,6 +5,7 @@ from datetime import datetime import math from aioesphomeapi import ( + EntityInfo, SensorInfo, SensorState, SensorStateClass as EsphomeSensorStateClass, @@ -19,7 +20,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -67,10 +68,27 @@ _STATE_CLASSES: EsphomeEnumMapper[ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): """A sensor implementation for esphome.""" - @property - def force_update(self) -> bool: - """Return if this sensor should force a state update.""" - return self._static_info.force_update + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_force_update = static_info.force_update + self._attr_native_unit_of_measurement = static_info.unit_of_measurement + self._attr_device_class = try_parse_enum( + SensorDeviceClass, static_info.device_class + ) + if not (state_class := static_info.state_class): + return + if ( + state_class == EsphomeSensorStateClass.MEASUREMENT + and static_info.last_reset_type == LastResetType.AUTO + ): + # Legacy, last_reset_type auto was the equivalent to the + # TOTAL_INCREASING state class + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + else: + self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) @property @esphome_state_property @@ -80,38 +98,10 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return None if self._state.missing_state: return None - if self.device_class == SensorDeviceClass.TIMESTAMP: + if self._attr_device_class == SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(self._state.state) return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - if not self._static_info.unit_of_measurement: - return None - return self._static_info.unit_of_measurement - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(SensorDeviceClass, self._static_info.device_class) - - @property - def state_class(self) -> SensorStateClass | None: - """Return the state class of this entity.""" - if not self._static_info.state_class: - return None - state_class = self._static_info.state_class - reset_type = self._static_info.last_reset_type - if ( - state_class == EsphomeSensorStateClass.MEASUREMENT - and reset_type == LastResetType.AUTO - ): - # Legacy, last_reset_type auto was the equivalent to the - # TOTAL_INCREASING state class - return SensorStateClass.TOTAL_INCREASING - return _STATE_CLASSES.from_esphome(self._static_info.state_class) - class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): """A text sensor implementation for ESPHome.""" From 1d18fdf7bdb67381f8cc6a0eb0afcbef1a08020b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 11:11:07 +0200 Subject: [PATCH 393/857] Improve alarm_control_panel device trigger tests (#94956) --- .../test_device_trigger.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 1956c4ac55e..57b9f8125c2 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -210,6 +210,41 @@ async def test_get_trigger_capabilities( } +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from an alarm_control_panel.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + hass.states.async_set( + "alarm_control_panel.test_5678", "attributes", {"supported_features": 15} + ) + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 6 + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + + async def test_if_fires_on_state_change( hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] ): @@ -469,3 +504,55 @@ async def test_if_fires_on_state_change_with_for( calls[0].data["some"] == f"turn_off device - {entry.entity_id} - disarmed - triggered - 0:00:05" ) + + +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "triggered", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + } + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - disarmed - triggered - None" + ) From 60b78f46486ba420cc4ed7419f6557d3b678c91e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 21 Jun 2023 11:17:11 +0200 Subject: [PATCH 394/857] Add error handling to hassio issues (#94951) --- homeassistant/components/hassio/issues.py | 6 +++++- tests/components/hassio/test_issues.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 2af0a6ed764..0bbd89aab86 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -306,7 +306,11 @@ class SupervisorIssues: async def update(self) -> None: """Update issues from Supervisor resolution center.""" - data = await self._client.get_resolution_info() + try: + data = await self._client.get_resolution_info() + except HassioAPIError as err: + _LOGGER.error("Failed to update supervisor issues: %r", err) + return self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 7bd30e452c0..4d694b79e46 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -715,3 +715,21 @@ async def test_supervisor_remove_missing_issue_without_error( msg = await client.receive_json() assert msg["success"] await hass.async_block_till_done() + + +async def test_system_is_not_ready( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Ensure hassio starts despite error.""" + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "", + "message": "System is not ready with state: setup", + }, + ) + + assert await async_setup_component(hass, "hassio", {}) + assert "Failed to update supervisor issues" in caplog.text From aec946e93a0f9fe236c619bcbe25e636e889238a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 21 Jun 2023 11:17:36 +0200 Subject: [PATCH 395/857] Improve description in Workday config flow (#94945) --- homeassistant/components/workday/config_flow.py | 9 ++++++++- homeassistant/components/workday/strings.json | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index a2d804a11c4..bfa6c299b57 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -220,7 +220,10 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): step_id="options", data_schema=new_schema, errors=errors, - description_placeholders={"name": self.data[CONF_NAME]}, + description_placeholders={ + "name": self.data[CONF_NAME], + "country": self.data[CONF_COUNTRY], + }, ) @@ -277,6 +280,10 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): step_id="init", data_schema=new_schema, errors=errors, + description_placeholders={ + "name": self.options[CONF_NAME], + "country": self.options[CONF_COUNTRY], + }, ) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 4ec1cf34e99..5af69e29a8b 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -12,7 +12,7 @@ } }, "options": { - "description": "Set workday options for {name}", + "description": "Set additional options for {name} configured for country {country}", "data": { "excludes": "Excludes", "days_offset": "Offset", @@ -39,6 +39,7 @@ "options": { "step": { "init": { + "description": "Change additional options for {name} configured for country {country}", "data": { "excludes": "[%key:component::workday::config::step::options::data::excludes%]", "days_offset": "[%key:component::workday::config::step::options::data::days_offset%]", From 83c478105bdb97a9710b4714702816561b7e5c27 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 21 Jun 2023 11:18:55 +0200 Subject: [PATCH 396/857] Add entity translations for huisbaasje (#94116) --- homeassistant/components/huisbaasje/sensor.py | 37 ++++----- .../components/huisbaasje/strings.json | 58 ++++++++++++++ tests/components/huisbaasje/test_sensor.py | 80 +++++++------------ 3 files changed, 107 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 369c6eba075..8bc86d423a1 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -58,7 +58,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): SENSORS_INFO = [ HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power", + translation_key="current_power", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -67,7 +67,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power In Peak", + translation_key="current_power_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -76,7 +76,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power In Off Peak", + translation_key="current_power_off_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -85,7 +85,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power Out Peak", + translation_key="current_power_out_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -94,7 +94,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power Out Off Peak", + translation_key="current_power_out_off_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -103,7 +103,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Consumption Peak Today", + translation_key="energy_consumption_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_IN, @@ -113,7 +113,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Consumption Off Peak Today", + translation_key="energy_consumption_off_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_IN_LOW, @@ -123,7 +123,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Production Peak Today", + translation_key="energy_production_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_OUT, @@ -133,7 +133,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Production Off Peak Today", + translation_key="energy_production_off_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_OUT_LOW, @@ -143,7 +143,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Today", + translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -153,7 +153,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy This Week", + translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -163,7 +163,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy This Month", + translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -173,7 +173,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy This Year", + translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -183,7 +183,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Gas", + translation_key="current_gas", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, @@ -192,7 +192,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas Today", + translation_key="gas_today", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -202,7 +202,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas This Week", + translation_key="gas_week", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -212,7 +212,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas This Month", + translation_key="gas_month", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -222,7 +222,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas This Year", + translation_key="gas_year", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -257,6 +257,7 @@ class HuisbaasjeSensor( """Defines a Huisbaasje sensor.""" entity_description: HuisbaasjeSensorEntityDescription + _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/huisbaasje/strings.json b/homeassistant/components/huisbaasje/strings.json index 169b9a0e901..de112f7519f 100644 --- a/homeassistant/components/huisbaasje/strings.json +++ b/homeassistant/components/huisbaasje/strings.json @@ -16,5 +16,63 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "current_power": { + "name": "Current power" + }, + "current_power_peak": { + "name": "Current power in peak" + }, + "current_power_off_peak": { + "name": "Current power in off peak" + }, + "current_power_out_peak": { + "name": "Current power out peak" + }, + "current_power_out_off_peak": { + "name": "Current power out off peak" + }, + "energy_consumption_peak_today": { + "name": "Energy consumption peak today" + }, + "energy_consumption_off_peak_today": { + "name": "Energy consumption off peak today" + }, + "energy_production_peak_today": { + "name": "Energy production peak today" + }, + "energy_production_off_peak_today": { + "name": "Energy production off peak today" + }, + "energy_today": { + "name": "Energy today" + }, + "energy_week": { + "name": "Energy this week" + }, + "energy_month": { + "name": "Energy this month" + }, + "energy_year": { + "name": "Energy this year" + }, + "current_gas": { + "name": "Current gas" + }, + "gas_today": { + "name": "Gas today" + }, + "gas_week": { + "name": "Gas this week" + }, + "gas_month": { + "name": "Gas this month" + }, + "gas_year": { + "name": "Gas this year" + } + } } } diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index d3a65a0be6d..b324a5be970 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -54,7 +54,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Assert data is loaded - current_power = hass.states.get("sensor.huisbaasje_current_power") + current_power = hass.states.get("sensor.current_power") assert current_power.state == "1012.0" assert ( current_power.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER @@ -68,7 +68,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: current_power.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT ) - current_power_in = hass.states.get("sensor.huisbaasje_current_power_in_peak") + current_power_in = hass.states.get("sensor.current_power_in_peak") assert current_power_in.state == "1012.0" assert ( current_power_in.attributes.get(ATTR_DEVICE_CLASS) @@ -84,9 +84,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfPower.WATT ) - current_power_in_low = hass.states.get( - "sensor.huisbaasje_current_power_in_off_peak" - ) + current_power_in_low = hass.states.get("sensor.current_power_in_off_peak") assert current_power_in_low.state == "unknown" assert ( current_power_in_low.attributes.get(ATTR_DEVICE_CLASS) @@ -102,7 +100,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfPower.WATT ) - current_power_out = hass.states.get("sensor.huisbaasje_current_power_out_peak") + current_power_out = hass.states.get("sensor.current_power_out_peak") assert current_power_out.state == "unknown" assert ( current_power_out.attributes.get(ATTR_DEVICE_CLASS) @@ -118,9 +116,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfPower.WATT ) - current_power_out_low = hass.states.get( - "sensor.huisbaasje_current_power_out_off_peak" - ) + current_power_out_low = hass.states.get("sensor.current_power_out_off_peak") assert current_power_out_low.state == "unknown" assert ( current_power_out_low.attributes.get(ATTR_DEVICE_CLASS) @@ -137,7 +133,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_consumption_peak_today = hass.states.get( - "sensor.huisbaasje_energy_consumption_peak_today" + "sensor.energy_consumption_peak_today" ) assert energy_consumption_peak_today.state == "2.67" assert ( @@ -158,7 +154,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_consumption_off_peak_today = hass.states.get( - "sensor.huisbaasje_energy_consumption_off_peak_today" + "sensor.energy_consumption_off_peak_today" ) assert energy_consumption_off_peak_today.state == "0.627" assert ( @@ -179,7 +175,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_production_peak_today = hass.states.get( - "sensor.huisbaasje_energy_production_peak_today" + "sensor.energy_production_peak_today" ) assert energy_production_peak_today.state == "1.512" assert ( @@ -200,7 +196,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_production_off_peak_today = hass.states.get( - "sensor.huisbaasje_energy_production_off_peak_today" + "sensor.energy_production_off_peak_today" ) assert energy_production_off_peak_today.state == "1.093" assert ( @@ -220,7 +216,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfEnergy.KILO_WATT_HOUR ) - energy_today = hass.states.get("sensor.huisbaasje_energy_today") + energy_today = hass.states.get("sensor.energy_today") assert energy_today.state == "3.3" assert ( energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -235,7 +231,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfEnergy.KILO_WATT_HOUR ) - energy_this_week = hass.states.get("sensor.huisbaasje_energy_this_week") + energy_this_week = hass.states.get("sensor.energy_this_week") assert energy_this_week.state == "17.5" assert ( energy_this_week.attributes.get(ATTR_DEVICE_CLASS) @@ -251,7 +247,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfEnergy.KILO_WATT_HOUR ) - energy_this_month = hass.states.get("sensor.huisbaasje_energy_this_month") + energy_this_month = hass.states.get("sensor.energy_this_month") assert energy_this_month.state == "103.3" assert ( energy_this_month.attributes.get(ATTR_DEVICE_CLASS) @@ -267,7 +263,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfEnergy.KILO_WATT_HOUR ) - energy_this_year = hass.states.get("sensor.huisbaasje_energy_this_year") + energy_this_year = hass.states.get("sensor.energy_this_year") assert energy_this_year.state == "673.0" assert ( energy_this_year.attributes.get(ATTR_DEVICE_CLASS) @@ -283,7 +279,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfEnergy.KILO_WATT_HOUR ) - current_gas = hass.states.get("sensor.huisbaasje_current_gas") + current_gas = hass.states.get("sensor.current_gas") assert current_gas.state == "0.0" assert current_gas.attributes.get(ATTR_DEVICE_CLASS) is None assert current_gas.attributes.get(ATTR_ICON) == "mdi:fire" @@ -295,7 +291,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR ) - gas_today = hass.states.get("sensor.huisbaasje_gas_today") + gas_today = hass.states.get("sensor.gas_today") assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" @@ -308,7 +304,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfVolume.CUBIC_METERS ) - gas_this_week = hass.states.get("sensor.huisbaasje_gas_this_week") + gas_this_week = hass.states.get("sensor.gas_this_week") assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" @@ -321,7 +317,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfVolume.CUBIC_METERS ) - gas_this_month = hass.states.get("sensor.huisbaasje_gas_this_month") + gas_this_month = hass.states.get("sensor.gas_this_month") assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" @@ -334,7 +330,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: == UnitOfVolume.CUBIC_METERS ) - gas_this_year = hass.states.get("sensor.huisbaasje_gas_this_year") + gas_this_year = hass.states.get("sensor.gas_this_year") assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" @@ -381,42 +377,26 @@ async def test_setup_entry_absent_measurement(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Assert data is loaded - assert hass.states.get("sensor.huisbaasje_current_power").state == "1012.0" + assert hass.states.get("sensor.current_power").state == "1012.0" + assert hass.states.get("sensor.current_power_in_peak").state == "unknown" + assert hass.states.get("sensor.current_power_in_off_peak").state == "unknown" + assert hass.states.get("sensor.current_power_out_peak").state == "unknown" + assert hass.states.get("sensor.current_power_out_off_peak").state == "unknown" + assert hass.states.get("sensor.current_gas").state == "unknown" + assert hass.states.get("sensor.energy_today").state == "3.3" assert ( - hass.states.get("sensor.huisbaasje_current_power_in_peak").state - == "unknown" + hass.states.get("sensor.energy_consumption_peak_today").state == "unknown" ) assert ( - hass.states.get("sensor.huisbaasje_current_power_in_off_peak").state + hass.states.get("sensor.energy_consumption_off_peak_today").state == "unknown" ) + assert hass.states.get("sensor.energy_production_peak_today").state == "unknown" assert ( - hass.states.get("sensor.huisbaasje_current_power_out_peak").state + hass.states.get("sensor.energy_production_off_peak_today").state == "unknown" ) - assert ( - hass.states.get("sensor.huisbaasje_current_power_out_off_peak").state - == "unknown" - ) - assert hass.states.get("sensor.huisbaasje_current_gas").state == "unknown" - assert hass.states.get("sensor.huisbaasje_energy_today").state == "3.3" - assert ( - hass.states.get("sensor.huisbaasje_energy_consumption_peak_today").state - == "unknown" - ) - assert ( - hass.states.get("sensor.huisbaasje_energy_consumption_off_peak_today").state - == "unknown" - ) - assert ( - hass.states.get("sensor.huisbaasje_energy_production_peak_today").state - == "unknown" - ) - assert ( - hass.states.get("sensor.huisbaasje_energy_production_off_peak_today").state - == "unknown" - ) - assert hass.states.get("sensor.huisbaasje_gas_today").state == "unknown" + assert hass.states.get("sensor.gas_today").state == "unknown" # Assert mocks are called assert len(mock_authenticate.mock_calls) == 1 From b254218dd6e4380f3ac80caa177cfccc0ae9291d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 11:20:33 +0200 Subject: [PATCH 397/857] Remove `base_url` configuration option from `tts` (#94905) --- homeassistant/components/cast/media_player.py | 10 ----- homeassistant/components/tts/__init__.py | 22 +--------- homeassistant/components/tts/const.py | 1 - homeassistant/components/tts/legacy.py | 14 ------- homeassistant/components/tts/media_source.py | 4 -- tests/components/cast/test_media_player.py | 31 +------------- tests/components/tts/test_init.py | 41 ------------------- tests/components/tts/test_legacy.py | 33 +-------------- 8 files changed, 5 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index ee3834e4edd..d32ff07c261 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -362,15 +362,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): ): external_url = None internal_url = None - tts_base_url = None url_description = "" - if "tts" in self.hass.config.components: - # pylint: disable-next=[import-outside-toplevel] - from homeassistant.components import tts - - with suppress(KeyError): # base_url not configured - tts_base_url = tts.get_base_url(self.hass) - with suppress(NoURLAvailableError): # external_url not configured external_url = get_url(self.hass, allow_internal=False) @@ -378,8 +370,6 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): internal_url = get_url(self.hass, allow_external=False) if media_status.content_id: - if tts_base_url and media_status.content_id.startswith(tts_base_url): - url_description = f" from tts.base_url ({tts_base_url})" if external_url and media_status.content_id.startswith(external_url): url_description = f" from external_url ({external_url})" if internal_url and media_status.content_id.startswith(internal_url): diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 8ee1b67020a..4402722e37f 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -52,7 +52,6 @@ from .const import ( ATTR_LANGUAGE, ATTR_MESSAGE, ATTR_OPTIONS, - CONF_BASE_URL, CONF_CACHE, CONF_CACHE_DIR, CONF_TIME_MEMORY, @@ -76,7 +75,6 @@ __all__ = [ "CONF_LANG", "DEFAULT_CACHE_DIR", "generate_media_source_id", - "get_base_url", "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", "Provider", @@ -93,8 +91,6 @@ ATTR_VOICE = "voice" CONF_LANG = "language" -BASE_URL_KEY = "tts_base_url" - SERVICE_CLEAR_CACHE = "clear_cache" _RE_LEGACY_VOICE_FILE = re.compile( @@ -214,15 +210,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - base_url: str | None = conf.get(CONF_BASE_URL) - if base_url is not None: - _LOGGER.warning( - "TTS base_url option is deprecated. Configure internal/external URL" - " instead" - ) - hass.data[BASE_URL_KEY] = base_url - tts = SpeechManager(hass, use_cache, cache_dir, time_memory, base_url) + tts = SpeechManager(hass, use_cache, cache_dir, time_memory) try: await tts.async_init_cache() @@ -413,7 +402,6 @@ class SpeechManager: use_cache: bool, cache_dir: str, time_memory: int, - base_url: str | None, ) -> None: """Initialize a speech store.""" self.hass = hass @@ -422,7 +410,6 @@ class SpeechManager: self.use_cache = use_cache self.cache_dir = cache_dir self.time_memory = time_memory - self.base_url = base_url self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} @@ -886,7 +873,7 @@ class TextToSpeechUrlView(HomeAssistantView): _LOGGER.error("Error on init tts: %s", err) return self.json({"error": err}, HTTPStatus.BAD_REQUEST) - base = self.tts.base_url or get_url(self.tts.hass) + base = get_url(self.tts.hass) url = base + path return self.json({"url": url, "path": path}) @@ -914,11 +901,6 @@ class TextToSpeechView(HomeAssistantView): return web.Response(body=data, content_type=content) -def get_base_url(hass: HomeAssistant) -> str: - """Get base URL.""" - return hass.data[BASE_URL_KEY] or get_url(hass) - - @websocket_api.websocket_command( { "type": "tts/engine/list", diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index 3427b761fa6..f721731330c 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -4,7 +4,6 @@ ATTR_LANGUAGE = "language" ATTR_MESSAGE = "message" ATTR_OPTIONS = "options" -CONF_BASE_URL = "base_url" CONF_CACHE = "cache" CONF_CACHE_DIR = "cache_dir" CONF_FIELDS = "fields" diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 619c9374622..4734c3f22d1 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, cast import voluptuous as vol -import yarl from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, @@ -31,7 +30,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.network import normalize_url from homeassistant.util.yaml import load_yaml from .const import ( @@ -39,7 +37,6 @@ from .const import ( ATTR_LANGUAGE, ATTR_MESSAGE, ATTR_OPTIONS, - CONF_BASE_URL, CONF_CACHE, CONF_CACHE_DIR, CONF_FIELDS, @@ -72,16 +69,6 @@ def _deprecated_platform(value: str) -> str: return value -def _valid_base_url(value: str) -> str: - """Validate base url, return value.""" - url = yarl.URL(cv.url(value)) - - if url.path != "/": - raise vol.Invalid("Path should be empty") - - return normalize_url(value) - - PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( { vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform), @@ -90,7 +77,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY): vol.All( vol.Coerce(int), vol.Range(min=60, max=57600) ), - vol.Optional(CONF_BASE_URL): _valid_base_url, vol.Optional(CONF_SERVICE_NAME): cv.string, } ) diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index d371d457dde..837a15a4f88 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -18,7 +18,6 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.network import get_url from .const import DATA_TTS_MANAGER, DOMAIN from .helper import get_engine_instance @@ -124,9 +123,6 @@ class TTSMediaSource(MediaSource): mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" - if manager.base_url and manager.base_url != get_url(self.hass): - url = f"{manager.base_url}{url}" - return PlayMedia(url, mime_type) async def async_browse_media( diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index f290754d6fa..6f7a13b47af 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1894,7 +1894,7 @@ async def test_failed_cast_other_url( assert await async_setup_component( hass, tts.DOMAIN, - {tts.DOMAIN: {"platform": "demo", "base_url": "http://example.local:8123"}}, + {tts.DOMAIN: {"platform": "demo"}}, ) info = get_fake_chromecast_info() @@ -1951,7 +1951,7 @@ async def test_failed_cast_external_url( assert await async_setup_component( hass, tts.DOMAIN, - {tts.DOMAIN: {"platform": "demo", "base_url": "http://example.com:8123"}}, + {tts.DOMAIN: {"platform": "demo"}}, ) info = get_fake_chromecast_info() @@ -1969,33 +1969,6 @@ async def test_failed_cast_external_url( ) -async def test_failed_cast_tts_base_url( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test warning when casting from tts.base_url fails.""" - await async_setup_component(hass, "homeassistant", {}) - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component( - hass, - tts.DOMAIN, - {tts.DOMAIN: {"platform": "demo", "base_url": "http://example.local:8123"}}, - ) - - info = get_fake_chromecast_info() - chromecast, _ = await async_setup_media_player_cast(hass, info) - _, _, media_status_cb = get_status_callbacks(chromecast) - - media_status = MagicMock(images=None) - media_status.player_is_idle = True - media_status.idle_reason = "ERROR" - media_status.content_id = "http://example.local:8123/tts.mp3" - media_status_cb(media_status) - assert ( - "Failed to cast media http://example.local:8123/tts.mp3 from tts.base_url" - in caplog.text - ) - - async def test_disconnect_on_stop(hass: HomeAssistant) -> None: """Test cast device disconnects socket on stop.""" info = get_fake_chromecast_info() diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index cedc4c7cae9..2656beba236 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -5,7 +5,6 @@ from typing import Any from unittest.mock import MagicMock, patch import pytest -import voluptuous as vol from homeassistant.components import tts from homeassistant.components.media_player import ( @@ -17,7 +16,6 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.components.media_source import Unresolvable -from homeassistant.components.tts.legacy import _valid_base_url from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -25,7 +23,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.network import normalize_url from .common import ( DEFAULT_LANG, @@ -1315,44 +1312,6 @@ async def test_tags_with_wave() -> None: assert tagged_data != tts_data -@pytest.mark.parametrize( - "value", - ( - "http://example.local:8123", - "http://example.local", - "http://example.local:80", - "https://example.com", - "https://example.com:443", - "https://example.com:8123", - ), -) -def test_valid_base_url(value) -> None: - """Test we validate base urls.""" - assert _valid_base_url(value) == normalize_url(value) - # Test we strip trailing `/` - assert _valid_base_url(value + "/") == normalize_url(value) - - -@pytest.mark.parametrize( - "value", - ( - "http://example.local:8123/sub-path", - "http://example.local/sub-path", - "https://example.com/sub-path", - "https://example.com:8123/sub-path", - "mailto:some@email", - "http:example.com", - "http:/example.com", - "http//example.com", - "example.com", - ), -) -def test_invalid_base_url(value) -> None: - """Test we catch bad base urls.""" - with pytest.raises(vol.Invalid): - _valid_base_url(value) - - @pytest.mark.parametrize( ("setup", "result_engine"), [ diff --git a/tests/components/tts/test_legacy.py b/tests/components/tts/test_legacy.py index 26b7c2397b4..5a8321e2ae4 100644 --- a/tests/components/tts/test_legacy.py +++ b/tests/components/tts/test_legacy.py @@ -4,11 +4,8 @@ from __future__ import annotations import pytest from homeassistant.components.media_player import ( - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, - MediaType, ) from homeassistant.components.tts import ATTR_MESSAGE, DOMAIN, Provider from homeassistant.const import ATTR_ENTITY_ID @@ -17,7 +14,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -from .common import SUPPORT_LANGUAGES, MockProvider, MockTTS, get_media_source_url +from .common import SUPPORT_LANGUAGES, MockProvider, MockTTS from tests.common import ( MockModule, @@ -140,34 +137,6 @@ async def test_platform_setup_with_error( assert "Error setting up platform: bad_tts" in caplog.text -async def test_service_base_url_set(hass: HomeAssistant, mock_tts) -> None: - """Set up a TTS platform with ``base_url`` set and call service.""" - calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - config = {DOMAIN: {"platform": "test", "base_url": "http://fnord"}} - - with assert_setup_component(1, DOMAIN): - assert await async_setup_component(hass, DOMAIN, config) - - await hass.services.async_call( - DOMAIN, - "test_say", - { - ATTR_ENTITY_ID: "media_player.something", - ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "http://fnord" - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - "_en-us_-_test.mp3" - ) - - async def test_service_without_cache_config( hass: HomeAssistant, mock_tts_cache_dir, mock_tts ) -> None: From 30453f69829a476e42e1ec58b8729bebb9a9b2f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 21 Jun 2023 11:26:24 +0200 Subject: [PATCH 398/857] Add entity translations for Adguard (#94171) --- homeassistant/components/adguard/sensor.py | 16 +++---- homeassistant/components/adguard/strings.json | 48 +++++++++++++++++++ homeassistant/components/adguard/switch.py | 12 ++--- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index f24aa20d28d..9f1c0a5b0fe 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -39,56 +39,56 @@ class AdGuardHomeEntityDescription( SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( AdGuardHomeEntityDescription( key="dns_queries", - name="DNS queries", + translation_key="dns_queries", icon="mdi:magnify", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.dns_queries(), ), AdGuardHomeEntityDescription( key="blocked_filtering", - name="DNS queries blocked", + translation_key="dns_queries_blocked", icon="mdi:magnify-close", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.blocked_filtering(), ), AdGuardHomeEntityDescription( key="blocked_percentage", - name="DNS queries blocked ratio", + translation_key="dns_queries_blocked_ratio", icon="mdi:magnify-close", native_unit_of_measurement=PERCENTAGE, value_fn=lambda adguard: adguard.stats.blocked_percentage(), ), AdGuardHomeEntityDescription( key="blocked_parental", - name="Parental control blocked", + translation_key="parental_control_blocked", icon="mdi:human-male-girl", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_parental(), ), AdGuardHomeEntityDescription( key="blocked_safebrowsing", - name="Safe browsing blocked", + translation_key="safe_browsing_blocked", icon="mdi:shield-half-full", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safebrowsing(), ), AdGuardHomeEntityDescription( key="enforced_safesearch", - name="Safe searches enforced", + translation_key="safe_searches_enforced", icon="mdi:shield-search", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safesearch(), ), AdGuardHomeEntityDescription( key="average_speed", - name="Average processing speed", + translation_key="average_processing_speed", icon="mdi:speedometer", native_unit_of_measurement=UnitOfTime.MILLISECONDS, value_fn=lambda adguard: adguard.stats.avg_processing_time(), ), AdGuardHomeEntityDescription( key="rules_count", - name="Rules count", + translation_key="rules_count", icon="mdi:counter", native_unit_of_measurement="rules", value_fn=lambda adguard: adguard.filtering.rules_count(allowlist=False), diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index e593d4199a4..bde73e82b37 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -24,5 +24,53 @@ "existing_instance_updated": "Updated existing configuration.", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "dns_queries": { + "name": "DNS queries" + }, + "dns_queries_blocked": { + "name": "DNS queries blocked" + }, + "dns_queries_blocked_ratio": { + "name": "DNS queries blocked ratio" + }, + "parental_control_blocked": { + "name": "Parental control blocked" + }, + "safe_browsing_blocked": { + "name": "Safe browsing blocked" + }, + "safe_searches_enforced": { + "name": "Safe searches enforced" + }, + "average_processing_speed": { + "name": "Average processing speed" + }, + "rules_count": { + "name": "Rules count" + } + }, + "switch": { + "protection": { + "name": "Protection" + }, + "parental": { + "name": "Parental control" + }, + "safe_search": { + "name": "Safe search" + }, + "safe_browsing": { + "name": "Safe browsing" + }, + "filtering": { + "name": "Filtering" + }, + "query_log": { + "name": "Query log" + } + } } } diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index a359bf86c2d..1020e8690f1 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -40,7 +40,7 @@ class AdGuardHomeSwitchEntityDescription( SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="protection", - name="Protection", + translation_key="protection", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.protection_enabled, turn_on_fn=lambda adguard: adguard.enable_protection, @@ -48,7 +48,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="parental", - name="Parental control", + translation_key="parental", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.parental.enabled, turn_on_fn=lambda adguard: adguard.parental.enable, @@ -56,7 +56,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="safesearch", - name="Safe search", + translation_key="safe_search", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safesearch.enabled, turn_on_fn=lambda adguard: adguard.safesearch.enable, @@ -64,7 +64,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="safebrowsing", - name="Safe browsing", + translation_key="safe_browsing", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safebrowsing.enabled, turn_on_fn=lambda adguard: adguard.safebrowsing.enable, @@ -72,7 +72,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="filtering", - name="Filtering", + translation_key="filtering", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.filtering.enabled, turn_on_fn=lambda adguard: adguard.filtering.enable, @@ -80,7 +80,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="querylog", - name="Query log", + translation_key="query_log", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.querylog.enabled, turn_on_fn=lambda adguard: adguard.querylog.enable, From c47543c9dd5712aba9c2ca587ff0c82fd4791051 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 21 Jun 2023 11:28:12 +0200 Subject: [PATCH 399/857] Add current_humidity attribute to tuya (de)humidifiers (#94953) Co-authored-by: Franck Nijhof --- homeassistant/components/tuya/humidifier.py | 26 ++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index a9564b94ddc..458a2681186 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -28,6 +28,7 @@ class TuyaHumidifierEntityDescription(HumidifierEntityDescription): # DPCode, to use. If None, the key will be used as DPCode dpcode: DPCode | tuple[DPCode, ...] | None = None + current_humidity: DPCode | None = None humidity: DPCode | None = None @@ -37,6 +38,7 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { "cs": TuyaHumidifierEntityDescription( key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), + current_humidity=DPCode.HUMIDITY_INDOOR, humidity=DPCode.DEHUMIDITY_SET_VALUE, device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), @@ -45,6 +47,7 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { "jsq": TuyaHumidifierEntityDescription( key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), + current_humidity=DPCode.HUMIDITY_CURRENT, humidity=DPCode.HUMIDITY_SET, device_class=HumidifierDeviceClass.HUMIDIFIER, ), @@ -79,6 +82,7 @@ async def async_setup_entry( class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): """Tuya (de)humidifier Device.""" + _current_humidity: IntegerTypeData | None = None _set_humidity: IntegerTypeData | None = None _switch_dpcode: DPCode | None = None entity_description: TuyaHumidifierEntityDescription @@ -89,7 +93,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): device_manager: TuyaDeviceManager, description: TuyaHumidifierEntityDescription, ) -> None: - """Init Tuya (de)humidier.""" + """Init Tuya (de)humidifier.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" @@ -107,6 +111,13 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): self._attr_min_humidity = int(int_type.min_scaled) self._attr_max_humidity = int(int_type.max_scaled) + # Determine current humidity DPCode + if int_type := self.find_dpcode( + description.current_humidity, + dptype=DPType.INTEGER, + ): + self._current_humidity = int_type + # Determine mode support and provided modes if enum_type := self.find_dpcode( DPCode.MODE, dptype=DPType.ENUM, prefer_function=True @@ -138,6 +149,19 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): return round(self._set_humidity.scale_value(humidity)) + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + if self._current_humidity is None: + return None + + if ( + current_humidity := self.device.status.get(self._current_humidity.dpcode) + ) is None: + return None + + return round(self._current_humidity.scale_value(current_humidity)) + def turn_on(self, **kwargs): """Turn the device on.""" self._send_command([{"code": self._switch_dpcode, "value": True}]) From 605c4db1429a3a29ccabf350a316ab36166cb6d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 10:29:04 +0100 Subject: [PATCH 400/857] Relocate async_get_announce_addresses from zeroconf to network (#94816) --- homeassistant/components/network/__init__.py | 26 ++++ homeassistant/components/zeroconf/__init__.py | 44 +------ tests/components/local_ip/test_init.py | 3 +- tests/components/network/test_init.py | 117 ++++++++++++++++++ tests/components/zeroconf/test_init.py | 30 +---- 5 files changed, 147 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 30ff2280408..32bb9a574cd 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -119,6 +119,32 @@ async def async_get_ipv4_broadcast_addresses(hass: HomeAssistant) -> set[IPv4Add return broadcast_addresses +async def async_get_announce_addresses(hass: HomeAssistant) -> list[str]: + """Return a list of IP addresses to announce/use via zeroconf/ssdp/etc. + + The default ip address is always returned first if available. + """ + adapters = await async_get_adapters(hass) + addresses: list[str] = [] + default_ip: str | None = None + for adapter in adapters: + if not adapter["enabled"]: + continue + for ips in adapter["ipv4"]: + addresses.append(str(IPv4Address(ips["address"]))) + for ips in adapter["ipv6"]: + addresses.append(str(IPv6Address(ips["address"]))) + + # Puts the default IPv4 address first in the list to preserve compatibility, + # because some mDNS implementations ignores anything but the first announced + # address. + if default_ip := await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP): + if default_ip in addresses: + addresses.remove(default_ip) + return [default_ip] + list(addresses) + return list(addresses) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" # Avoid circular issue: http->network->websocket_api->http diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 927f0b6db3a..f77909b1bdd 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -7,10 +7,9 @@ from contextlib import suppress from dataclasses import dataclass from fnmatch import translate from functools import lru_cache -from ipaddress import IPv4Address, IPv6Address, ip_address +from ipaddress import IPv4Address, IPv6Address import logging import re -import socket import sys from typing import Any, Final, cast @@ -25,8 +24,6 @@ from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import network -from homeassistant.components.network import MDNS_TARGET_IP, async_get_source_ip -from homeassistant.components.network.models import Adapter from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo @@ -243,32 +240,6 @@ def _build_homekit_model_lookups( return homekit_model_lookup, homekit_model_matchers -def _get_announced_addresses( - adapters: list[Adapter], - first_ip: bytes | None = None, -) -> list[bytes]: - """Return a list of IP addresses to announce via zeroconf. - - If first_ip is not None, it will be the first address in the list. - """ - addresses = { - addr.packed - for addr in [ - ip_address(ip["address"]) - for adapter in adapters - if adapter["enabled"] - for ip in cast(list, adapter["ipv6"]) + cast(list, adapter["ipv4"]) - ] - if not (addr.is_unspecified or addr.is_loopback) - } - if first_ip: - address_list = [first_ip] - address_list.extend(addresses - set({first_ip})) - else: - address_list = list(addresses) - return address_list - - def _filter_disallowed_characters(name: str) -> str: """Filter disallowed characters from a string. @@ -307,24 +278,13 @@ async def _async_register_hass_zc_service( # Set old base URL based on external or internal params["base_url"] = params["external_url"] or params["internal_url"] - adapters = await network.async_get_adapters(hass) - - # Puts the default IPv4 address first in the list to preserve compatibility, - # because some mDNS implementations ignores anything but the first announced - # address. - host_ip = await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP) - host_ip_pton = None - if host_ip: - host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip) - address_list = _get_announced_addresses(adapters, host_ip_pton) - _suppress_invalid_properties(params) info = AsyncServiceInfo( ZEROCONF_TYPE, name=f"{valid_location_name}.{ZEROCONF_TYPE}", server=f"{uuid}.local.", - addresses=address_list, + parsed_addresses=await network.async_get_announce_addresses(hass), port=hass.http.server_port, properties=params, ) diff --git a/tests/components/local_ip/test_init.py b/tests/components/local_ip/test_init.py index 21becc39a94..5c9e9b4f551 100644 --- a/tests/components/local_ip/test_init.py +++ b/tests/components/local_ip/test_init.py @@ -3,8 +3,7 @@ from __future__ import annotations from homeassistant import config_entries from homeassistant.components.local_ip import DOMAIN -from homeassistant.components.network import async_get_source_ip -from homeassistant.components.zeroconf import MDNS_TARGET_IP +from homeassistant.components.network import MDNS_TARGET_IP, async_get_source_ip from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index a54e649115b..880caecc138 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -712,3 +712,120 @@ async def test_async_get_source_ip_no_ip_loopback( await hass.async_block_till_done() assert await network.async_get_source_ip(hass) == "127.0.0.1" + + +_ADAPTERS_WITH_MANUAL_CONFIG = [ + { + "auto": True, + "index": 1, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 1, + }, + { + "address": "fe80::1234:5678:9abc:def0", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 1, + }, + ], + "name": "eth0", + }, + { + "auto": True, + "index": 2, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": True, + "index": 3, + "default": False, + "enabled": True, + "ipv4": [{"address": "172.16.1.5", "network_prefix": 23}], + "ipv6": [ + { + "address": "fe80::dead:beef:dead:beef", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 3, + } + ], + "name": "eth2", + }, + { + "auto": False, + "index": 4, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, +] + + +async def test_async_get_announce_addresses(hass: HomeAssistant) -> None: + """Test addresses for mDNS/etc announcement.""" + first_ip = "172.16.1.5" + with patch( + "homeassistant.components.network.async_get_source_ip", + return_value=first_ip, + ), patch( + "homeassistant.components.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + actual = await network.async_get_announce_addresses(hass) + assert actual[0] == first_ip and actual == [ + first_ip, + "2001:db8::", + "fe80::1234:5678:9abc:def0", + "192.168.1.5", + "fe80::dead:beef:dead:beef", + ] + + first_ip = "192.168.1.5" + with patch( + "homeassistant.components.network.async_get_source_ip", + return_value=first_ip, + ), patch( + "homeassistant.components.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + actual = await network.async_get_announce_addresses(hass) + + assert actual[0] == first_ip and actual == [ + first_ip, + "2001:db8::", + "fe80::1234:5678:9abc:def0", + "172.16.1.5", + "fe80::dead:beef:dead:beef", + ] + + +async def test_async_get_announce_addresses_no_source_ip(hass: HomeAssistant) -> None: + """Test addresses for mDNS/etc announcement without source ip.""" + with patch( + "homeassistant.components.network.async_get_source_ip", + return_value=None, + ), patch( + "homeassistant.components.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + actual = await network.async_get_announce_addresses(hass) + assert actual == [ + "2001:db8::", + "fe80::1234:5678:9abc:def0", + "192.168.1.5", + "172.16.1.5", + "fe80::dead:beef:dead:beef", + ] diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index bd39c00df98..5740abef789 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,5 +1,4 @@ """Test Zeroconf component setup process.""" -from ipaddress import ip_address from typing import Any from unittest.mock import call, patch @@ -13,11 +12,7 @@ from zeroconf import ( from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import zeroconf -from homeassistant.components.zeroconf import ( - CONF_DEFAULT_INTERFACE, - CONF_IPV6, - _get_announced_addresses, -) +from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6 from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, @@ -1202,29 +1197,6 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( ) -async def test_get_announced_addresses( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: - """Test addresses for mDNS announcement.""" - expected = { - ip_address(ip).packed - for ip in [ - "fe80::1234:5678:9abc:def0", - "2001:db8::", - "192.168.1.5", - "fe80::dead:beef:dead:beef", - "172.16.1.5", - ] - } - first_ip = ip_address("172.16.1.5").packed - actual = _get_announced_addresses(_ADAPTERS_WITH_MANUAL_CONFIG, first_ip) - assert actual[0] == first_ip and set(actual) == expected - - first_ip = ip_address("192.168.1.5").packed - actual = _get_announced_addresses(_ADAPTERS_WITH_MANUAL_CONFIG, first_ip) - assert actual[0] == first_ip and set(actual) == expected - - _ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6 = [ { "auto": True, From 3bacd9df2fa8f99ed927b2f06ea9659f51d86c74 Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Wed, 21 Jun 2023 11:55:06 +0200 Subject: [PATCH 401/857] Add trigger for persistent_notification (#94809) Co-authored-by: J. Nick Koston --- .../persistent_notification/__init__.py | 15 ++- .../persistent_notification/trigger.py | 80 ++++++++++++++ .../persistent_notification/conftest.py | 12 +++ .../persistent_notification/test_init.py | 8 +- .../persistent_notification/test_trigger.py | 101 ++++++++++++++++++ 5 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/persistent_notification/trigger.py create mode 100644 tests/components/persistent_notification/conftest.py create mode 100644 tests/components/persistent_notification/test_trigger.py diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index fe8849c7788..960d0a5ca59 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -1,7 +1,7 @@ """Support for displaying persistent notifications.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import datetime import logging from typing import Any, Final, TypedDict @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, singleton from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -63,6 +63,17 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +@callback +def async_register_callback( + hass: HomeAssistant, + _callback: Callable[[UpdateType, dict[str, Notification]], None], +) -> CALLBACK_TYPE: + """Register a callback.""" + return async_dispatcher_connect( + hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _callback + ) + + @bind_hass def create( hass: HomeAssistant, diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py new file mode 100644 index 00000000000..12f98083bdf --- /dev/null +++ b/homeassistant/components/persistent_notification/trigger.py @@ -0,0 +1,80 @@ +"""Offer persistent_notifications triggered automation rules.""" +from __future__ import annotations + +import logging +from typing import Final + +import voluptuous as vol + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import Notification, UpdateType, async_register_callback + +_LOGGER = logging.getLogger(__name__) + + +CONF_NOTIFICATION_ID: Final = "notification_id" +CONF_UPDATE_TYPE: Final = "update_type" + +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): "persistent_notification", + vol.Optional(CONF_NOTIFICATION_ID): str, + vol.Optional(CONF_UPDATE_TYPE): vol.All( + cv.ensure_list, [vol.Coerce(UpdateType)] + ), + } +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + trigger_data: TriggerData = trigger_info["trigger_data"] + job = HassJob(action) + + persistent_notification_id = config.get(CONF_NOTIFICATION_ID) + update_types = config.get(CONF_UPDATE_TYPE) + + @callback + def persistent_notification_listener( + update_type: UpdateType, notifications: dict[str, Notification] + ) -> None: + """Listen for persistent_notification updates.""" + + for notification in notifications.values(): + if update_types and update_type not in update_types: + continue + if ( + persistent_notification_id + and notification[CONF_NOTIFICATION_ID] != persistent_notification_id + ): + continue + + hass.async_run_hass_job( + job, + { + "trigger": { + **trigger_data, # type: ignore[arg-type] # https://github.com/python/mypy/issues/9117 + "platform": "persistent_notification", + "update_type": update_type, + "notification": notification, + } + }, + ) + + _LOGGER.debug( + "Attaching persistent_notification trigger for ID: '%s', update_types: %s", + persistent_notification_id, + update_types, + ) + + return async_register_callback(hass, persistent_notification_listener) diff --git a/tests/components/persistent_notification/conftest.py b/tests/components/persistent_notification/conftest.py new file mode 100644 index 00000000000..d665c0075b3 --- /dev/null +++ b/tests/components/persistent_notification/conftest.py @@ -0,0 +1,12 @@ +"""The tests for the persistent notification component.""" + +import pytest + +import homeassistant.components.persistent_notification as pn +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_integration(hass): + """Set up persistent notification integration.""" + assert await async_setup_component(hass, pn.DOMAIN, {}) diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 4f0851dc477..71a0fcae917 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -1,5 +1,5 @@ """The tests for the persistent notification component.""" -import pytest + import homeassistant.components.persistent_notification as pn from homeassistant.components.websocket_api.const import TYPE_RESULT @@ -9,12 +9,6 @@ from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator -@pytest.fixture(autouse=True) -async def setup_integration(hass): - """Set up persistent notification integration.""" - assert await async_setup_component(hass, pn.DOMAIN, {}) - - async def test_create(hass: HomeAssistant) -> None: """Test creating notification without title or notification id.""" notifications = pn._async_get_or_create_notifications(hass) diff --git a/tests/components/persistent_notification/test_trigger.py b/tests/components/persistent_notification/test_trigger.py new file mode 100644 index 00000000000..3cf3655a3b6 --- /dev/null +++ b/tests/components/persistent_notification/test_trigger.py @@ -0,0 +1,101 @@ +"""The tests for the persistent notification component triggers.""" +from typing import Any + +import homeassistant.components.persistent_notification as pn +from homeassistant.components.persistent_notification import trigger +from homeassistant.core import Context, HomeAssistant, callback + + +async def test_automation_with_pn_trigger(hass: HomeAssistant) -> None: + """Test automation with a persistent_notification trigger.""" + + result_any = [] + result_dismissed = [] + result_id = [] + + trigger_info = {"trigger_data": {}} + + @callback + def trigger_callback_any( + run_variables: dict[str, Any], context: Context | None = None + ) -> None: + result_any.append(run_variables) + + await trigger.async_attach_trigger( + hass, + {"platform": "persistent_notification"}, + trigger_callback_any, + trigger_info, + ) + + @callback + def trigger_callback_dismissed( + run_variables: dict[str, Any], context: Context | None = None + ) -> None: + result_dismissed.append(run_variables) + + await trigger.async_attach_trigger( + hass, + {"platform": "persistent_notification", "update_type": "removed"}, + trigger_callback_dismissed, + trigger_info, + ) + + @callback + def trigger_callback_id( + run_variables: dict[str, Any], context: Context | None = None + ) -> None: + result_id.append(run_variables) + + await trigger.async_attach_trigger( + hass, + {"platform": "persistent_notification", "notification_id": "42"}, + trigger_callback_id, + trigger_info, + ) + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "test_notification", "message": "test"}, + blocking=True, + ) + + result = result_any[0].get("trigger") + assert result["platform"] == "persistent_notification" + assert result["update_type"] == pn.UpdateType.ADDED + assert result["notification"]["notification_id"] == "test_notification" + assert result["notification"]["message"] == "test" + + assert len(result_dismissed) == 0 + assert len(result_id) == 0 + + await hass.services.async_call( + pn.DOMAIN, + "dismiss", + {"notification_id": "test_notification"}, + blocking=True, + ) + + result = result_any[1].get("trigger") + assert result["platform"] == "persistent_notification" + assert result["update_type"] == pn.UpdateType.REMOVED + assert result["notification"]["notification_id"] == "test_notification" + assert result["notification"]["message"] == "test" + assert result_any[1] == result_dismissed[0] + + assert len(result_id) == 0 + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "42", "message": "Forty Two"}, + blocking=True, + ) + + result = result_any[2].get("trigger") + assert result["platform"] == "persistent_notification" + assert result["update_type"] == pn.UpdateType.ADDED + assert result["notification"]["notification_id"] == "42" + assert result["notification"]["message"] == "Forty Two" + assert result_any[2] == result_id[0] From 05039036f1465106be8ae5db91716e58a40e01e7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 21 Jun 2023 10:01:17 +0000 Subject: [PATCH 402/857] Add compatibility with sleeping Shelly gen2 devices with firmware 1.0.0 (#94864) --- homeassistant/components/shelly/__init__.py | 5 ++- .../components/shelly/config_flow.py | 7 +++- homeassistant/components/shelly/utils.py | 6 +++- tests/components/shelly/test_config_flow.py | 32 +++++++++++++++++++ tests/components/shelly/test_init.py | 18 ++++++++++- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 7cb1f697765..69959453a78 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -44,6 +44,7 @@ from .utils import ( get_coap_context, get_device_entry_gen, get_rpc_device_sleep_period, + get_rpc_device_wakeup_period, get_ws_context, ) @@ -258,7 +259,9 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo if sleep_period is None: data = {**entry.data} - data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period(device.config) + data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period( + device.config + ) or get_rpc_device_wakeup_period(device.status) hass.config_entries.async_update_entry(entry, data=data) hass.async_create_task(_async_rpc_device_setup()) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 43e9f9b8fe7..bad13fde006 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -39,6 +39,7 @@ from .utils import ( get_info_gen, get_model_name, get_rpc_device_sleep_period, + get_rpc_device_wakeup_period, get_ws_context, mac_address_from_name, ) @@ -76,9 +77,13 @@ async def validate_input( ) await rpc_device.shutdown() + sleep_period = get_rpc_device_sleep_period( + rpc_device.config + ) or get_rpc_device_wakeup_period(rpc_device.status) + return { "title": rpc_device.name, - CONF_SLEEP_PERIOD: get_rpc_device_sleep_period(rpc_device.config), + CONF_SLEEP_PERIOD: sleep_period, "model": rpc_device.shelly.get("model"), "gen": 2, } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index c929e152b3f..03df3da346b 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -255,7 +255,11 @@ def get_block_device_sleep_period(settings: dict[str, Any]) -> int: def get_rpc_device_sleep_period(config: dict[str, Any]) -> int: - """Return the device sleep period in seconds or 0 for non sleeping devices.""" + """Return the device sleep period in seconds or 0 for non sleeping devices. + + sys.sleep.wakeup_period value is deprecated and not available in Shelly + firmware 1.0.0 or later. + """ return cast(int, config["sys"].get("sleep", {}).get("wakeup_period", 0)) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 621885bef08..7a29d7b1a42 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -1117,3 +1117,35 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( await hass.async_block_till_done() assert len(mock_rpc_device.initialize.mock_calls) == 0 assert "device did not update" not in caplog.text + + +async def test_sleeping_device_gen2_with_new_firmware( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test sleeping device Gen2 with firmware 1.0.0 or later.""" + monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 666) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "gen": 2}, + ), patch("homeassistant.components.shelly.async_setup", return_value=True), patch( + "homeassistant.components.shelly.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result["data"] == { + "host": "1.1.1.1", + "model": "SNSW-002P16EU", + "sleep_period": 666, + "gen": 2, + } diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 42ec4578016..be6e319c8ac 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.setup import async_setup_component -from . import MOCK_MAC, init_integration +from . import MOCK_MAC, init_integration, mutate_rpc_device_status from tests.common import MockConfigEntry @@ -155,6 +155,22 @@ async def test_sleeping_rpc_device_online( assert entry.data["sleep_period"] == device_sleep +async def test_sleeping_rpc_device_online_new_firmware( + hass: HomeAssistant, + mock_rpc_device, + monkeypatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sleeping device Gen2 with firmware 1.0.0 or later.""" + entry = await init_integration(hass, 2, sleep_period=None) + assert "will resume when device is online" in caplog.text + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "sys", "wakeup_period", 1500) + mock_rpc_device.mock_update() + assert "online, resuming setup" in caplog.text + assert entry.data["sleep_period"] == 1500 + + @pytest.mark.parametrize( ("gen", "entity_id"), [ From 732ce34a6675ff8e7cc442b25ed76aa78b9c0c35 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 12:10:28 +0200 Subject: [PATCH 403/857] Remove assert_lists_same test helper (#94904) --- .../device_action/tests/test_device_action.py | 6 +++--- .../device_condition/tests/test_device_condition.py | 4 ++-- .../device_trigger/tests/test_device_trigger.py | 4 ++-- tests/common.py | 13 ------------- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/script/scaffold/templates/device_action/tests/test_device_action.py b/script/scaffold/templates/device_action/tests/test_device_action.py index 7807a1389f5..a6e8f99854a 100644 --- a/script/scaffold/templates/device_action/tests/test_device_action.py +++ b/script/scaffold/templates/device_action/tests/test_device_action.py @@ -1,5 +1,6 @@ """The tests for NEW_NAME device actions.""" import pytest +from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType @@ -11,7 +12,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -44,7 +44,7 @@ async def test_get_actions( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) @pytest.mark.parametrize( @@ -91,7 +91,7 @@ async def test_get_actions_hidden_auxiliary( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert_lists_same(actions, expected_actions) + assert actions == unordered(expected_actions) async def test_action(hass: HomeAssistant) -> None: diff --git a/script/scaffold/templates/device_condition/tests/test_device_condition.py b/script/scaffold/templates/device_condition/tests/test_device_condition.py index 61ee7459bbf..1f6e0ffde3c 100644 --- a/script/scaffold/templates/device_condition/tests/test_device_condition.py +++ b/script/scaffold/templates/device_condition/tests/test_device_condition.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType @@ -13,7 +14,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -59,7 +59,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert_lists_same(conditions, expected_conditions) + assert conditions == unordered(expected_conditions) async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: diff --git a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py index 4b151e60f0d..177c095b601 100644 --- a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py +++ b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py @@ -1,5 +1,6 @@ """The tests for NEW_NAME device triggers.""" import pytest +from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType @@ -11,7 +12,6 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, ) @@ -57,7 +57,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert_lists_same(triggers, expected_triggers) + assert triggers == unordered(expected_triggers) async def test_if_fires_on_state_change( diff --git a/tests/common.py b/tests/common.py index 06beb27481b..512bdeef594 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1373,19 +1373,6 @@ def async_mock_signal(hass: HomeAssistant, signal: str) -> list[tuple[Any]]: return calls -def assert_lists_same(a: list[Any], b: list[Any]) -> None: - """Compare two lists, ignoring order. - - Check both that all items in a are in b and that all items in b are in a, - otherwise assert_lists_same(["1", "1"], ["1", "2"]) could be True. - """ - assert len(a) == len(b) - for i in a: - assert i in b - for i in b: - assert i in a - - _SENTINEL = object() From dcf8db36b7ff47e461b886285db6b9f102066541 Mon Sep 17 00:00:00 2001 From: Kyle Hildebrandt Date: Wed, 21 Jun 2023 06:16:58 -0400 Subject: [PATCH 404/857] Use yt-dlp instead of youtube-dl (#94625) * Update to use yt-dlp instead of youtube-dl * Update homeassistant/components/media_extractor/__init__.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/media_extractor/__init__.py | 8 ++++---- homeassistant/components/media_extractor/manifest.json | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index a0c542d72a5..a35650f0092 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -2,8 +2,8 @@ import logging import voluptuous as vol -from youtube_dl import YoutubeDL -from youtube_dl.utils import DownloadError, ExtractorError +from yt_dlp import YoutubeDL +from yt_dlp.utils import DownloadError, ExtractorError from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, @@ -127,7 +127,7 @@ class MediaExtractor: _LOGGER.error("Could not extract stream for the query: %s", query) raise MEQueryException() from err - return requested_stream["url"] + return requested_stream["webpage_url"] return stream_selector @@ -147,7 +147,7 @@ class MediaExtractor: if entity_id: data[ATTR_ENTITY_ID] = entity_id - self.hass.async_create_task( + self.hass.create_task( self.hass.services.async_call(MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data) ) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index e463a456e33..ccab196032f 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -5,7 +5,7 @@ "dependencies": ["media_player"], "documentation": "https://www.home-assistant.io/integrations/media_extractor", "iot_class": "calculated", - "loggers": ["youtube_dl"], + "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["youtube-dl==2021.12.17"] + "requirements": ["yt-dlp==2023.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b775f40a691..e2456d3c79c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2727,7 +2727,7 @@ yolink-api==0.2.9 youless-api==1.0.1 # homeassistant.components.media_extractor -youtube-dl==2021.12.17 +yt-dlp==2023.3.4 # homeassistant.components.zamg zamg==0.2.2 From 9876abcac9ac29932ef864ba2ff3e37e6a385665 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 11:31:14 +0100 Subject: [PATCH 405/857] Migrate esphome light platform to use _on_static_info_update (#94960) --- homeassistant/components/esphome/light.py | 85 +++++++++++------------ 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 5e16f2476bb..9ba7e474e8c 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,7 +3,13 @@ from __future__ import annotations from typing import Any, cast -from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState +from aioesphomeapi import ( + APIVersion, + EntityInfo, + LightColorCapability, + LightInfo, + LightState, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -22,7 +28,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry @@ -128,6 +134,8 @@ def _filter_color_modes( class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" + _native_supported_color_modes: list[int] + @property def _supports_color_mode(self) -> bool: """Return whether the client supports the new color mode system natively.""" @@ -141,7 +149,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - data: dict[str, Any] = {"key": self._static_info.key, "state": True} + data: dict[str, Any] = {"key": self._key, "state": True} # The list of color modes that would fit this service call color_modes = self._native_supported_color_modes try_keep_current_mode = True @@ -259,7 +267,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - data: dict[str, Any] = {"key": self._static_info.key, "state": False} + data: dict[str, Any] = {"key": self._key, "state": False} if ATTR_FLASH in kwargs: data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: @@ -287,17 +295,18 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @esphome_state_property def rgb_color(self) -> tuple[int, int, int] | None: """Return the rgb color value [int, int, int].""" + state = self._state if not self._supports_color_mode: return ( - round(self._state.red * 255), - round(self._state.green * 255), - round(self._state.blue * 255), + round(state.red * 255), + round(state.green * 255), + round(state.blue * 255), ) return ( - round(self._state.red * self._state.color_brightness * 255), - round(self._state.green * self._state.color_brightness * 255), - round(self._state.blue * self._state.color_brightness * 255), + round(state.red * state.color_brightness * 255), + round(state.green * state.color_brightness * 255), + round(state.blue * state.color_brightness * 255), ) @property @@ -312,15 +321,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @esphome_state_property def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" + state = self._state rgb = cast("tuple[int, int, int]", self.rgb_color) if not _filter_color_modes( self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww - min_ct = self._static_info.min_mireds - max_ct = self._static_info.max_mireds - color_temp = min(max(self._state.color_temperature, min_ct), max_ct) - white = self._state.white + static_info = self._static_info + min_ct = static_info.min_mireds + max_ct = static_info.max_mireds + color_temp = min(max(state.color_temperature, min_ct), max_ct) + white = state.white ww_frac = (color_temp - min_ct) / (max_ct - min_ct) cw_frac = 1 - ww_frac @@ -332,8 +343,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): ) return ( *rgb, - round(self._state.cold_white * 255), - round(self._state.warm_white * 255), + round(state.cold_white * 255), + round(state.warm_white * 255), ) @property @@ -348,26 +359,24 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """Return the current effect.""" return self._state.effect - @property - def _native_supported_color_modes(self) -> list[int]: - return self._static_info.supported_color_modes_compat(self._api_version) - - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._native_supported_color_modes = static_info.supported_color_modes_compat( + self._api_version + ) flags = LightEntityFeature.FLASH # All color modes except UNKNOWN,ON_OFF support transition modes = self._native_supported_color_modes if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= LightEntityFeature.TRANSITION - if self._static_info.effects: + if static_info.effects: flags |= LightEntityFeature.EFFECT - return flags + self._attr_supported_features = flags - @property - def supported_color_modes(self) -> set[str] | None: - """Flag supported color modes.""" supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) if ColorMode.ONOFF in supported and len(supported) > 1: supported.remove(ColorMode.ONOFF) @@ -375,19 +384,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): supported.remove(ColorMode.BRIGHTNESS) if ColorMode.WHITE in supported and len(supported) == 1: supported.remove(ColorMode.WHITE) - return supported - - @property - def effect_list(self) -> list[str]: - """Return the list of supported effects.""" - return self._static_info.effects - - @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return round(self._static_info.min_mireds) - - @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return round(self._static_info.max_mireds) + self._attr_supported_color_modes = supported + self._attr_effect_list = static_info.effects + self._attr_min_mireds = round(static_info.min_mireds) + self._attr_max_mireds = round(static_info.max_mireds) From 9d91cfa27fc10b584114378ee13989e546c03267 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 13:13:29 +0200 Subject: [PATCH 406/857] Migrate esphome number platform to use _on_static_info_update (#94958) --- homeassistant/components/esphome/number.py | 65 ++++++++++------------ 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 4e27d45bf61..786512f3a42 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -3,11 +3,16 @@ from __future__ import annotations import math -from aioesphomeapi import NumberInfo, NumberMode as EsphomeNumberMode, NumberState +from aioesphomeapi import ( + EntityInfo, + NumberInfo, + NumberMode as EsphomeNumberMode, + NumberState, +) from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum @@ -44,48 +49,34 @@ NUMBER_MODES: EsphomeEnumMapper[EsphomeNumberMode, NumberMode] = EsphomeEnumMapp class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): """A number implementation for esphome.""" - @property - def device_class(self) -> NumberDeviceClass | None: - """Return the class of this entity.""" - return try_parse_enum(NumberDeviceClass, self._static_info.device_class) - - @property - def native_min_value(self) -> float: - """Return the minimum value.""" - return self._static_info.min_value - - @property - def native_max_value(self) -> float: - """Return the maximum value.""" - return self._static_info.max_value - - @property - def native_step(self) -> float: - """Return the increment/decrement step.""" - return self._static_info.step - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - return self._static_info.unit_of_measurement - - @property - def mode(self) -> NumberMode: - """Return the mode of the entity.""" - if self._static_info.mode: - return NUMBER_MODES.from_esphome(self._static_info.mode) - return NumberMode.AUTO + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_device_class = try_parse_enum( + NumberDeviceClass, self._static_info.device_class + ) + self._attr_native_min_value = static_info.min_value + self._attr_native_max_value = static_info.max_value + self._attr_native_step = static_info.step + self._attr_native_unit_of_measurement = static_info.unit_of_measurement + if mode := static_info.mode: + self._attr_mode = NUMBER_MODES.from_esphome(mode) + else: + self._attr_mode = NumberMode.AUTO @property @esphome_state_property def native_value(self) -> float | None: """Return the state of the entity.""" - if math.isnan(self._state.state): + state = self._state + if math.isnan(state.state): return None - if self._state.missing_state: + if state.missing_state: return None - return self._state.state + return state.state async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self._client.number_command(self._static_info.key, value) + await self._client.number_command(self._key, value) From 4414f06ed2760b79912ad566dc5e74c2bb3f0954 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:49:53 +0200 Subject: [PATCH 407/857] Teach binary_sensor device trigger about entity registry ids (#94963) * Teach binary_sensor device trigger about entity registry ids * Update deconz test --- .../binary_sensor/device_trigger.py | 4 +- .../binary_sensor/test_device_trigger.py | 163 +++++++++++++++--- .../components/deconz/test_device_trigger.py | 13 +- 3 files changed, 147 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index b2fd371a260..c1eac31886e 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -195,7 +195,7 @@ TURNED_OFF = [trigger[1][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TURNED_OFF + TURNED_ON), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -254,7 +254,7 @@ async def async_get_triggers( **automation, "platform": "device", "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": DOMAIN, } for automation in templates diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index a2e9fefaa41..4b8318e2d79 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -41,6 +41,7 @@ async def test_get_triggers( enable_custom_integrations: None, ) -> None: """Test we get the expected triggers from a binary_sensor.""" + registry_entries: dict[BinarySensorDeviceClass, er.RegistryEntry] = {} platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) @@ -53,7 +54,7 @@ async def test_get_triggers( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) for device_class in BinarySensorDeviceClass: - entity_registry.async_get_or_create( + registry_entries[device_class] = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES[device_class].unique_id, @@ -66,7 +67,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger["type"], "device_id": device_entry.id, - "entity_id": platform.ENTITIES[device_class].entity_id, + "entity_id": registry_entries[device_class].id, "metadata": {"secondary": False}, } for device_class in BinarySensorDeviceClass @@ -101,7 +102,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -115,7 +116,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entry.id, "metadata": {"secondary": True}, } for trigger in ["turned_on", "turned_off"] @@ -132,9 +133,9 @@ async def test_get_triggers_no_state( entity_registry: er.EntityRegistry, ) -> None: """Test we get the expected triggers from a binary_sensor.""" + registry_entries: dict[BinarySensorDeviceClass, er.RegistryEntry] = {} platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() - entity_ids = {} config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -143,13 +144,13 @@ async def test_get_triggers_no_state( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) for device_class in BinarySensorDeviceClass: - entity_ids[device_class] = entity_registry.async_get_or_create( + registry_entries[device_class] = entity_registry.async_get_or_create( DOMAIN, "test", f"5678_{device_class}", device_id=device_entry.id, original_device_class=device_class, - ).entity_id + ) await hass.async_block_till_done() @@ -159,7 +160,7 @@ async def test_get_triggers_no_state( "domain": DOMAIN, "type": trigger["type"], "device_id": device_entry.id, - "entity_id": entity_ids[device_class], + "entity_id": registry_entries[device_class].id, "metadata": {"secondary": False}, } for device_class in BinarySensorDeviceClass @@ -201,8 +202,42 @@ async def test_get_trigger_capabilities( assert capabilities == expected_capabilities +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a binary_sensor trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + async def test_if_fires_on_state_change( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for on and off triggers firing.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -210,7 +245,11 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() - sensor1 = platform.ENTITIES["battery"] + entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["battery"].unique_id, + ) assert await async_setup_component( hass, @@ -222,7 +261,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "bat_low", }, "action": { @@ -246,7 +285,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "not_bat_low", }, "action": { @@ -269,26 +308,30 @@ async def test_if_fires_on_state_change( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data["some"] == "not_bat_low device - {} - on - off - None".format( - sensor1.entity_id + assert ( + calls[0].data["some"] + == f"not_bat_low device - {entry.entity_id} - on - off - None" ) - hass.states.async_set(sensor1.entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data["some"] == "bat_low device - {} - off - on - None".format( - sensor1.entity_id + assert ( + calls[1].data["some"] == f"bat_low device - {entry.entity_id} - off - on - None" ) async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -297,7 +340,11 @@ async def test_if_fires_on_state_change_with_for( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() - sensor1 = platform.ENTITIES["battery"] + entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["battery"].unique_id, + ) assert await async_setup_component( hass, @@ -309,7 +356,7 @@ async def test_if_fires_on_state_change_with_for( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, }, @@ -333,16 +380,80 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() assert len(calls) == 1 await hass.async_block_till_done() - assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( - sensor1.entity_id + assert ( + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - on - off - 0:00:05" + ) + + +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for triggers firing.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["battery"].unique_id, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + } + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(entry.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "turn_off device - {} - on - off - None".format( + entry.entity_id ) diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index efe97a84d37..f5ed20975e4 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -26,7 +26,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component @@ -192,6 +192,9 @@ async def test_get_triggers_for_alarm_event( device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:00")} ) + entity_registry = er.async_get(hass) + low_bat_entity = entity_registry.async_get("binary_sensor.keypad_low_battery") + tamper_entity = entity_registry.async_get("binary_sensor.keypad_tampered") triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id @@ -201,7 +204,7 @@ async def test_get_triggers_for_alarm_event( { CONF_DEVICE_ID: device.id, CONF_DOMAIN: BINARY_SENSOR_DOMAIN, - ATTR_ENTITY_ID: "binary_sensor.keypad_low_battery", + ATTR_ENTITY_ID: low_bat_entity.id, CONF_PLATFORM: "device", CONF_TYPE: CONF_BAT_LOW, "metadata": {"secondary": True}, @@ -209,7 +212,7 @@ async def test_get_triggers_for_alarm_event( { CONF_DEVICE_ID: device.id, CONF_DOMAIN: BINARY_SENSOR_DOMAIN, - ATTR_ENTITY_ID: "binary_sensor.keypad_low_battery", + ATTR_ENTITY_ID: low_bat_entity.id, CONF_PLATFORM: "device", CONF_TYPE: CONF_NOT_BAT_LOW, "metadata": {"secondary": True}, @@ -217,7 +220,7 @@ async def test_get_triggers_for_alarm_event( { CONF_DEVICE_ID: device.id, CONF_DOMAIN: BINARY_SENSOR_DOMAIN, - ATTR_ENTITY_ID: "binary_sensor.keypad_tampered", + ATTR_ENTITY_ID: tamper_entity.id, CONF_PLATFORM: "device", CONF_TYPE: CONF_TAMPERED, "metadata": {"secondary": True}, @@ -225,7 +228,7 @@ async def test_get_triggers_for_alarm_event( { CONF_DEVICE_ID: device.id, CONF_DOMAIN: BINARY_SENSOR_DOMAIN, - ATTR_ENTITY_ID: "binary_sensor.keypad_tampered", + ATTR_ENTITY_ID: tamper_entity.id, CONF_PLATFORM: "device", CONF_TYPE: CONF_NOT_TAMPERED, "metadata": {"secondary": True}, From 49ec806046f1a8c548f6b561ffb9d10e40d376ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:50:11 +0200 Subject: [PATCH 408/857] Teach button device trigger about entity registry ids (#94965) * Teach button device trigger about entity registry ids * Update homekit_controller tests --- .../components/button/device_trigger.py | 4 +- .../components/button/test_device_trigger.py | 76 ++++++++++++++++--- .../homekit_controller/test_device_trigger.py | 27 ++++--- 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py index fbf054996c3..1b206337f33 100644 --- a/homeassistant/components/button/device_trigger.py +++ b/homeassistant/components/button/device_trigger.py @@ -26,7 +26,7 @@ TRIGGER_TYPES = {"pressed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), } ) @@ -42,7 +42,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "pressed", } for entry in er.async_entries_for_device(registry, device_id) diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py index 4e3be3eba67..32f10044206 100644 --- a/tests/components/button/test_device_trigger.py +++ b/tests/components/button/test_device_trigger.py @@ -37,7 +37,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -46,7 +46,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": "pressed", "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } ] @@ -79,7 +79,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -93,7 +93,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["pressed"] @@ -104,9 +104,13 @@ async def test_get_triggers_hidden_auxiliary( assert triggers == unordered(expected_triggers) -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" - hass.states.async_set("button.entity", "unknown") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, "unknown") assert await async_setup_component( hass, @@ -118,7 +122,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "button.entity", + "entity_id": entry.id, "type": "pressed", }, "action": { @@ -140,11 +144,59 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Test triggering device trigger with a to state - hass.states.async_set("button.entity", "2021-01-01T23:59:59+00:00") + hass.states.async_set(entry.entity_id, "2021-01-01T23:59:59+00:00") await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data[ - "some" - ] == "to - device - {} - unknown - 2021-01-01T23:59:59+00:00 - None - 0".format( - "button.entity" + assert ( + calls[0].data["some"] + == f"to - device - {entry.entity_id} - unknown - 2021-01-01T23:59:59+00:00 - None - 0" + ) + + +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, "unknown") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "pressed", + }, + "action": { + "service": "test.automation", + "data": { + "some": ( + "to - {{ trigger.platform }} " + "- {{ trigger.entity_id }} " + "- {{ trigger.from_state.state }} " + "- {{ trigger.to_state.state }} " + "- {{ trigger.for }} " + "- {{ trigger.id }}" + ) + }, + }, + } + ] + }, + ) + + # Test triggering device trigger with a to state + hass.states.async_set(entry.entity_id, "2021-01-01T23:59:59+00:00") + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"to - device - {entry.entity_id} - unknown - 2021-01-01T23:59:59+00:00 - None - 0" ) diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index 4588941bcbf..e38952785df 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -91,16 +91,17 @@ async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: await setup_test_component(hass, create_remote) entity_registry = er.async_get(hass) - entry = entity_registry.async_get("sensor.testdevice_battery") + bat_sensor = entity_registry.async_get("sensor.testdevice_battery") + identify_button = entity_registry.async_get("button.testdevice_identify") device_registry = dr.async_get(hass) - device = device_registry.async_get(entry.device_id) + device = device_registry.async_get(bat_sensor.device_id) expected = [ { "device_id": device.id, "domain": "sensor", - "entity_id": "sensor.testdevice_battery", + "entity_id": bat_sensor.entity_id, "platform": "device", "type": "battery_level", "metadata": {"secondary": True}, @@ -108,7 +109,7 @@ async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: { "device_id": device.id, "domain": "button", - "entity_id": "button.testdevice_identify", + "entity_id": identify_button.id, "platform": "device", "type": "pressed", "metadata": {"secondary": True}, @@ -139,16 +140,17 @@ async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: await setup_test_component(hass, create_button) entity_registry = er.async_get(hass) - entry = entity_registry.async_get("sensor.testdevice_battery") + bat_sensor = entity_registry.async_get("sensor.testdevice_battery") + identify_button = entity_registry.async_get("button.testdevice_identify") device_registry = dr.async_get(hass) - device = device_registry.async_get(entry.device_id) + device = device_registry.async_get(bat_sensor.device_id) expected = [ { "device_id": device.id, "domain": "sensor", - "entity_id": "sensor.testdevice_battery", + "entity_id": bat_sensor.entity_id, "platform": "device", "type": "battery_level", "metadata": {"secondary": True}, @@ -156,7 +158,7 @@ async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: { "device_id": device.id, "domain": "button", - "entity_id": "button.testdevice_identify", + "entity_id": identify_button.id, "platform": "device", "type": "pressed", "metadata": {"secondary": True}, @@ -186,16 +188,17 @@ async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: await setup_test_component(hass, create_doorbell) entity_registry = er.async_get(hass) - entry = entity_registry.async_get("sensor.testdevice_battery") + bat_sensor = entity_registry.async_get("sensor.testdevice_battery") + identify_button = entity_registry.async_get("button.testdevice_identify") device_registry = dr.async_get(hass) - device = device_registry.async_get(entry.device_id) + device = device_registry.async_get(bat_sensor.device_id) expected = [ { "device_id": device.id, "domain": "sensor", - "entity_id": "sensor.testdevice_battery", + "entity_id": bat_sensor.entity_id, "platform": "device", "type": "battery_level", "metadata": {"secondary": True}, @@ -203,7 +206,7 @@ async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: { "device_id": device.id, "domain": "button", - "entity_id": "button.testdevice_identify", + "entity_id": identify_button.id, "platform": "device", "type": "pressed", "metadata": {"secondary": True}, From f3defff429b731c0b90a60229671e4ed855fcba3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:50:21 +0200 Subject: [PATCH 409/857] Teach climate device trigger about entity registry ids (#94969) --- .../components/climate/device_trigger.py | 6 +- .../components/climate/test_device_trigger.py | 110 ++++++++++++++---- 2 files changed, 89 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 005e744b53f..470c45aee70 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -34,7 +34,7 @@ TRIGGER_TYPES = { HVAC_MODE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "hvac_mode_changed", vol.Required(state_trigger.CONF_TO): vol.In(const.HVAC_MODES), } @@ -43,7 +43,7 @@ HVAC_MODE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( CURRENT_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( ["current_temperature_changed", "current_humidity_changed"] ), @@ -77,7 +77,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } triggers.append( diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index 6cc304c4955..c600e4004e8 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -52,13 +52,12 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) - entity_id = f"{DOMAIN}.test_5678" hass.states.async_set( - entity_id, - HVACMode.COOL, + entity_entry.entity_id, + const.HVAC_MODE_COOL, { const.ATTR_HVAC_ACTION: HVACAction.IDLE, const.ATTR_CURRENT_HUMIDITY: 23, @@ -71,7 +70,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": entity_id, + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in [ @@ -109,7 +108,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -117,9 +116,8 @@ async def test_get_triggers_hidden_auxiliary( entity_category=entity_category, hidden_by=hidden_by, ) - entity_id = f"{DOMAIN}.test_5678" hass.states.async_set( - entity_id, + entity_entry.entity_id, HVACMode.COOL, { const.ATTR_HVAC_ACTION: HVACAction.IDLE, @@ -133,7 +131,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in [ @@ -148,10 +146,14 @@ async def test_get_triggers_hidden_auxiliary( assert triggers == unordered(expected_triggers) -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + hass.states.async_set( - "climate.entity", + entry.entity_id, HVACMode.COOL, { const.ATTR_HVAC_ACTION: HVACAction.IDLE, @@ -170,7 +172,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "climate.entity", + "entity_id": entry.id, "type": "hvac_mode_changed", "to": HVACMode.AUTO, }, @@ -184,7 +186,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "climate.entity", + "entity_id": entry.id, "type": "current_temperature_changed", "above": 20, }, @@ -198,7 +200,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "climate.entity", + "entity_id": entry.id, "type": "current_humidity_changed", "below": 10, }, @@ -213,7 +215,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: # Fake that the HVAC mode is changing hass.states.async_set( - "climate.entity", + entry.entity_id, HVACMode.AUTO, { const.ATTR_HVAC_ACTION: HVACAction.COOLING, @@ -227,7 +229,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: # Fake that the temperature is changing hass.states.async_set( - "climate.entity", + entry.entity_id, HVACMode.AUTO, { const.ATTR_HVAC_ACTION: HVACAction.COOLING, @@ -241,7 +243,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: # Fake that the humidity is changing hass.states.async_set( - "climate.entity", + entry.entity_id, HVACMode.AUTO, { const.ATTR_HVAC_ACTION: HVACAction.COOLING, @@ -254,6 +256,60 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: assert calls[2].data["some"] == "current_humidity_changed" +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set( + entry.entity_id, + HVACMode.COOL, + { + const.ATTR_HVAC_ACTION: HVACAction.IDLE, + const.ATTR_CURRENT_HUMIDITY: 23, + const.ATTR_CURRENT_TEMPERATURE: 18, + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "hvac_mode_changed", + "to": HVACMode.AUTO, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "hvac_mode_changed"}, + }, + }, + ] + }, + ) + + # Fake that the HVAC mode is changing + hass.states.async_set( + entry.entity_id, + HVACMode.AUTO, + { + const.ATTR_HVAC_ACTION: HVACAction.COOLING, + const.ATTR_CURRENT_HUMIDITY: 23, + const.ATTR_CURRENT_TEMPERATURE: 18, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "hvac_mode_changed" + + async def test_get_trigger_capabilities_hvac_mode(hass: HomeAssistant) -> None: """Test we get the expected capabilities from a climate trigger.""" capabilities = await device_trigger.async_get_trigger_capabilities( @@ -262,7 +318,7 @@ async def test_get_trigger_capabilities_hvac_mode(hass: HomeAssistant) -> None: "platform": "device", "domain": "climate", "type": "hvac_mode_changed", - "entity_id": "climate.upstairs", + "entity_id": "01234567890123456789012345678901", "to": "heat", }, ) @@ -290,17 +346,23 @@ async def test_get_trigger_capabilities_hvac_mode(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "type", ["current_temperature_changed", "current_humidity_changed"] + ("type", "suffix"), + [ + ("current_temperature_changed", UnitOfTemperature.CELSIUS), + ("current_humidity_changed", "%"), + ], ) -async def test_get_trigger_capabilities_temp_humid(hass: HomeAssistant, type) -> None: +async def test_get_trigger_capabilities_temp_humid( + hass: HomeAssistant, type, suffix +) -> None: """Test we get the expected capabilities from a climate trigger.""" capabilities = await device_trigger.async_get_trigger_capabilities( hass, { "platform": "device", "domain": "climate", - "type": "current_temperature_changed", - "entity_id": "climate.upstairs", + "type": type, + "entity_id": "01234567890123456789012345678901", "above": "23", }, ) @@ -311,13 +373,13 @@ async def test_get_trigger_capabilities_temp_humid(hass: HomeAssistant, type) -> capabilities["extra_fields"], custom_serializer=cv.custom_serializer ) == [ { - "description": {"suffix": UnitOfTemperature.CELSIUS}, + "description": {"suffix": suffix}, "name": "above", "optional": True, "type": "float", }, { - "description": {"suffix": UnitOfTemperature.CELSIUS}, + "description": {"suffix": suffix}, "name": "below", "optional": True, "type": "float", From 49c7d2ff899dd7da832d20bf572e1f0f470bdb46 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:50:40 +0200 Subject: [PATCH 410/857] Teach cover device trigger about entity registry ids (#94971) --- .../components/cover/device_trigger.py | 6 +- tests/components/cover/test_device_trigger.py | 194 ++++++++++++++---- 2 files changed, 154 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index aad225c8039..2fb456d726d 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -43,7 +43,7 @@ STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} POSITION_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(POSITION_TRIGGER_TYPES), vol.Optional(CONF_ABOVE): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) @@ -58,7 +58,7 @@ POSITION_TRIGGER_SCHEMA = vol.All( STATE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(STATE_TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -87,7 +87,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if supports_open_close: diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index aede85fa63c..fc82bbd1499 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -88,7 +88,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -97,7 +97,7 @@ async def test_get_triggers( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", + entity_entry.entity_id, "attributes", {"supported_features": features_state}, ) @@ -110,7 +110,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in expected_trigger_types @@ -144,7 +144,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -159,7 +159,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["opened", "closed", "opening", "closing"] @@ -208,6 +208,45 @@ async def test_get_trigger_capabilities( } +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test we get the expected capabilities from a cover trigger.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[0] + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 4 + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + + async def test_get_trigger_capabilities_set_pos( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -338,9 +377,13 @@ async def test_get_trigger_capabilities_set_tilt_pos( } -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for state triggers firing.""" - hass.states.async_set("cover.entity", STATE_CLOSED) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_CLOSED) assert await async_setup_component( hass, @@ -352,7 +395,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "opened", }, "action": { @@ -374,7 +417,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "closed", }, "action": { @@ -396,7 +439,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "opening", }, "action": { @@ -418,7 +461,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "closing", }, "action": { @@ -440,42 +483,49 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Fake that the entity is opened. - hass.states.async_set("cover.entity", STATE_OPEN) + hass.states.async_set(entry.entity_id, STATE_OPEN) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data[ - "some" - ] == "opened - device - {} - closed - open - None".format("cover.entity") + assert ( + calls[0].data["some"] + == f"opened - device - {entry.entity_id} - closed - open - None" + ) # Fake that the entity is closed. - hass.states.async_set("cover.entity", STATE_CLOSED) + hass.states.async_set(entry.entity_id, STATE_CLOSED) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data[ - "some" - ] == "closed - device - {} - open - closed - None".format("cover.entity") + assert ( + calls[1].data["some"] + == f"closed - device - {entry.entity_id} - open - closed - None" + ) # Fake that the entity is opening. - hass.states.async_set("cover.entity", STATE_OPENING) + hass.states.async_set(entry.entity_id, STATE_OPENING) await hass.async_block_till_done() assert len(calls) == 3 - assert calls[2].data[ - "some" - ] == "opening - device - {} - closed - opening - None".format("cover.entity") + assert ( + calls[2].data["some"] + == f"opening - device - {entry.entity_id} - closed - opening - None" + ) # Fake that the entity is closing. - hass.states.async_set("cover.entity", STATE_CLOSING) + hass.states.async_set(entry.entity_id, STATE_CLOSING) await hass.async_block_till_done() assert len(calls) == 4 - assert calls[3].data[ - "some" - ] == "closing - device - {} - opening - closing - None".format("cover.entity") + assert ( + calls[3].data["some"] + == f"closing - device - {entry.entity_id} - opening - closing - None" + ) -async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> None: - """Test for triggers firing with delay.""" - entity_id = "cover.entity" - hass.states.async_set(entity_id, STATE_CLOSED) +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for state triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_CLOSED) assert await async_setup_component( hass, @@ -487,7 +537,56 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.entity_id, + "type": "opened", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "opened " + "- {{ trigger.platform }} " + "- {{ trigger.entity_id }} " + "- {{ trigger.from_state.state }} " + "- {{ trigger.to_state.state }} " + "- {{ trigger.for }}" + ) + }, + }, + }, + ] + }, + ) + + # Fake that the entity is opened. + hass.states.async_set(entry.entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"opened - device - {entry.entity_id} - closed - open - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_CLOSED) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "opened", "for": {"seconds": 5}, }, @@ -511,10 +610,9 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> }, ) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_CLOSED assert len(calls) == 0 - hass.states.async_set(entity_id, STATE_OPEN) + hass.states.async_set(entry.entity_id, STATE_OPEN) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -523,12 +621,15 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> await hass.async_block_till_done() assert ( calls[0].data["some"] - == f"turn_off device - {entity_id} - closed - open - 0:00:05" + == f"turn_off device - {entry.entity_id} - closed - open - 0:00:05" ) async def test_if_fires_on_position( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for position triggers.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -537,6 +638,8 @@ async def test_if_fires_on_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + entry = entity_registry.async_get(ent.entity_id) + assert await async_setup_component( hass, automation.DOMAIN, @@ -548,7 +651,7 @@ async def test_if_fires_on_position( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "position", "above": 45, } @@ -573,7 +676,7 @@ async def test_if_fires_on_position( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "position", "below": 90, } @@ -598,7 +701,7 @@ async def test_if_fires_on_position( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "position", "above": 45, "below": 90, @@ -669,7 +772,10 @@ async def test_if_fires_on_position( async def test_if_fires_on_tilt_position( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for tilt position triggers.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -678,6 +784,8 @@ async def test_if_fires_on_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + entry = entity_registry.async_get(ent.entity_id) + assert await async_setup_component( hass, automation.DOMAIN, @@ -689,7 +797,7 @@ async def test_if_fires_on_tilt_position( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "tilt_position", "above": 45, } @@ -714,7 +822,7 @@ async def test_if_fires_on_tilt_position( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "tilt_position", "below": 90, } @@ -739,7 +847,7 @@ async def test_if_fires_on_tilt_position( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "tilt_position", "above": 45, "below": 90, From 20be441c9f2f324dfd3f893308a71b7c0b7228d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:51:00 +0200 Subject: [PATCH 411/857] Teach device_tracker device trigger about entity registry ids (#94972) --- .../device_tracker/device_trigger.py | 6 +- .../device_tracker/test_device_trigger.py | 136 +++++++++++++++--- 2 files changed, 123 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 150b5872275..a96f9affb1d 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -27,7 +27,7 @@ TRIGGER_TYPES: Final[set[str]] = {"enters", "leaves"} TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN_ZONE), } @@ -51,7 +51,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "enters", } ) @@ -60,7 +60,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "leaves", } ) diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index b48ff93bb4b..75209ec607b 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -73,7 +73,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -82,7 +82,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["leaves", "enters"] @@ -116,7 +116,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -130,7 +130,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["leaves", "enters"] @@ -141,10 +141,14 @@ async def test_get_triggers_hidden_auxiliary( assert triggers == unordered(expected_triggers) -async def test_if_fires_on_zone_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for enter and leave triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + hass.states.async_set( - "device_tracker.entity", + entry.entity_id, "state", {"latitude": AWAY_LATITUDE, "longitude": AWAY_LONGITUDE}, ) @@ -159,7 +163,7 @@ async def test_if_fires_on_zone_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "device_tracker.entity", + "entity_id": entry.id, "type": "enters", "zone": "zone.test", }, @@ -183,7 +187,7 @@ async def test_if_fires_on_zone_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "device_tracker.entity", + "entity_id": entry.id, "type": "leaves", "zone": "zone.test", }, @@ -208,26 +212,87 @@ async def test_if_fires_on_zone_change(hass: HomeAssistant, calls) -> None: # Fake that the entity is entering. hass.states.async_set( - "device_tracker.entity", + entry.entity_id, "state", {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE}, ) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data["some"] == "enter - device - {} - -117.235 - -117.238".format( - "device_tracker.entity" + assert ( + calls[0].data["some"] + == f"enter - device - {entry.entity_id} - -117.235 - -117.238" ) # Fake that the entity is leaving. hass.states.async_set( - "device_tracker.entity", + entry.entity_id, "state", {"latitude": AWAY_LATITUDE, "longitude": AWAY_LONGITUDE}, ) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data["some"] == "leave - device - {} - -117.238 - -117.235".format( - "device_tracker.entity" + assert ( + calls[1].data["some"] + == f"leave - device - {entry.entity_id} - -117.238 - -117.235" + ) + + +async def test_if_fires_on_zone_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for enter and leave triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set( + entry.entity_id, + "state", + {"latitude": AWAY_LATITUDE, "longitude": AWAY_LONGITUDE}, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "enters", + "zone": "zone.test", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "enter " + "- {{ trigger.platform }} " + "- {{ trigger.entity_id }} " + "- {{ " + " trigger.from_state.attributes.longitude|round(3) " + " }} " + "- {{ trigger.to_state.attributes.longitude|round(3) }}" + ) + }, + }, + }, + ] + }, + ) + + # Fake that the entity is entering. + hass.states.async_set( + entry.entity_id, + "state", + {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE}, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"enter - device - {entry.entity_id} - -117.235 - -117.238" ) @@ -243,7 +308,7 @@ async def test_get_trigger_capabilities( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) capabilities = await device_trigger.async_get_trigger_capabilities( @@ -253,7 +318,46 @@ async def test_get_trigger_capabilities( "domain": DOMAIN, "type": "enters", "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "zone", + "required": True, + "type": "select", + "options": [("zone.test", "test"), ("zone.home", "test home")], + } + ] + + +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a device_tracker trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "enters", + "device_id": device_entry.id, + "entity_id": entity_entry.entity_id, }, ) assert capabilities and "extra_fields" in capabilities From e404441e8cbc4bb4080685cb729c24f8c0bfd6d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:51:09 +0200 Subject: [PATCH 412/857] Teach lock device trigger about entity registry ids (#94975) --- .../components/lock/device_trigger.py | 4 +- tests/components/lock/test_device_trigger.py | 152 ++++++++++++++---- 2 files changed, 121 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index ec996d4f0b2..c6b86eaca4a 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -29,7 +29,7 @@ TRIGGER_TYPES = {"locked", "unlocked", "locking", "unlocking", "jammed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -54,7 +54,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, } for trigger in TRIGGER_TYPES diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 54d2afcacb6..107e0924440 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -53,7 +53,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -62,7 +62,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["locked", "unlocked", "unlocking", "locking", "jammed"] @@ -96,7 +96,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -110,7 +110,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["locked", "unlocked", "unlocking", "locking", "jammed"] @@ -152,9 +152,45 @@ async def test_get_trigger_capabilities( } -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a lock.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 5 + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + + +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" - hass.states.async_set("lock.entity", STATE_UNLOCKED) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_UNLOCKED) assert await async_setup_component( hass, @@ -166,7 +202,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "locked", }, "action": { @@ -185,7 +221,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "unlocked", }, "action": { @@ -204,26 +240,31 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Fake that the entity is turning on. - hass.states.async_set("lock.entity", STATE_LOCKED) + hass.states.async_set(entry.entity_id, STATE_LOCKED) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data[ - "some" - ] == "locked - device - {} - unlocked - locked - None".format("lock.entity") + assert ( + calls[0].data["some"] + == f"locked - device - {entry.entity_id} - unlocked - locked - None" + ) # Fake that the entity is turning off. - hass.states.async_set("lock.entity", STATE_UNLOCKED) + hass.states.async_set(entry.entity_id, STATE_UNLOCKED) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data[ - "some" - ] == "unlocked - device - {} - locked - unlocked - None".format("lock.entity") + assert ( + calls[1].data["some"] + == f"unlocked - device - {entry.entity_id} - locked - unlocked - None" + ) -async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> None: - """Test for triggers firing with delay.""" - entity_id = f"{DOMAIN}.entity" - hass.states.async_set(entity_id, STATE_UNLOCKED) +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_UNLOCKED) assert await async_setup_component( hass, @@ -235,7 +276,53 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.entity_id, + "type": "locked", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "locked - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}} - {{ trigger.for }}" + ) + }, + }, + }, + ] + }, + ) + + # Fake that the entity is turning on. + hass.states.async_set(entry.entity_id, STATE_LOCKED) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"locked - device - {entry.entity_id} - unlocked - locked - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_UNLOCKED) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "locked", "for": {"seconds": 5}, }, @@ -260,7 +347,7 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.id, "type": "unlocking", "for": {"seconds": 5}, }, @@ -285,7 +372,7 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.id, "type": "jammed", "for": {"seconds": 5}, }, @@ -310,7 +397,7 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.id, "type": "locking", "for": {"seconds": 5}, }, @@ -334,10 +421,9 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> }, ) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNLOCKED assert len(calls) == 0 - hass.states.async_set(entity_id, STATE_LOCKED) + hass.states.async_set(entry.entity_id, STATE_LOCKED) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -346,10 +432,10 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> await hass.async_block_till_done() assert ( calls[0].data["some"] - == f"turn_off device - {entity_id} - unlocked - locked - 0:00:05" + == f"turn_off device - {entry.entity_id} - unlocked - locked - 0:00:05" ) - hass.states.async_set(entity_id, STATE_UNLOCKING) + hass.states.async_set(entry.entity_id, STATE_UNLOCKING) await hass.async_block_till_done() assert len(calls) == 1 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=16)) @@ -358,10 +444,10 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> await hass.async_block_till_done() assert ( calls[1].data["some"] - == f"turn_on device - {entity_id} - locked - unlocking - 0:00:05" + == f"turn_on device - {entry.entity_id} - locked - unlocking - 0:00:05" ) - hass.states.async_set(entity_id, STATE_JAMMED) + hass.states.async_set(entry.entity_id, STATE_JAMMED) await hass.async_block_till_done() assert len(calls) == 2 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=21)) @@ -370,10 +456,10 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> await hass.async_block_till_done() assert ( calls[2].data["some"] - == f"turn_off device - {entity_id} - unlocking - jammed - 0:00:05" + == f"turn_off device - {entry.entity_id} - unlocking - jammed - 0:00:05" ) - hass.states.async_set(entity_id, STATE_LOCKING) + hass.states.async_set(entry.entity_id, STATE_LOCKING) await hass.async_block_till_done() assert len(calls) == 3 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) @@ -382,5 +468,5 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> await hass.async_block_till_done() assert ( calls[3].data["some"] - == f"turn_on device - {entity_id} - jammed - locking - 0:00:05" + == f"turn_on device - {entry.entity_id} - jammed - locking - 0:00:05" ) From 7f0be78ebbf058076fe3366f3de74097297f4df9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:51:24 +0200 Subject: [PATCH 413/857] Teach netatmo device trigger about entity registry ids (#94980) --- .../components/netatmo/device_trigger.py | 6 +- .../components/netatmo/test_device_trigger.py | 101 ++++++++++++++++-- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index f3f45458d78..f4719badcfa 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -56,7 +56,7 @@ TRIGGER_TYPES = OUTDOOR_CAMERA_TRIGGERS + INDOOR_CAMERA_TRIGGERS + CLIMATE_TRIGG TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_SUBTYPE): str, } @@ -111,7 +111,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, CONF_SUBTYPE: subtype, } @@ -122,7 +122,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, } ) diff --git a/tests/components/netatmo/test_device_trigger.py b/tests/components/netatmo/test_device_trigger.py index 29a0b46a97c..c7cbaf4d131 100644 --- a/tests/components/netatmo/test_device_trigger.py +++ b/tests/components/netatmo/test_device_trigger.py @@ -56,7 +56,7 @@ async def test_get_triggers( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, model=device_type, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id ) expected_triggers = [] @@ -70,7 +70,7 @@ async def test_get_triggers( "type": event_type, "subtype": subtype, "device_id": device_entry.id, - "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } ) @@ -81,7 +81,7 @@ async def test_get_triggers( "domain": NETATMO_DOMAIN, "type": event_type, "device_id": device_entry.id, - "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } ) @@ -130,7 +130,7 @@ async def test_if_fires_on_event( identifiers={(NETATMO_DOMAIN, mac_address)}, model=camera_type, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id ) events = async_capture_events(hass, "netatmo_event") @@ -145,7 +145,90 @@ async def test_if_fires_on_event( "platform": "device", "domain": NETATMO_DOMAIN, "device_id": device_entry.id, - "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "entity_id": entity_entry.id, + "type": event_type, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "{{trigger.event.data.type}} - {{trigger.platform}} - {{trigger.event.data.device_id}}" + ) + }, + }, + }, + ] + }, + ) + + device = device_registry.async_get_device(set(), {connection}) + assert device is not None + + # Fake that the entity is turning on. + hass.bus.async_fire( + event_type=NETATMO_EVENT, + event_data={ + "type": event_type, + ATTR_DEVICE_ID: device.id, + }, + ) + await hass.async_block_till_done() + assert len(events) == 1 + assert len(calls) == 1 + assert calls[0].data["some"] == f"{event_type} - device - {device.id}" + + +@pytest.mark.parametrize( + ("platform", "camera_type", "event_type"), + [("camera", "Smart Outdoor Camera", trigger) for trigger in OUTDOOR_CAMERA_TRIGGERS] + + [("camera", "Smart Indoor Camera", trigger) for trigger in INDOOR_CAMERA_TRIGGERS] + + [ + ("climate", "Smart Valve", trigger) + for trigger in CLIMATE_TRIGGERS + if trigger not in SUBTYPES + ] + + [ + ("climate", "Smart Thermostat", trigger) + for trigger in CLIMATE_TRIGGERS + if trigger not in SUBTYPES + ], +) +async def test_if_fires_on_event_legacy( + hass: HomeAssistant, + calls, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + platform, + camera_type, + event_type, +) -> None: + """Test for event triggers firing.""" + mac_address = "12:34:56:AB:CD:EF" + connection = (dr.CONNECTION_NETWORK_MAC, mac_address) + config_entry = MockConfigEntry(domain=NETATMO_DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={connection}, + identifiers={(NETATMO_DOMAIN, mac_address)}, + model=camera_type, + ) + entity_entry = entity_registry.async_get_or_create( + platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id + ) + events = async_capture_events(hass, "netatmo_event") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": NETATMO_DOMAIN, + "device_id": device_entry.id, + "entity_id": entity_entry.entity_id, "type": event_type, }, "action": { @@ -212,7 +295,7 @@ async def test_if_fires_on_event_with_subtype( identifiers={(NETATMO_DOMAIN, mac_address)}, model=camera_type, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id ) events = async_capture_events(hass, "netatmo_event") @@ -227,7 +310,7 @@ async def test_if_fires_on_event_with_subtype( "platform": "device", "domain": NETATMO_DOMAIN, "device_id": device_entry.id, - "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "entity_id": entity_entry.id, "type": event_type, "subtype": sub_type, }, @@ -288,7 +371,7 @@ async def test_if_invalid_device( identifiers={(NETATMO_DOMAIN, mac_address)}, model=device_type, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id ) @@ -302,7 +385,7 @@ async def test_if_invalid_device( "platform": "device", "domain": NETATMO_DOMAIN, "device_id": device_entry.id, - "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "entity_id": entity_entry.id, "type": event_type, }, "action": { From af97857c8701953c7121329ff9e5316528f7bfb2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:51:39 +0200 Subject: [PATCH 414/857] Teach select device trigger about entity registry ids (#94981) --- .../components/device_automation/__init__.py | 16 +- .../device_automation/exceptions.py | 4 + .../components/select/device_trigger.py | 13 +- .../components/select/test_device_trigger.py | 222 ++++++++++++++++-- 4 files changed, 225 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 71acc6dfa79..80c635dc994 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -40,7 +40,7 @@ from .const import ( # noqa: F401 CONF_TURNED_OFF, CONF_TURNED_ON, ) -from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig +from .exceptions import DeviceNotFound, EntityNotFound, InvalidDeviceAutomationConfig if TYPE_CHECKING: from .action import DeviceAutomationActionProtocol @@ -313,7 +313,7 @@ async def _async_get_device_automation_capabilities( try: capabilities = await getattr(platform, function_name)(hass, automation) - except InvalidDeviceAutomationConfig: + except (EntityNotFound, InvalidDeviceAutomationConfig): return {} capabilities = capabilities.copy() @@ -328,6 +328,18 @@ async def _async_get_device_automation_capabilities( return capabilities # type: ignore[no-any-return] +@callback +def async_get_entity_registry_entry_or_raise( + hass: HomeAssistant, entity_registry_id: str +) -> er.RegistryEntry: + """Get an entity registry entry from entry ID or raise.""" + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(entity_registry_id) + if entry is None: + raise EntityNotFound + return entry + + def handle_device_errors( func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]] ) -> Callable[ diff --git a/homeassistant/components/device_automation/exceptions.py b/homeassistant/components/device_automation/exceptions.py index ad92696cb94..0b2f2c01be7 100644 --- a/homeassistant/components/device_automation/exceptions.py +++ b/homeassistant/components/device_automation/exceptions.py @@ -8,3 +8,7 @@ class InvalidDeviceAutomationConfig(HomeAssistantError): class DeviceNotFound(HomeAssistantError): """When referenced device not found.""" + + +class EntityNotFound(HomeAssistantError): + """When referenced entity not found.""" diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index 8e8267cb5e0..2cd2da0e1a6 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -3,7 +3,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, + async_get_entity_registry_entry_or_raise, +) from homeassistant.components.homeassistant.triggers.state import ( CONF_FOR, CONF_FROM, @@ -31,7 +34,7 @@ TRIGGER_TYPES = {"current_option_changed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_TO): vol.Any(vol.Coerce(str)), vol.Optional(CONF_FROM): vol.Any(vol.Coerce(str)), @@ -50,7 +53,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "current_option_changed", } for entry in er.async_entries_for_device(registry, device_id) @@ -89,8 +92,10 @@ async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List trigger capabilities.""" + try: - options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] + entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) + options = get_capability(hass, entry.entity_id, ATTR_OPTIONS) or [] except HomeAssistantError: options = [] diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index 45522892c6b..8a6ccd43abe 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -45,7 +45,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -54,7 +54,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": "current_option_changed", "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } ] @@ -87,7 +87,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -101,7 +101,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["current_option_changed"] @@ -112,10 +112,14 @@ async def test_get_triggers_hidden_auxiliary( assert triggers == unordered(expected_triggers) -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + hass.states.async_set( - "select.entity", "option1", {"options": ["option1", "option2", "option3"]} + entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} ) assert await async_setup_component( @@ -128,7 +132,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "select.entity", + "entity_id": entry.id, "type": "current_option_changed", "to": "option2", }, @@ -149,7 +153,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "select.entity", + "entity_id": entry.id, "type": "current_option_changed", "from": "option2", }, @@ -170,7 +174,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "select.entity", + "entity_id": entry.id, "type": "current_option_changed", "from": "option3", "to": "option1", @@ -192,37 +196,94 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Test triggering device trigger with a to state - hass.states.async_set("select.entity", "option2") + hass.states.async_set(entry.entity_id, "option2") await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data[ - "some" - ] == "to - device - {} - option1 - option2 - None - 0".format("select.entity") + assert ( + calls[0].data["some"] + == f"to - device - {entry.entity_id} - option1 - option2 - None - 0" + ) # Test triggering device trigger with a from state - hass.states.async_set("select.entity", "option3") + hass.states.async_set(entry.entity_id, "option3") await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data[ - "some" - ] == "from - device - {} - option2 - option3 - None - 0".format("select.entity") + assert ( + calls[1].data["some"] + == f"from - device - {entry.entity_id} - option2 - option3 - None - 0" + ) # Test triggering device trigger with both a from and to state - hass.states.async_set("select.entity", "option1") + hass.states.async_set(entry.entity_id, "option1") await hass.async_block_till_done() assert len(calls) == 3 - assert calls[2].data[ - "some" - ] == "from-to - device - {} - option3 - option1 - None - 0".format("select.entity") + assert ( + calls[2].data["some"] + == f"from-to - device - {entry.entity_id} - option3 - option1 - None - 0" + ) -async def test_get_trigger_capabilities(hass: HomeAssistant) -> None: +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "current_option_changed", + "to": "option2", + }, + "action": { + "service": "test.automation", + "data": { + "some": ( + "to - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}} - {{ trigger.for }} - " + "{{ trigger.id}}" + ) + }, + }, + }, + ] + }, + ) + + # Test triggering device trigger with a to state + hass.states.async_set(entry.entity_id, "option2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"to - device - {entry.entity_id} - option1 - option2 - None - 0" + ) + + +async def test_get_trigger_capabilities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we get the expected capabilities from a select trigger.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config = { "platform": "device", "domain": DOMAIN, "type": "current_option_changed", - "entity_id": "select.test", + "entity_id": entry.id, "to": "option1", } @@ -253,7 +314,120 @@ async def test_get_trigger_capabilities(hass: HomeAssistant) -> None: ] # Mock an entity - hass.states.async_set("select.test", "option1", {"options": ["option1", "option2"]}) + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2"]} + ) + + # Test if we get the right capabilities now + capabilities = await async_get_trigger_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "type": "select", + "options": [("option1", "option1"), ("option2", "option2")], + }, + { + "name": "to", + "optional": True, + "type": "select", + "options": [("option1", "option1"), ("option2", "option2")], + }, + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + }, + ] + + +async def test_get_trigger_capabilities_unknown( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test we get the expected capabilities from a select trigger.""" + config = { + "platform": "device", + "domain": DOMAIN, + "type": "current_option_changed", + "entity_id": "12345", + "to": "option1", + } + + # Test when entity doesn't exists + capabilities = await async_get_trigger_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "type": "select", + "options": [], + }, + { + "name": "to", + "optional": True, + "type": "select", + "options": [], + }, + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + }, + ] + + +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test we get the expected capabilities from a select trigger.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + config = { + "platform": "device", + "domain": DOMAIN, + "type": "current_option_changed", + "entity_id": entry.entity_id, + "to": "option1", + } + + # Test when entity doesn't exists + capabilities = await async_get_trigger_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "type": "select", + "options": [], + }, + { + "name": "to", + "optional": True, + "type": "select", + "options": [], + }, + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + }, + ] + + # Mock an entity + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2"]} + ) # Test if we get the right capabilities now capabilities = await async_get_trigger_capabilities(hass, config) From 1cb62d776ea4bcd303e18100ba3c088d06125d29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 14:54:56 +0200 Subject: [PATCH 415/857] Migrate esphome cover platform to use _on_static_info_update (#94959) --- homeassistant/components/esphome/cover.py | 51 ++++++++++------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 9d82b285291..4fb9924613d 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState +from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState, EntityInfo from homeassistant.components.cover import ( ATTR_POSITION, @@ -13,7 +13,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum @@ -38,32 +38,27 @@ async def async_setup_entry( class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """A cover implementation for ESPHome.""" - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info flags = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - - if self._api_version < APIVersion(1, 8) or self._static_info.supports_stop: + if self._api_version < APIVersion(1, 8) or static_info.supports_stop: flags |= CoverEntityFeature.STOP - if self._static_info.supports_position: + if static_info.supports_position: flags |= CoverEntityFeature.SET_POSITION - if self._static_info.supports_tilt: + if static_info.supports_tilt: flags |= ( CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - return flags - - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(CoverDeviceClass, self._static_info.device_class) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._static_info.assumed_state + self._attr_supported_features = flags + self._attr_device_class = try_parse_enum( + CoverDeviceClass, static_info.device_class + ) + self._attr_assumed_state = static_info.assumed_state @property @esphome_state_property @@ -102,33 +97,31 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._client.cover_command(key=self._static_info.key, position=1.0) + await self._client.cover_command(key=self._key, position=1.0) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._client.cover_command(key=self._static_info.key, position=0.0) + await self._client.cover_command(key=self._key, position=0.0) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._client.cover_command(key=self._static_info.key, stop=True) + await self._client.cover_command(key=self._key, stop=True) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self._client.cover_command( - key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100 + key=self._key, position=kwargs[ATTR_POSITION] / 100 ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - await self._client.cover_command(key=self._static_info.key, tilt=1.0) + await self._client.cover_command(key=self._key, tilt=1.0) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - await self._client.cover_command(key=self._static_info.key, tilt=0.0) + await self._client.cover_command(key=self._key, tilt=0.0) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position: int = kwargs[ATTR_TILT_POSITION] - await self._client.cover_command( - key=self._static_info.key, tilt=tilt_position / 100 - ) + await self._client.cover_command(key=self._key, tilt=tilt_position / 100) From 22e1feb223a51446027a9b2dce8773680c996800 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 14:58:11 +0200 Subject: [PATCH 416/857] Teach humidifier device trigger about entity registry ids (#94974) --- .../components/humidifier/device_trigger.py | 4 +- .../humidifier/test_device_trigger.py | 153 +++++++++++++----- 2 files changed, 113 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 79074a06e18..0e0f401819b 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -46,7 +46,7 @@ CURRENT_TRIGGER_SCHEMA = vol.All( HUMIDIFIER_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "target_humidity_changed", vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(int)), vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(int)), @@ -85,7 +85,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } triggers.append( diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index db557509463..b69a59de1d2 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -56,12 +56,11 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) - entity_id = f"{DOMAIN}.test_5678" hass.states.async_set( - entity_id, + entity_entry.entity_id, STATE_ON, { const.ATTR_HUMIDITY: 23, @@ -71,22 +70,29 @@ async def test_get_triggers( ATTR_SUPPORTED_FEATURES: 1, }, ) + humidifier_trigger_types = ["current_humidity_changed", "target_humidity_changed"] + toggle_trigger_types = ["turned_on", "turned_off", "changed_states"] expected_triggers = [ { "platform": "device", "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": entity_id, + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in [ - "current_humidity_changed", - "target_humidity_changed", - "turned_off", - "turned_on", - "changed_states", - ] + for trigger in humidifier_trigger_types + ] + expected_triggers += [ + { + "platform": "device", + "domain": DOMAIN, + "type": trigger, + "device_id": device_entry.id, + "entity_id": entity_entry.entity_id, + "metadata": {"secondary": False}, + } + for trigger in toggle_trigger_types ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -117,7 +123,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -125,21 +131,29 @@ async def test_get_triggers_hidden_auxiliary( entity_category=entity_category, hidden_by=hidden_by, ) + humidifier_trigger_types = ["target_humidity_changed"] + toggle_trigger_types = ["turned_on", "turned_off", "changed_states"] expected_triggers = [ { "platform": "device", "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in [ - "target_humidity_changed", - "turned_off", - "turned_on", - "changed_states", - ] + for trigger in humidifier_trigger_types + ] + expected_triggers += [ + { + "platform": "device", + "domain": DOMAIN, + "type": trigger, + "device_id": device_entry.id, + "entity_id": entity_entry.entity_id, + "metadata": {"secondary": True}, + } + for trigger in toggle_trigger_types ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -147,10 +161,14 @@ async def test_get_triggers_hidden_auxiliary( assert triggers == unordered(expected_triggers) -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + hass.states.async_set( - "humidifier.entity", + entry.entity_id, STATE_ON, { const.ATTR_HUMIDITY: 23, @@ -170,7 +188,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "target_humidity_changed", "below": 20, }, @@ -184,7 +202,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, }, @@ -198,7 +216,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, "for": {"seconds": 5}, @@ -213,7 +231,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.entity_id, "type": "turned_on", }, "action": { @@ -237,7 +255,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.entity_id, "type": "turned_off", }, "action": { @@ -261,7 +279,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.entity_id, "type": "changed_states", }, "action": { @@ -285,13 +303,13 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Fake that the humidity is changing - hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 7}) + hass.states.async_set(entry.entity_id, STATE_ON, {const.ATTR_HUMIDITY: 7}) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == "target_humidity_changed_below" # Fake that the humidity is changing - hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 37}) + hass.states.async_set(entry.entity_id, STATE_ON, {const.ATTR_HUMIDITY: 37}) await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "target_humidity_changed_above" @@ -303,28 +321,32 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: assert calls[2].data["some"] == "target_humidity_changed_above_for" # Fake turn off - hass.states.async_set("humidifier.entity", STATE_OFF, {const.ATTR_HUMIDITY: 37}) + hass.states.async_set(entry.entity_id, STATE_OFF, {const.ATTR_HUMIDITY: 37}) await hass.async_block_till_done() assert len(calls) == 5 assert {calls[3].data["some"], calls[4].data["some"]} == { - "turn_off device - humidifier.entity - on - off - None", - "turn_on_or_off device - humidifier.entity - on - off - None", + "turn_off device - humidifier.test_5678 - on - off - None", + "turn_on_or_off device - humidifier.test_5678 - on - off - None", } # Fake turn on - hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 37}) + hass.states.async_set(entry.entity_id, STATE_ON, {const.ATTR_HUMIDITY: 37}) await hass.async_block_till_done() assert len(calls) == 7 assert {calls[5].data["some"], calls[6].data["some"]} == { - "turn_on device - humidifier.entity - off - on - None", - "turn_on_or_off device - humidifier.entity - off - on - None", + "turn_on device - humidifier.test_5678 - off - on - None", + "turn_on_or_off device - humidifier.test_5678 - off - on - None", } -async def test_invalid_config(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + hass.states.async_set( - "humidifier.entity", + entry.entity_id, STATE_ON, { const.ATTR_HUMIDITY: 23, @@ -344,7 +366,54 @@ async def test_invalid_config(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.entity_id, + "type": "target_humidity_changed", + "below": 20, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed_below"}, + }, + }, + ] + }, + ) + + # Fake that the humidity is changing + hass.states.async_set(entry.entity_id, STATE_ON, {const.ATTR_HUMIDITY: 7}) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "target_humidity_changed_below" + + +async def test_invalid_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set( + entry.entity_id, + STATE_ON, + { + const.ATTR_HUMIDITY: 23, + ATTR_MODE: "home", + const.ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_SUPPORTED_FEATURES: 1, + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "target_humidity_changed", "below": 20, "invalid": "invalid", @@ -359,7 +428,7 @@ async def test_invalid_config(hass: HomeAssistant, calls) -> None: ) # Fake that the humidity is changing - hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 7}) + hass.states.async_set(entry.entity_id, STATE_ON, {const.ATTR_HUMIDITY: 7}) await hass.async_block_till_done() # Should not trigger for invalid config assert len(calls) == 0 @@ -373,7 +442,7 @@ async def test_get_trigger_capabilities_on(hass: HomeAssistant) -> None: "platform": "device", "domain": "humidifier", "type": "turned_on", - "entity_id": "humidifier.upstairs", + "entity_id": "01234568901234568901234568901", "above": "23", }, ) @@ -393,7 +462,7 @@ async def test_get_trigger_capabilities_off(hass: HomeAssistant) -> None: "platform": "device", "domain": "humidifier", "type": "turned_off", - "entity_id": "humidifier.upstairs", + "entity_id": "01234568901234568901234568901", "above": "23", }, ) @@ -413,7 +482,7 @@ async def test_get_trigger_capabilities_humidity(hass: HomeAssistant) -> None: "platform": "device", "domain": "humidifier", "type": "target_humidity_changed", - "entity_id": "humidifier.upstairs", + "entity_id": "01234568901234568901234568901", "above": "23", }, ) From f9366e5cc778e2d2820e698c88d9c9f597742441 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 21 Jun 2023 14:58:58 +0200 Subject: [PATCH 417/857] Migrate google translate to config entries (#93803) Co-authored-by: Franck Nijhof --- .../components/google_translate/__init__.py | 21 +- .../google_translate/config_flow.py | 49 +++ .../components/google_translate/const.py | 7 +- .../components/google_translate/manifest.json | 2 + .../components/google_translate/strings.json | 15 + .../components/google_translate/tts.py | 105 ++++- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/google_translate/conftest.py | 14 + .../google_translate/test_config_flow.py | 68 ++++ tests/components/google_translate/test_tts.py | 376 +++++++++++++----- 11 files changed, 557 insertions(+), 103 deletions(-) create mode 100644 homeassistant/components/google_translate/config_flow.py create mode 100644 homeassistant/components/google_translate/strings.json create mode 100644 tests/components/google_translate/conftest.py create mode 100644 tests/components/google_translate/test_config_flow.py diff --git a/homeassistant/components/google_translate/__init__.py b/homeassistant/components/google_translate/__init__.py index f7860c57d99..ac6b07bd4b3 100644 --- a/homeassistant/components/google_translate/__init__.py +++ b/homeassistant/components/google_translate/__init__.py @@ -1 +1,20 @@ -"""The google_translate component.""" +"""The Google Translate text-to-speech integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.TTS] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Translate text-to-speech from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_translate/config_flow.py b/homeassistant/components/google_translate/config_flow.py new file mode 100644 index 00000000000..550b0f5f382 --- /dev/null +++ b/homeassistant/components/google_translate/config_flow.py @@ -0,0 +1,49 @@ +"""Config flow for Google Translate text-to-speech integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.tts import CONF_LANG +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + CONF_TLD, + DEFAULT_LANG, + DEFAULT_TLD, + DOMAIN, + SUPPORT_LANGUAGES, + SUPPORT_TLD, +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), + vol.Optional(CONF_TLD, default=DEFAULT_TLD): vol.In(SUPPORT_TLD), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Translate text-to-speech.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self._async_abort_entries_match( + { + CONF_LANG: user_input[CONF_LANG], + CONF_TLD: user_input[CONF_TLD], + } + ) + return self.async_create_entry( + title="Google Translate text-to-speech", data=user_input + ) + + return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA) diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py index 78e96acc91d..0bb8663119b 100644 --- a/homeassistant/components/google_translate/const.py +++ b/homeassistant/components/google_translate/const.py @@ -1,6 +1,11 @@ -"""Constant for google_translate integration.""" +"""Constants for the Google Translate text-to-speech integration.""" from dataclasses import dataclass +CONF_TLD = "tld" +DEFAULT_LANG = "en" +DEFAULT_TLD = "com" +DOMAIN = "google_translate" + SUPPORT_LANGUAGES = [ "af", "ar", diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 504925a4667..c9b955543db 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -2,6 +2,8 @@ "domain": "google_translate", "name": "Google Translate text-to-speech", "codeowners": [], + "config_flow": true, + "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", "loggers": ["gtts"], diff --git a/homeassistant/components/google_translate/strings.json b/homeassistant/components/google_translate/strings.json new file mode 100644 index 00000000000..a83e61f01f9 --- /dev/null +++ b/homeassistant/components/google_translate/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "language": "Language", + "tld": "TLD" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index c02d262f6e5..45288e81996 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -1,33 +1,120 @@ """Support for the Google speech service.""" +from __future__ import annotations + from io import BytesIO import logging +from typing import Any from gtts import gTTS, gTTSError import voluptuous as vol -from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import ( + CONF_LANG, + PLATFORM_SCHEMA, + Provider, + TextToSpeechEntity, + TtsAudioType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import MAP_LANG_TLD, SUPPORT_LANGUAGES, SUPPORT_TLD +from .const import ( + CONF_TLD, + DEFAULT_LANG, + DEFAULT_TLD, + MAP_LANG_TLD, + SUPPORT_LANGUAGES, + SUPPORT_TLD, +) _LOGGER = logging.getLogger(__name__) -DEFAULT_LANG = "en" - SUPPORT_OPTIONS = ["tld"] -DEFAULT_TLD = "com" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), - vol.Optional("tld", default=DEFAULT_TLD): vol.In(SUPPORT_TLD), + vol.Optional(CONF_TLD, default=DEFAULT_TLD): vol.In(SUPPORT_TLD), } ) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> GoogleProvider: """Set up Google speech component.""" - return GoogleProvider(hass, config[CONF_LANG], config["tld"]) + return GoogleProvider(hass, config[CONF_LANG], config[CONF_TLD]) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Google Translate speech platform via config entry.""" + default_language = config_entry.data[CONF_LANG] + default_tld = config_entry.data[CONF_TLD] + async_add_entities([GoogleTTSEntity(config_entry, default_language, default_tld)]) + + +class GoogleTTSEntity(TextToSpeechEntity): + """The Google speech API entity.""" + + def __init__(self, config_entry: ConfigEntry, lang: str, tld: str) -> None: + """Init Google TTS service.""" + if lang in MAP_LANG_TLD: + self._lang = MAP_LANG_TLD[lang].lang + self._tld = MAP_LANG_TLD[lang].tld + else: + self._lang = lang + self._tld = tld + self._attr_name = f"Google {self._lang} {self._tld}" + self._attr_unique_id = config_entry.entry_id + + @property + def default_language(self): + """Return the default language.""" + return self._lang + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self): + """Return a list of supported options.""" + return SUPPORT_OPTIONS + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Load TTS from google.""" + tld = self._tld + if language in MAP_LANG_TLD: + tld_language = MAP_LANG_TLD[language] + tld = tld_language.tld + language = tld_language.lang + if options is not None and "tld" in options: + tld = options["tld"] + + tts = gTTS(text=message, lang=language, tld=tld) + mp3_data = BytesIO() + + try: + tts.write_to_fp(mp3_data) + except gTTSError as exc: + _LOGGER.debug( + "Error during processing of TTS request %s", exc, exc_info=True + ) + raise HomeAssistantError(exc) from exc + + return "mp3", mp3_data.getvalue() class GoogleProvider(Provider): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 96cb74cb316..05de95f902e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -173,6 +173,7 @@ FLOWS = { "google_generative_ai_conversation", "google_mail", "google_sheets", + "google_translate", "google_travel_time", "govee_ble", "gpslogger", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 044bb8fec68..f1ad6c28828 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2084,7 +2084,7 @@ }, "google_translate": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_push", "name": "Google Translate text-to-speech" }, diff --git a/tests/components/google_translate/conftest.py b/tests/components/google_translate/conftest.py new file mode 100644 index 00000000000..34132fc5c1d --- /dev/null +++ b/tests/components/google_translate/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Google Translate text-to-speech tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.google_translate.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/google_translate/test_config_flow.py b/tests/components/google_translate/test_config_flow.py new file mode 100644 index 00000000000..70ad09961af --- /dev/null +++ b/tests/components/google_translate/test_config_flow.py @@ -0,0 +1,68 @@ +"""Test the Google Translate text-to-speech config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_translate.const import CONF_TLD, DOMAIN +from homeassistant.components.tts import CONF_LANG +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_step(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test user step create entry result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LANG: "de", + CONF_TLD: "de", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Translate text-to-speech" + assert result["data"] == { + CONF_LANG: "de", + CONF_TLD: "de", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test user step already configured entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_LANG: "de", CONF_TLD: "de"} + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LANG: "de", + CONF_TLD: "de", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 6597507d334..d6669ee3c5f 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -1,21 +1,27 @@ """The tests for the Google speech platform.""" -from unittest.mock import patch +from __future__ import annotations + +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock, patch from gtts import gTTSError import pytest from homeassistant.components import media_source, tts +from homeassistant.components.google_translate.const import CONF_TLD, DOMAIN from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) from homeassistant.config import async_process_ha_core_config -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockConfigEntry, async_mock_service @pytest.fixture(autouse=True) @@ -29,7 +35,7 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir -async def get_media_source_url(hass, media_content_id): +async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> str: """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) @@ -39,13 +45,13 @@ async def get_media_source_url(hass, media_content_id): @pytest.fixture -async def calls(hass): +async def calls(hass: HomeAssistant) -> list[ServiceCall]: """Mock media player calls.""" return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @pytest.fixture(autouse=True) -async def setup_internal_url(hass): +async def setup_internal_url(hass: HomeAssistant) -> None: """Set up internal url.""" await async_process_ha_core_config( hass, {"internal_url": "http://example.local:8123"} @@ -53,26 +59,85 @@ async def setup_internal_url(hass): @pytest.fixture -def mock_gtts(): +def mock_gtts() -> Generator[MagicMock, None, None]: """Mock gtts.""" with patch("homeassistant.components.google_translate.tts.gTTS") as mock_gtts: yield mock_gtts -async def test_service_say(hass: HomeAssistant, mock_gtts, calls) -> None: - """Test service call say.""" +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + config: dict[str, Any], + request: pytest.FixtureRequest, +) -> None: + """Set up the test environment.""" + if request.param == "mock_setup": + await mock_setup(hass, config) + elif request.param == "mock_config_entry_setup": + await mock_config_entry_setup(hass, config) + else: + raise RuntimeError("Invalid setup fixture") - await async_setup_component( - hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "google_translate"}} + +@pytest.fixture(name="config") +def config_fixture() -> dict[str, Any]: + """Return config.""" + return {} + + +async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock setup.""" + assert await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} ) + +async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock config entry setup.""" + default_config = {tts.CONF_LANG: "en", CONF_TLD: "com"} + config_entry = MockConfigEntry(domain=DOMAIN, data=default_config | config) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "google_translate_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_en_com", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service( + hass: HomeAssistant, + mock_gtts: MagicMock, + calls: list[ServiceCall], + setup: str, + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test tts service.""" await hass.services.async_call( tts.DOMAIN, - "google_translate_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - }, + tts_service, + service_data, blocking=True, ) @@ -88,22 +153,43 @@ async def test_service_say(hass: HomeAssistant, mock_gtts, calls) -> None: } -async def test_service_say_german_config(hass: HomeAssistant, mock_gtts, calls) -> None: +@pytest.mark.parametrize("config", [{tts.CONF_LANG: "de"}]) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "google_translate_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_de_com", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ], + indirect=["setup"], +) +async def test_service_say_german_config( + hass: HomeAssistant, + mock_gtts: MagicMock, + calls: list[ServiceCall], + setup: str, + tts_service: str, + service_data: dict[str, Any], +) -> None: """Test service call say with german code in the config.""" - - await async_setup_component( - hass, - tts.DOMAIN, - {tts.DOMAIN: {"platform": "google_translate", "language": "de"}}, - ) - await hass.services.async_call( tts.DOMAIN, - "google_translate_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - }, + tts_service, + service_data, blocking=True, ) @@ -117,25 +203,44 @@ async def test_service_say_german_config(hass: HomeAssistant, mock_gtts, calls) } +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "google_translate_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "de", + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_en_com", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "de", + }, + ), + ], + indirect=["setup"], +) async def test_service_say_german_service( - hass: HomeAssistant, mock_gtts, calls + hass: HomeAssistant, + mock_gtts: MagicMock, + calls: list[ServiceCall], + setup: str, + tts_service: str, + service_data: dict[str, Any], ) -> None: """Test service call say with german code in the service.""" - - config = { - tts.DOMAIN: {"platform": "google_translate", "service_name": "google_say"} - } - - await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "google_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "de", - }, + tts_service, + service_data, blocking=True, ) @@ -149,22 +254,43 @@ async def test_service_say_german_service( } -async def test_service_say_en_uk_config(hass: HomeAssistant, mock_gtts, calls) -> None: +@pytest.mark.parametrize("config", [{tts.CONF_LANG: "en-uk"}]) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "google_translate_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_en_co_uk", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ], + indirect=["setup"], +) +async def test_service_say_en_uk_config( + hass: HomeAssistant, + mock_gtts: MagicMock, + calls: list[ServiceCall], + setup: str, + tts_service: str, + service_data: dict[str, Any], +) -> None: """Test service call say with en-uk code in the config.""" - - await async_setup_component( - hass, - tts.DOMAIN, - {tts.DOMAIN: {"platform": "google_translate", "language": "en-uk"}}, - ) - await hass.services.async_call( tts.DOMAIN, - "google_translate_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - }, + tts_service, + service_data, blocking=True, ) @@ -178,23 +304,44 @@ async def test_service_say_en_uk_config(hass: HomeAssistant, mock_gtts, calls) - } -async def test_service_say_en_uk_service(hass: HomeAssistant, mock_gtts, calls) -> None: +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "google_translate_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "en-uk", + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_en_com", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "en-uk", + }, + ), + ], + indirect=["setup"], +) +async def test_service_say_en_uk_service( + hass: HomeAssistant, + mock_gtts: MagicMock, + calls: list[ServiceCall], + setup: str, + tts_service: str, + service_data: dict[str, Any], +) -> None: """Test service call say with en-uk code in the config.""" - - await async_setup_component( - hass, - tts.DOMAIN, - {tts.DOMAIN: {"platform": "google_translate"}}, - ) - await hass.services.async_call( tts.DOMAIN, - "google_translate_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "en-uk", - }, + tts_service, + service_data, blocking=True, ) @@ -208,21 +355,44 @@ async def test_service_say_en_uk_service(hass: HomeAssistant, mock_gtts, calls) } -async def test_service_say_en_couk(hass: HomeAssistant, mock_gtts, calls) -> None: +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "google_translate_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {"tld": "co.uk"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_en_com", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {"tld": "co.uk"}, + }, + ), + ], + indirect=["setup"], +) +async def test_service_say_en_couk( + hass: HomeAssistant, + mock_gtts: MagicMock, + calls: list[ServiceCall], + setup: str, + tts_service: str, + service_data: dict[str, Any], +) -> None: """Test service call say in co.uk tld accent.""" - - await async_setup_component( - hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "google_translate"}} - ) - await hass.services.async_call( tts.DOMAIN, - "google_translate_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {"tld": "co.uk"}, - }, + tts_service, + service_data, blocking=True, ) @@ -238,20 +408,44 @@ async def test_service_say_en_couk(hass: HomeAssistant, mock_gtts, calls) -> Non } -async def test_service_say_error(hass: HomeAssistant, mock_gtts, calls) -> None: +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "google_translate_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_en_com", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + ), + ], + indirect=["setup"], +) +async def test_service_say_error( + hass: HomeAssistant, + mock_gtts: MagicMock, + calls: list[ServiceCall], + setup: str, + tts_service: str, + service_data: dict[str, Any], +) -> None: """Test service call say with http response 400.""" mock_gtts.return_value.write_to_fp.side_effect = gTTSError - await async_setup_component( - hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "google_translate"}} - ) await hass.services.async_call( tts.DOMAIN, - "google_translate_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - }, + tts_service, + service_data, blocking=True, ) From e24c2ae55c875ea5017391214c63f5cbc01d36df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 15:14:57 +0200 Subject: [PATCH 418/857] Avoid fetching both unifiprotect RTSP urls (#94978) --- homeassistant/components/unifiprotect/camera.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 00dbbe77e77..a4da77fe50b 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -167,9 +167,8 @@ class ProtectCamera(ProtectDeviceEntity, Camera): if not self.channel.is_rtsp_enabled: disable_stream = False - rtsp_url = self.channel.rtsp_url - if self._secure: - rtsp_url = self.channel.rtsps_url + channel = self.channel + rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url # _async_set_stream_source called by __init__ self._stream_source = ( # pylint: disable=attribute-defined-outside-init From 367644afe185919c39cdae25609441458307152f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 15:41:24 +0200 Subject: [PATCH 419/857] Migrate esphome switch platform to use _on_static_info_update (#94962) --- homeassistant/components/esphome/switch.py | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 83148542435..e71853a1287 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -3,11 +3,11 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import SwitchInfo, SwitchState +from aioesphomeapi import EntityInfo, SwitchInfo, SwitchState from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum @@ -32,10 +32,15 @@ async def async_setup_entry( class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """A switch implementation for ESPHome.""" - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._static_info.assumed_state + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_assumed_state = static_info.assumed_state + self._attr_device_class = try_parse_enum( + SwitchDeviceClass, static_info.device_class + ) @property @esphome_state_property @@ -43,15 +48,10 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """Return true if the switch is on.""" return self._state.state - @property - def device_class(self) -> SwitchDeviceClass | None: - """Return the class of this device.""" - return try_parse_enum(SwitchDeviceClass, self._static_info.device_class) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self._client.switch_command(self._static_info.key, True) + await self._client.switch_command(self._key, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._client.switch_command(self._static_info.key, False) + await self._client.switch_command(self._key, False) From c8cd469c9562acae4f50fc80135e11e3d3d72007 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 16:09:28 +0200 Subject: [PATCH 420/857] Teach media_player device trigger about entity registry ids (#94979) --- .../components/media_player/device_trigger.py | 4 +- .../media_player/test_device_trigger.py | 202 ++++++++++++++---- 2 files changed, 161 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 58fc0aca84f..e626059841c 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -33,7 +33,7 @@ TRIGGER_TYPES = {"turned_on", "turned_off", "buffering", "idle", "paused", "play MEDIA_PLAYER_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -66,7 +66,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, } for trigger in TRIGGER_TYPES diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index c899bf2ce75..8bf71f85647 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -54,13 +54,15 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) + entity_trigger_types = { + "changed_states", + } trigger_types = { "buffering", - "changed_states", "idle", "paused", "playing", @@ -73,11 +75,22 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in trigger_types ] + expected_triggers += [ + { + "platform": "device", + "domain": DOMAIN, + "type": trigger, + "device_id": device_entry.id, + "entity_id": entity_entry.entity_id, + "metadata": {"secondary": False}, + } + for trigger in entity_trigger_types + ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) @@ -107,7 +120,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -115,24 +128,38 @@ async def test_get_triggers_hidden_auxiliary( entity_category=entity_category, hidden_by=hidden_by, ) + entity_trigger_types = { + "changed_states", + } + trigger_types = { + "buffering", + "idle", + "paused", + "playing", + "turned_off", + "turned_on", + } expected_triggers = [ { "platform": "device", "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in [ - "buffering", - "changed_states", - "idle", - "paused", - "playing", - "turned_off", - "turned_on", - ] + for trigger in trigger_types + ] + expected_triggers += [ + { + "platform": "device", + "domain": DOMAIN, + "type": trigger, + "device_id": device_entry.id, + "entity_id": entity_entry.entity_id, + "metadata": {"secondary": True}, + } + for trigger in entity_trigger_types ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -171,9 +198,45 @@ async def test_get_trigger_capabilities( } -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a media player.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 7 + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + + +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test triggers firing.""" - hass.states.async_set("media_player.entity", STATE_OFF) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_OFF) data_template = ( "{label} - {{{{ trigger.platform}}}} - " @@ -200,7 +263,9 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "media_player.entity", + "entity_id": entry.entity_id + if trigger == "changed_states" + else entry.id, "type": trigger, }, "action": { @@ -214,64 +279,73 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Fake that the entity is turning on. - hass.states.async_set("media_player.entity", STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - "turned_on - device - media_player.entity - off - on - None", - "changed_states - device - media_player.entity - off - on - None", + "turned_on - device - media_player.test_5678 - off - on - None", + "changed_states - device - media_player.test_5678 - off - on - None", } # Fake that the entity is turning off. - hass.states.async_set("media_player.entity", STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - "turned_off - device - media_player.entity - on - off - None", - "changed_states - device - media_player.entity - on - off - None", + "turned_off - device - media_player.test_5678 - on - off - None", + "changed_states - device - media_player.test_5678 - on - off - None", } # Fake that the entity becomes idle. - hass.states.async_set("media_player.entity", STATE_IDLE) + hass.states.async_set(entry.entity_id, STATE_IDLE) await hass.async_block_till_done() assert len(calls) == 6 assert {calls[4].data["some"], calls[5].data["some"]} == { - "idle - device - media_player.entity - off - idle - None", - "changed_states - device - media_player.entity - off - idle - None", + "idle - device - media_player.test_5678 - off - idle - None", + "changed_states - device - media_player.test_5678 - off - idle - None", } # Fake that the entity starts playing. - hass.states.async_set("media_player.entity", STATE_PLAYING) + hass.states.async_set(entry.entity_id, STATE_PLAYING) await hass.async_block_till_done() assert len(calls) == 8 assert {calls[6].data["some"], calls[7].data["some"]} == { - "playing - device - media_player.entity - idle - playing - None", - "changed_states - device - media_player.entity - idle - playing - None", + "playing - device - media_player.test_5678 - idle - playing - None", + "changed_states - device - media_player.test_5678 - idle - playing - None", } # Fake that the entity is paused. - hass.states.async_set("media_player.entity", STATE_PAUSED) + hass.states.async_set(entry.entity_id, STATE_PAUSED) await hass.async_block_till_done() assert len(calls) == 10 assert {calls[8].data["some"], calls[9].data["some"]} == { - "paused - device - media_player.entity - playing - paused - None", - "changed_states - device - media_player.entity - playing - paused - None", + "paused - device - media_player.test_5678 - playing - paused - None", + "changed_states - device - media_player.test_5678 - playing - paused - None", } # Fake that the entity is buffering. - hass.states.async_set("media_player.entity", STATE_BUFFERING) + hass.states.async_set(entry.entity_id, STATE_BUFFERING) await hass.async_block_till_done() assert len(calls) == 12 assert {calls[10].data["some"], calls[11].data["some"]} == { - "buffering - device - media_player.entity - paused - buffering - None", - "changed_states - device - media_player.entity - paused - buffering - None", + "buffering - device - media_player.test_5678 - paused - buffering - None", + "changed_states - device - media_player.test_5678 - paused - buffering - None", } -async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> None: - """Test for triggers firing with delay.""" - entity_id = f"{DOMAIN}.entity" - hass.states.async_set(entity_id, STATE_OFF) +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_OFF) + + data_template = ( + "{label} - {{{{ trigger.platform}}}} - " + "{{{{ trigger.entity_id}}}} - {{{{ trigger.from_state.state}}}} - " + "{{{{ trigger.to_state.state}}}} - {{{{ trigger.for }}}}" + ) assert await async_setup_component( hass, @@ -283,7 +357,49 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.entity_id, + "type": "turned_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": data_template.format(label="turned_on") + }, + }, + } + ] + }, + ) + + # Fake that the entity is turning on. + hass.states.async_set(entry.entity_id, STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "turned_on - device - media_player.test_5678 - off - on - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_OFF) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "turned_on", "for": {"seconds": 5}, }, @@ -307,10 +423,9 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> }, ) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF assert len(calls) == 0 - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -318,5 +433,6 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> assert len(calls) == 1 await hass.async_block_till_done() assert ( - calls[0].data["some"] == f"turn_off device - {entity_id} - off - on - 0:00:05" + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - off - on - 0:00:05" ) From 86792fcc2f7241f002d629b90fb6bed4fb0177b5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 21 Jun 2023 16:12:51 +0200 Subject: [PATCH 421/857] Update mypy to 1.4.0 (#94987) --- .../components/arcam_fmj/device_trigger.py | 2 +- homeassistant/components/demo/mailbox.py | 2 +- .../components/esphome/domain_data.py | 4 ++-- .../components/fritzbox/binary_sensor.py | 4 ++-- homeassistant/components/fritzbox/sensor.py | 20 +++++++++---------- .../components/jellyfin/media_source.py | 12 +++++------ homeassistant/components/knx/sensor.py | 2 +- homeassistant/components/lacrosse/sensor.py | 2 +- .../persistent_notification/trigger.py | 2 +- homeassistant/components/rest/__init__.py | 8 ++------ homeassistant/components/sentry/__init__.py | 2 +- homeassistant/components/tuya/diagnostics.py | 2 +- homeassistant/helpers/singleton.py | 2 +- requirements_test.txt | 2 +- 14 files changed, 31 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index ecaec0e0e7d..d62e764f47a 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -71,7 +71,7 @@ async def async_attach_trigger( job, { "trigger": { - **trigger_data, # type: ignore[arg-type] # https://github.com/python/mypy/issues/9117 + **trigger_data, **config, "description": f"{DOMAIN} - {entity_id}", } diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index 9627383443e..8aa3e1ef384 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -78,7 +78,7 @@ class DemoMailbox(Mailbox): """Return a list of the current messages.""" return sorted( self._messages.values(), - key=lambda item: item["info"]["origtime"], # type: ignore[no-any-return] + key=lambda item: item["info"]["origtime"], reverse=True, ) diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index 32d2d1effff..1379b274122 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -28,10 +28,10 @@ class DomainData: _entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict) _stores: dict[str, Store] = field(default_factory=dict) _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] + default_factory=lambda: LRU(MAX_CACHED_SERVICES) ) _gatt_mtu_cache: MutableMapping[int, int] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] + default_factory=lambda: LRU(MAX_CACHED_SERVICES) ) def get_gatt_services_cache( diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index f87beb34079..dc56bc0473e 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -42,8 +42,8 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( key="alarm", translation_key="alarm", device_class=BinarySensorDeviceClass.WINDOW, - suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] - is_on=lambda device: device.alert_state, # type: ignore[no-any-return] + suitable=lambda device: device.has_alarm, + is_on=lambda device: device.alert_state, ), FritzBinarySensorEntityDescription( key="lock", diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 7922224e195..498176d6c25 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -97,7 +97,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_temperature, - native_value=lambda device: device.temperature, # type: ignore[no-any-return] + native_value=lambda device: device.temperature, ), FritzSensorEntityDescription( key="humidity", @@ -106,7 +106,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.rel_humidity is not None, - native_value=lambda device: device.rel_humidity, # type: ignore[no-any-return] + native_value=lambda device: device.rel_humidity, ), FritzSensorEntityDescription( key="battery", @@ -115,7 +115,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.battery_level is not None, - native_value=lambda device: device.battery_level, # type: ignore[no-any-return] + native_value=lambda device: device.battery_level, ), FritzSensorEntityDescription( key="power_consumption", @@ -123,7 +123,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: round((device.power or 0.0) / 1000, 3), ), FritzSensorEntityDescription( @@ -132,7 +132,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: round((device.voltage or 0.0) / 1000, 2), ), FritzSensorEntityDescription( @@ -141,7 +141,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: round((device.current or 0.0) / 1000, 3), ), FritzSensorEntityDescription( @@ -150,7 +150,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: (device.energy or 0.0) / 1000, ), # Thermostat Sensors @@ -161,7 +161,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_comfort_temperature, - native_value=lambda device: device.comfort_temperature, # type: ignore[no-any-return] + native_value=lambda device: device.comfort_temperature, ), FritzSensorEntityDescription( key="eco_temperature", @@ -170,7 +170,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_eco_temperature, - native_value=lambda device: device.eco_temperature, # type: ignore[no-any-return] + native_value=lambda device: device.eco_temperature, ), FritzSensorEntityDescription( key="nextchange_temperature", @@ -179,7 +179,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, - native_value=lambda device: device.nextchange_temperature, # type: ignore[no-any-return] + native_value=lambda device: device.nextchange_temperature, ), FritzSensorEntityDescription( key="nextchange_time", diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index b2e7e1468fd..f9c73443d00 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -189,7 +189,7 @@ class JellyfinSource(MediaSource): async def _build_artists(self, library_id: str) -> list[BrowseMediaSource]: """Return all artists in the music library.""" artists = await self._get_children(library_id, ITEM_TYPE_ARTIST) - artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_artist(artist, False) for artist in artists] async def _build_artist( @@ -220,7 +220,7 @@ class JellyfinSource(MediaSource): async def _build_albums(self, parent_id: str) -> list[BrowseMediaSource]: """Return all albums of a single artist as browsable media sources.""" albums = await self._get_children(parent_id, ITEM_TYPE_ALBUM) - albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_album(album, False) for album in albums] async def _build_album( @@ -310,7 +310,7 @@ class JellyfinSource(MediaSource): async def _build_movies(self, library_id: str) -> list[BrowseMediaSource]: """Return all movies in the movie library.""" movies = await self._get_children(library_id, ITEM_TYPE_MOVIE) - movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) return [ self._build_movie(movie) for movie in movies @@ -363,7 +363,7 @@ class JellyfinSource(MediaSource): async def _build_tvshow(self, library_id: str) -> list[BrowseMediaSource]: """Return all series in the tv library.""" series = await self._get_children(library_id, ITEM_TYPE_SERIES) - series = sorted(series, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + series = sorted(series, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_series(serie, False) for serie in series] async def _build_series( @@ -394,7 +394,7 @@ class JellyfinSource(MediaSource): async def _build_seasons(self, series_id: str) -> list[BrowseMediaSource]: """Return all seasons in the series.""" seasons = await self._get_children(series_id, ITEM_TYPE_SEASON) - seasons = sorted(seasons, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + seasons = sorted(seasons, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_season(season, False) for season in seasons] async def _build_season( @@ -425,7 +425,7 @@ class JellyfinSource(MediaSource): async def _build_episodes(self, season_id: str) -> list[BrowseMediaSource]: """Return all episode in the season.""" episodes = await self._get_children(season_id, ITEM_TYPE_EPISODE) - episodes = sorted(episodes, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + episodes = sorted(episodes, key=lambda k: k[ITEM_KEY_NAME]) return [ self._build_episode(episode) for episode in episodes diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index ea5ba2f63a6..4400c304193 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -71,7 +71,7 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( device_class=SensorDeviceClass.ENUM, options=[opt.value for opt in XknxConnectionType], should_poll=False, - value_fn=lambda knx: knx.xknx.connection_manager.connection_type.value, # type: ignore[no-any-return] + value_fn=lambda knx: knx.xknx.connection_manager.connection_type.value, ), KNXSystemEntityDescription( key="telegrams_incoming", diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index fb2c60b32c9..7355a60f5f0 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -93,7 +93,7 @@ def setup_platform( _LOGGER.warning("Unable to open serial port: %s", exc) return - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: lacrosse.close()) # type: ignore[no-any-return] + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: lacrosse.close()) if CONF_JEELINK_LED in config: lacrosse.led_mode_state(config.get(CONF_JEELINK_LED)) diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py index 12f98083bdf..4c9c2bd9204 100644 --- a/homeassistant/components/persistent_notification/trigger.py +++ b/homeassistant/components/persistent_notification/trigger.py @@ -63,7 +63,7 @@ async def async_attach_trigger( job, { "trigger": { - **trigger_data, # type: ignore[arg-type] # https://github.com/python/mypy/issues/9117 + **trigger_data, "platform": "persistent_notification", "update_type": update_type, "notification": notification, diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index b249b7536b5..ee79c45921c 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine import contextlib from datetime import timedelta import logging -from typing import Any, cast +from typing import Any import httpx import voluptuous as vol @@ -160,11 +160,7 @@ def _rest_coordinator( if resource_template: async def _async_refresh_with_resource_template() -> None: - rest.set_url( - cast(template.Template, resource_template).async_render( - parse_result=False - ) - ) + rest.set_url(resource_template.async_render(parse_result=False)) await rest.async_update() update_method = _async_refresh_with_resource_template diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 092358e82f6..8815986d368 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -206,7 +206,7 @@ def process_before_send( "channel": channel, "custom_components": "\n".join(sorted(custom_components)), "integrations": "\n".join(sorted(integrations)), - **system_info, # type: ignore[arg-type] + **system_info, }, } ) diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 67bbad0aceb..454416970ea 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -155,7 +155,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, for entity_entry in hass_entities: state = hass.states.get(entity_entry.entity_id) - state_dict = None + state_dict: dict[str, Any] | None = None if state: state_dict = dict(state.as_dict()) diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index f15806ae5ff..5579106bb55 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -38,7 +38,7 @@ def singleton(data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: async def async_wrapped(hass: HomeAssistant) -> Any: if data_key not in hass.data: evt = hass.data[data_key] = asyncio.Event() - result = await func(hass) # type: ignore[misc] + result = await func(hass) hass.data[data_key] = result evt.set() return cast(_T, result) diff --git a/requirements_test.txt b/requirements_test.txt index f53a16b009d..b0138a2c502 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==2.15.4 coverage==7.2.4 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.3.0 +mypy==1.4.0 pre-commit==3.1.0 pydantic==1.10.9 pylint==2.17.4 From 61554682d45b84accc19078029e718adc1e3e811 Mon Sep 17 00:00:00 2001 From: centertivevier Date: Wed, 21 Jun 2023 18:10:30 +0200 Subject: [PATCH 422/857] Bump slixmpp to 1.8.4 (#94944) --- homeassistant/components/xmpp/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index dd7df91223c..99b3ff126d3 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/xmpp", "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], - "requirements": ["slixmpp==1.8.3"] + "requirements": ["slixmpp==1.8.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index e2456d3c79c..1056712879a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ sisyphus-control==3.1.3 slackclient==2.5.0 # homeassistant.components.xmpp -slixmpp==1.8.3 +slixmpp==1.8.4 # homeassistant.components.smart_meter_texas smart-meter-texas==0.4.7 From 57c9aad9b17a37a60f7313f01fc64fa61a4b7005 Mon Sep 17 00:00:00 2001 From: Barry Williams Date: Wed, 21 Jun 2023 17:46:17 +0100 Subject: [PATCH 423/857] Migrate Linn / Openhome integration to SSDP config flow (#94564) * Migrate Linn / Openhome integration to SSDP config flow * moved device initialisation into __init__ * wait for user step before adding openhome entities * add CONFIG_SCHEMA * cover confirmation step in config flow test * Address comments --------- Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 1 + homeassistant/components/openhome/__init__.py | 54 ++++++++ .../components/openhome/config_flow.py | 66 ++++++++++ .../components/openhome/manifest.json | 17 ++- .../components/openhome/media_player.py | 42 +++---- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- homeassistant/generated/ssdp.py | 14 +++ requirements_test_all.txt | 3 + tests/components/openhome/__init__.py | 1 + tests/components/openhome/test_config_flow.py | 116 ++++++++++++++++++ 11 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/openhome/config_flow.py create mode 100644 tests/components/openhome/__init__.py create mode 100644 tests/components/openhome/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index b41b14d46fb..c82f249a906 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -885,6 +885,7 @@ build.json @home-assistant/supervisor /homeassistant/components/opengarage/ @danielhiversen /tests/components/opengarage/ @danielhiversen /homeassistant/components/openhome/ @bazwilliams +/tests/components/openhome/ @bazwilliams /homeassistant/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23 diff --git a/homeassistant/components/openhome/__init__.py b/homeassistant/components/openhome/__init__.py index 78294ceb6f4..d201646e81c 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -1 +1,55 @@ """The openhome component.""" + +import asyncio +import logging + +import aiohttp +from async_upnp_client.client import UpnpError +from openhomedevice.device import Device + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.MEDIA_PLAYER] + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Cleanup before removing config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> bool: + """Set up the configuration config entry.""" + _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) + + device = await hass.async_add_executor_job(Device, config_entry.data[CONF_HOST]) + + try: + await device.init() + except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as exc: + raise ConfigEntryNotReady from exc + + _LOGGER.debug("Initialised device: %s", device.uuid()) + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = device + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py new file mode 100644 index 00000000000..c8a13a3c7aa --- /dev/null +++ b/homeassistant/components/openhome/config_flow.py @@ -0,0 +1,66 @@ +"""Config flow for Linn / OpenHome.""" + +import logging +from typing import Any + +from homeassistant.components.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _is_complete_discovery(discovery_info: SsdpServiceInfo) -> bool: + """Test if discovery is complete and usable.""" + return bool(ATTR_UPNP_UDN in discovery_info.upnp and discovery_info.ssdp_location) + + +class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle an Openhome config flow.""" + + async def async_step_ssdp(self, discovery_info: SsdpServiceInfo) -> FlowResult: + """Handle a flow initialized by discovery.""" + _LOGGER.debug("async_step_ssdp: started") + + if not _is_complete_discovery(discovery_info): + _LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring") + return self.async_abort(reason="incomplete_discovery") + + _LOGGER.debug( + "async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN] + ) + + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) + self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location}) + + _LOGGER.debug( + "async_step_ssdp: create entry %s", discovery_info.upnp[ATTR_UPNP_UDN] + ) + + self.context[CONF_NAME] = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] + self.context[CONF_HOST] = discovery_info.ssdp_location + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of discovered node.""" + + if user_input is not None: + return self.async_create_entry( + title=self.context[CONF_NAME], + data={CONF_HOST: self.context[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={CONF_NAME: self.context[CONF_NAME]}, + ) diff --git a/homeassistant/components/openhome/manifest.json b/homeassistant/components/openhome/manifest.json index aa563151f0b..61d425895bf 100644 --- a/homeassistant/components/openhome/manifest.json +++ b/homeassistant/components/openhome/manifest.json @@ -2,8 +2,23 @@ "domain": "openhome", "name": "Linn / OpenHome", "codeowners": ["@bazwilliams"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openhome", "iot_class": "local_polling", "loggers": ["async_upnp_client", "openhomedevice"], - "requirements": ["openhomedevice==2.0.2"] + "requirements": ["openhomedevice==2.0.2"], + "ssdp": [ + { + "st": "urn:av-openhome-org:service:Product:1" + }, + { + "st": "urn:av-openhome-org:service:Product:2" + }, + { + "st": "urn:av-openhome-org:service:Product:3" + }, + { + "st": "urn:av-openhome-org:service:Product:4" + } + ] } diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index b625d9976da..c0941906e40 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -9,7 +9,6 @@ from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp from async_upnp_client.client import UpnpError -from openhomedevice.device import Device import voluptuous as vol from homeassistant.components import media_source @@ -21,12 +20,13 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_PIN_INDEX, DATA_OPENHOME, SERVICE_INVOKE_PIN +from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN _OpenhomeDeviceT = TypeVar("_OpenhomeDeviceT", bound="OpenhomeDevice") _R = TypeVar("_R") @@ -41,34 +41,20 @@ SUPPORT_OPENHOME = ( _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Openhome platform.""" + """Set up the Openhome config entry.""" - if not discovery_info: - return + _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) - openhome_data = hass.data.setdefault(DATA_OPENHOME, set()) - - name = discovery_info.get("name") - description = discovery_info.get("ssdp_description") - - _LOGGER.info("Openhome device found: %s", name) - device = await hass.async_add_executor_job(Device, description) - await device.init() - - # if device has already been discovered - if device.uuid() in openhome_data: - return + device = hass.data[DOMAIN][config_entry.entry_id] entity = OpenhomeDevice(hass, device) async_add_entities([entity]) - openhome_data.add(device.uuid()) platform = entity_platform.async_get_current_platform() @@ -133,6 +119,18 @@ class OpenhomeDevice(MediaPlayerEntity): self._attr_state = MediaPlayerState.PLAYING self._available = True + @property + def device_info(self): + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self._device.uuid()), + }, + manufacturer=self._device.device.manufacturer, + model=self._device.device.model_name, + name=self._device.device.friendly_name, + ) + @property def available(self): """Device is available.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 05de95f902e..13c3a385756 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -320,6 +320,7 @@ FLOWS = { "openai_conversation", "openexchangerates", "opengarage", + "openhome", "opentherm_gw", "openuv", "openweathermap", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f1ad6c28828..e67f385f4b4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3954,7 +3954,7 @@ "openhome": { "name": "Linn / OpenHome", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "opensensemap": { diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 3a2097a1d30..8e7319917f0 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -224,6 +224,20 @@ SSDP = { "manufacturer": "The OctoPrint Project", }, ], + "openhome": [ + { + "st": "urn:av-openhome-org:service:Product:1", + }, + { + "st": "urn:av-openhome-org:service:Product:2", + }, + { + "st": "urn:av-openhome-org:service:Product:3", + }, + { + "st": "urn:av-openhome-org:service:Product:4", + }, + ], "roku": [ { "deviceType": "urn:roku-com:device:player:1-0", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0755a43c1bb..5c0faae7555 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1026,6 +1026,9 @@ openai==0.27.2 # homeassistant.components.openerz openerz-api==0.2.0 +# homeassistant.components.openhome +openhomedevice==2.0.2 + # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/tests/components/openhome/__init__.py b/tests/components/openhome/__init__.py new file mode 100644 index 00000000000..39c7a3a4dcb --- /dev/null +++ b/tests/components/openhome/__init__.py @@ -0,0 +1 @@ +"""Tests for the Linn / OpenHome integration.""" diff --git a/tests/components/openhome/test_config_flow.py b/tests/components/openhome/test_config_flow.py new file mode 100644 index 00000000000..4cc5c58dda5 --- /dev/null +++ b/tests/components/openhome/test_config_flow.py @@ -0,0 +1,116 @@ +"""Tests for the Openhome config flow module.""" + +from homeassistant import data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.openhome.const import DOMAIN +from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN +from homeassistant.config_entries import SOURCE_SSDP +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_UDN = "uuid:4c494e4e-1234-ab12-abcd-01234567819f" +MOCK_FRIENDLY_NAME = "Test Client" +MOCK_SSDP_LOCATION = "http://device:12345/description.xml" + +MOCK_DISCOVER = ssdp.SsdpServiceInfo( + ssdp_usn="usn", + ssdp_st="st", + ssdp_location=MOCK_SSDP_LOCATION, + upnp={ATTR_UPNP_FRIENDLY_NAME: MOCK_FRIENDLY_NAME, ATTR_UPNP_UDN: MOCK_UDN}, +) + + +async def test_ssdp(hass: HomeAssistant) -> None: + """Test a ssdp import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=MOCK_DISCOVER, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == {CONF_NAME: MOCK_FRIENDLY_NAME} + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["title"] == MOCK_FRIENDLY_NAME + assert result2["data"] == {CONF_HOST: MOCK_SSDP_LOCATION} + + +async def test_device_exists(hass: HomeAssistant) -> None: + """Test a ssdp import where device already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_SSDP_LOCATION}, + title=MOCK_FRIENDLY_NAME, + unique_id=MOCK_UDN, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=MOCK_DISCOVER, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_missing_udn(hass: HomeAssistant) -> None: + """Test a ssdp import where discovery is missing udn.""" + broken_discovery = ssdp.SsdpServiceInfo( + ssdp_usn="usn", + ssdp_st="st", + ssdp_location=MOCK_SSDP_LOCATION, + upnp={ + ATTR_UPNP_FRIENDLY_NAME: MOCK_FRIENDLY_NAME, + }, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=broken_discovery, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "incomplete_discovery" + + +async def test_missing_ssdp_location(hass: HomeAssistant) -> None: + """Test a ssdp import where discovery is missing udn.""" + broken_discovery = ssdp.SsdpServiceInfo( + ssdp_usn="usn", + ssdp_st="st", + ssdp_location="", + upnp={ATTR_UPNP_FRIENDLY_NAME: MOCK_FRIENDLY_NAME, ATTR_UPNP_UDN: MOCK_UDN}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=broken_discovery, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "incomplete_discovery" + + +async def test_host_updated(hass: HomeAssistant) -> None: + """Test a ssdp import flow where host changes.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "old_host"}, + title=MOCK_FRIENDLY_NAME, + unique_id=MOCK_UDN, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=MOCK_DISCOVER, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == MOCK_SSDP_LOCATION From 6230a5169572c06f67e5265f324e842477d7698a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 21 Jun 2023 18:47:53 +0200 Subject: [PATCH 424/857] Remove superclass from GMail Authentication (#95001) --- homeassistant/components/google_mail/__init__.py | 3 +-- homeassistant/components/google_mail/api.py | 10 +++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 7e5281630bc..15c4192ccf5 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -8,7 +8,6 @@ from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -35,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Mail from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + auth = AsyncConfigEntryAuth(session) try: await auth.check_and_refresh_token() except ClientResponseError as err: diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index 202fa5b56b6..ffa33deae14 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,25 +1,21 @@ """API for Google Mail bound to Home Assistant OAuth.""" -from aiohttp import ClientSession from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials -from google.oauth2.utils import OAuthClientAuthHandler from googleapiclient.discovery import Resource, build from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers import config_entry_oauth2_flow -class AsyncConfigEntryAuth(OAuthClientAuthHandler): +class AsyncConfigEntryAuth: """Provide Google Mail authentication tied to an OAuth2 based config entry.""" def __init__( self, - websession: ClientSession, - oauth2Session: config_entry_oauth2_flow.OAuth2Session, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Google Mail Auth.""" - self.oauth_session = oauth2Session - super().__init__(websession) + self.oauth_session = oauth2_session @property def access_token(self) -> str: From 492ed1b5443d6ff8162257d11bb67df035026bbb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 21 Jun 2023 18:49:23 +0200 Subject: [PATCH 425/857] Remove superclass from YouTube (#95002) --- homeassistant/components/youtube/__init__.py | 3 +-- homeassistant/components/youtube/api.py | 6 +----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index b120359c0d7..c62c533be06 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -7,7 +7,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -24,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up YouTube from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - auth = AsyncConfigEntryAuth(hass, async_get_clientsession(hass), session) + auth = AsyncConfigEntryAuth(hass, session) try: await auth.check_and_refresh_token() except ClientResponseError as err: diff --git a/homeassistant/components/youtube/api.py b/homeassistant/components/youtube/api.py index b0d0bde2baa..64abf1a6753 100644 --- a/homeassistant/components/youtube/api.py +++ b/homeassistant/components/youtube/api.py @@ -1,8 +1,6 @@ """API for YouTube bound to Home Assistant OAuth.""" -from aiohttp import ClientSession from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials -from google.oauth2.utils import OAuthClientAuthHandler from googleapiclient.discovery import Resource, build from homeassistant.const import CONF_ACCESS_TOKEN @@ -10,19 +8,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -class AsyncConfigEntryAuth(OAuthClientAuthHandler): +class AsyncConfigEntryAuth: """Provide Google authentication tied to an OAuth2 based config entry.""" def __init__( self, hass: HomeAssistant, - websession: ClientSession, oauth2_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize YouTube Auth.""" self.oauth_session = oauth2_session self.hass = hass - super().__init__(websession) @property def access_token(self) -> str: From 31f845bfe03aa6e8284deeb6d8f81f2effe473a7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 21 Jun 2023 19:19:26 +0200 Subject: [PATCH 426/857] Add current_humidity attribute to mqtt humidifier (#94955) --- homeassistant/components/mqtt/climate.py | 4 +- homeassistant/components/mqtt/const.py | 2 + homeassistant/components/mqtt/humidifier.py | 50 +++++++++++++++++++++ tests/components/mqtt/test_humidifier.py | 42 +++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 755df281736..98fd344a30c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -52,6 +52,8 @@ from homeassistant.util.unit_conversion import TemperatureConverter from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( + CONF_CURRENT_HUMIDITY_TEMPLATE, + CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, CONF_CURRENT_TEMP_TOPIC, CONF_ENCODING, @@ -94,8 +96,6 @@ CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" -CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" -CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" CONF_FAN_MODE_LIST = "fan_modes" diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index a8d7812965c..fd259965d20 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -29,6 +29,8 @@ CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" +CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" +CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index f00944fc091..624bf0c698b 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import humidifier from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE, DEFAULT_MAX_HUMIDITY, @@ -37,6 +38,8 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_CURRENT_HUMIDITY_TEMPLATE, + CONF_CURRENT_HUMIDITY_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN, @@ -117,6 +120,8 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( ): cv.ensure_list, vol.Inclusive(CONF_MODE_COMMAND_TOPIC, "available_modes"): valid_publish_topic, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_HUMIDITY_TOPIC): valid_subscribe_topic, vol.Optional( CONF_DEVICE_CLASS, default=HumidifierDeviceClass.HUMIDIFIER ): vol.In( @@ -224,6 +229,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_CURRENT_HUMIDITY_TOPIC, CONF_TARGET_HUMIDITY_STATE_TOPIC, CONF_TARGET_HUMIDITY_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, @@ -263,6 +269,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._value_templates = {} value_templates: dict[str, Template | None] = { + ATTR_CURRENT_HUMIDITY: config.get(CONF_CURRENT_HUMIDITY_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), ATTR_HUMIDITY: config.get(CONF_TARGET_HUMIDITY_STATE_TEMPLATE), ATTR_MODE: config.get(CONF_MODE_STATE_TEMPLATE), @@ -301,6 +308,49 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): "encoding": self._config[CONF_ENCODING] or None, } + @callback + @log_messages(self.hass, self.entity_id) + def current_humidity_received(msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the current humidity.""" + rendered_current_humidity_payload = self._value_templates[ + ATTR_CURRENT_HUMIDITY + ](msg.payload) + if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: + self._attr_current_humidity = None + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + return + if not rendered_current_humidity_payload: + _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) + return + try: + current_humidity = round(float(rendered_current_humidity_payload)) + except ValueError: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + if current_humidity < 0 or current_humidity > 100: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + self._attr_current_humidity = current_humidity + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if self._topic[CONF_CURRENT_HUMIDITY_TOPIC] is not None: + topics[CONF_CURRENT_HUMIDITY_TOPIC] = { + "topic": self._topic[CONF_CURRENT_HUMIDITY_TOPIC], + "msg_callback": current_humidity_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + @callback @log_messages(self.hass, self.entity_id) def target_humidity_received(msg: ReceiveMessage) -> None: diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 08050aec8a0..fecf9c33fc0 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -8,12 +8,14 @@ from voluptuous.error import MultipleInvalid from homeassistant.components import humidifier, mqtt from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE, DOMAIN, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) +from homeassistant.components.mqtt.const import CONF_CURRENT_HUMIDITY_TOPIC from homeassistant.components.mqtt.humidifier import ( CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, @@ -151,6 +153,7 @@ async def test_fail_setup_if_no_command_topic( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", + "current_humidity_topic": "current-humidity-topic", "payload_off": "StAtE_OfF", "payload_on": "StAtE_On", "target_humidity_state_topic": "humidity-state-topic", @@ -220,6 +223,26 @@ async def test_controlling_state_via_topic( assert "not a valid mode" in caplog.text caplog.clear() + async_fire_mqtt_message(hass, "current-humidity-topic", "48") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) == 48 + + async_fire_mqtt_message(hass, "current-humidity-topic", "101") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) == 48 + + async_fire_mqtt_message(hass, "current-humidity-topic", "-1.6") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) == 48 + + async_fire_mqtt_message(hass, "current-humidity-topic", "43.6") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) == 44 + + async_fire_mqtt_message(hass, "current-humidity-topic", "invalid") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) == 44 + async_fire_mqtt_message(hass, "mode-state-topic", "auto") state = hass.states.get("humidifier.test") assert state.attributes.get(humidifier.ATTR_MODE) == "auto" @@ -258,6 +281,7 @@ async def test_controlling_state_via_topic( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", + "current_humidity_topic": "current-humidity-topic", "target_humidity_state_topic": "humidity-state-topic", "target_humidity_command_topic": "humidity-command-topic", "mode_state_topic": "mode-state-topic", @@ -267,6 +291,7 @@ async def test_controlling_state_via_topic( "eco", "baby", ], + "current_humidity_template": "{{ value_json.val }}", "state_value_template": "{{ value_json.val }}", "target_humidity_state_template": "{{ value_json.val }}", "mode_state_template": "{{ value_json.val }}", @@ -312,6 +337,22 @@ async def test_controlling_state_via_topic_and_json_message( assert state.attributes.get(humidifier.ATTR_HUMIDITY) is None caplog.clear() + async_fire_mqtt_message(hass, "current-humidity-topic", '{"val": 1}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) == 1 + + async_fire_mqtt_message(hass, "current-humidity-topic", '{"val": 100}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) == 100 + + async_fire_mqtt_message(hass, "current-humidity-topic", '{"val": "None"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) is None + + async_fire_mqtt_message(hass, "current-humidity-topic", '{"otherval": 100}') + assert state.attributes.get(humidifier.ATTR_CURRENT_HUMIDITY) is None + caplog.clear() + async_fire_mqtt_message(hass, "mode-state-topic", '{"val": "low"}') assert "not a valid mode" in caplog.text caplog.clear() @@ -746,6 +787,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ("state_topic", "ON", None, "on"), (CONF_MODE_STATE_TOPIC, "auto", ATTR_MODE, "auto"), (CONF_TARGET_HUMIDITY_STATE_TOPIC, "45", ATTR_HUMIDITY, 45), + (CONF_CURRENT_HUMIDITY_TOPIC, "39", ATTR_CURRENT_HUMIDITY, 39), ], ) async def test_encoding_subscribable_topics( From 90386bc036f02872579fc71bc7a0e3319feaeb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 21:41:06 +0200 Subject: [PATCH 427/857] Reduce overhead to fetch unifiprotect attributes (#94976) --- homeassistant/components/unifiprotect/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 061f6745f32..e0c56cfd5fc 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -38,6 +38,8 @@ from .const import ( ModelType, ) +_SENTINEL = object() + def get_nested_attr(obj: Any, attr: str) -> Any: """Fetch a nested attribute.""" @@ -46,9 +48,8 @@ def get_nested_attr(obj: Any, attr: str) -> Any: else: value = obj for key in attr.split("."): - if not hasattr(value, key): + if (value := getattr(value, key, _SENTINEL)) is _SENTINEL: return None - value = getattr(value, key) return value.value if isinstance(value, Enum) else value From 8d2daaa694074b7a676a368c788c316676914267 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 22:23:35 +0200 Subject: [PATCH 428/857] Limit cache size of EntityValues (#94983) --- homeassistant/helpers/entity_values.py | 19 +++++++++++++------ tests/helpers/test_entity_values.py | 7 +++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index d489a4b1d37..fe4a4249c54 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -3,14 +3,24 @@ from __future__ import annotations from collections import OrderedDict import fnmatch +from functools import lru_cache import re from typing import Any from homeassistant.core import split_entity_id +_MAX_EXPECTED_ENTITIES = 16384 + class EntityValues: - """Class to store entity id based values.""" + """Class to store entity id based values. + + This class is expected to only be used infrequently + as it caches all entity ids up to _MAX_EXPECTED_ENTITIES. + + The cache includes `self` so it is important to + only use this in places where usage of `EntityValues` is immortal. + """ def __init__( self, @@ -19,7 +29,6 @@ class EntityValues: glob: dict[str, dict[str, str]] | None = None, ) -> None: """Initialize an EntityConfigDict.""" - self._cache: dict[str, dict[str, str]] = {} self._exact = exact self._domain = domain @@ -32,13 +41,11 @@ class EntityValues: self._glob = compiled + @lru_cache(maxsize=_MAX_EXPECTED_ENTITIES) def get(self, entity_id: str) -> dict[str, str]: """Get config for an entity id.""" - if entity_id in self._cache: - return self._cache[entity_id] - domain, _ = split_entity_id(entity_id) - result = self._cache[entity_id] = {} + result: dict[str, str] = {} if self._domain is not None and domain in self._domain: result.update(self._domain[domain]) diff --git a/tests/helpers/test_entity_values.py b/tests/helpers/test_entity_values.py index f953ec0ba9e..1ac8e480f51 100644 --- a/tests/helpers/test_entity_values.py +++ b/tests/helpers/test_entity_values.py @@ -10,9 +10,12 @@ def test_override_single_value() -> None: """Test values with exact match.""" store = EV({ent: {"key": "value"}}) assert store.get(ent) == {"key": "value"} - assert len(store._cache) == 1 + assert store.get.cache_info().currsize == 1 + assert store.get.cache_info().misses == 1 assert store.get(ent) == {"key": "value"} - assert len(store._cache) == 1 + assert store.get.cache_info().currsize == 1 + assert store.get.cache_info().misses == 1 + assert store.get.cache_info().hits == 1 def test_override_by_domain() -> None: From 235f50a3412ac172abba76e6ca7b4d045aface08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jun 2023 22:24:26 +0200 Subject: [PATCH 429/857] Migrate esphome button platform to use _on_static_info_update (#95007) --- homeassistant/components/esphome/button.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 0cb577f30c9..4b2f02b266d 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -1,7 +1,7 @@ """Support for ESPHome buttons.""" from __future__ import annotations -from aioesphomeapi import ButtonInfo, EntityState +from aioesphomeapi import ButtonInfo, EntityInfo, EntityState from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry @@ -30,10 +30,13 @@ async def async_setup_entry( class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): """A button implementation for ESPHome.""" - @property - def device_class(self) -> ButtonDeviceClass | None: - """Return the class of this entity.""" - return try_parse_enum(ButtonDeviceClass, self._static_info.device_class) + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + self._attr_device_class = try_parse_enum( + ButtonDeviceClass, self._static_info.device_class + ) @callback def _on_device_update(self) -> None: @@ -44,4 +47,4 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self._client.button_command(self._static_info.key) + await self._client.button_command(self._key) From f10256169beb893495238a2b6abf2cffccaa21a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 21 Jun 2023 23:28:43 +0200 Subject: [PATCH 430/857] Teach homekit about entity registry ids in device triggers (#95009) --- homeassistant/components/homekit/type_triggers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index eb2cd5d34ad..ee737e01ff4 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -47,10 +47,12 @@ class DeviceTriggerAccessory(HomeAccessory): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") unique_id = f'{type_}-{subtype or ""}' - if (entity_id := trigger.get("entity_id")) and ( - entry := ent_reg.async_get(entity_id) + entity_id: str | None = None + if (entity_id_or_uuid := trigger.get("entity_id")) and ( + entry := ent_reg.async_get(entity_id_or_uuid) ): unique_id += f"-entity_unique_id:{get_system_unique_id(entry)}" + entity_id = entry.entity_id trigger_name_parts = [] if entity_id and (state := self.hass.states.get(entity_id)): trigger_name_parts.append(state.name) From b5084dbce2b4bec5b28ef636f29a1571e0e0d063 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 21 Jun 2023 23:57:33 +0200 Subject: [PATCH 431/857] Bump Matter Server to 3.5.1: some small fixes and stability improvements (#94985) --- homeassistant/components/matter/adapter.py | 4 ++-- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 7d73ceafc7a..9f16dae8334 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -92,7 +92,7 @@ class MatterAdapter: get_clean_name(basic_info.nodeLabel) or get_clean_name(basic_info.productLabel) or get_clean_name(basic_info.productName) - or device_type.__class__.__name__ + or device_type.__name__ if device_type else None ) @@ -117,7 +117,7 @@ class MatterAdapter: identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info.serialNumber}")) model = ( - get_clean_name(basic_info.productName) or device_type.__class__.__name__ + get_clean_name(basic_info.productName) or device_type.__name__ if device_type else None ) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 5af01f2eea5..707f7e70ee3 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.4.1"] + "requirements": ["python-matter-server==3.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1056712879a..a74ec5e81ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2106,7 +2106,7 @@ python-kasa==0.5.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.4.1 +python-matter-server==3.5.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c0faae7555..962a30bb1f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1544,7 +1544,7 @@ python-juicenet==1.1.0 python-kasa==0.5.1 # homeassistant.components.matter -python-matter-server==3.4.1 +python-matter-server==3.5.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From ef2669afe4fea64a116b154c87d01317664473c8 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Thu, 22 Jun 2023 00:17:13 +0200 Subject: [PATCH 432/857] Bump bimmer_connected to 0.13.7 (#95017) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index a719cbdf3d0..d30198bdc12 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.13.6"] + "requirements": ["bimmer-connected==0.13.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index a74ec5e81ed..aa23c771068 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -503,7 +503,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.13.6 +bimmer-connected==0.13.7 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 962a30bb1f2..b9d87d072de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -424,7 +424,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.13.6 +bimmer-connected==0.13.7 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 From 65a5244d5a642f993cb6da11dea92eb68b13cce3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 01:19:47 +0200 Subject: [PATCH 433/857] Fix race and add test coverage for esphome select platform (#95019) --- .coveragerc | 1 - homeassistant/components/esphome/__init__.py | 15 ++- homeassistant/components/esphome/select.py | 5 +- tests/components/esphome/conftest.py | 130 +++++++++++++------ tests/components/esphome/test_select.py | 44 +++++++ 5 files changed, 148 insertions(+), 47 deletions(-) diff --git a/.coveragerc b/.coveragerc index 944b2a5f838..17af3760817 100644 --- a/.coveragerc +++ b/.coveragerc @@ -319,7 +319,6 @@ omit = homeassistant/components/esphome/lock.py homeassistant/components/esphome/media_player.py homeassistant/components/esphome/number.py - homeassistant/components/esphome/select.py homeassistant/components/esphome/sensor.py homeassistant/components/esphome/switch.py homeassistant/components/etherscan/sensor.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index bfd023a9980..c91f63787f7 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -845,6 +845,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._on_static_info_update, ) ) + self._update_state_from_entry_data() @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: @@ -868,11 +869,9 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_icon = None @callback - def _on_state_update(self) -> None: - """Call when state changed. + def _update_state_from_entry_data(self) -> None: + """Update state from entry data.""" - Behavior can be changed in child classes - """ state = self._entry_data.state key = self._key state_type = self._state_type @@ -880,6 +879,14 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): if has_state: self._state = cast(_StateT, state[state_type][key]) self._has_state = has_state + + @callback + def _on_state_update(self) -> None: + """Call when state changed. + + Behavior can be changed in child classes + """ + self._update_state_from_entry_data() self.async_write_ha_state() @callback diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index e4cac21dbc8..d7cecf07d9e 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -53,9 +53,8 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): @esphome_state_property def current_option(self) -> str | None: """Return the state of the entity.""" - if self._state.missing_state: - return None - return self._state.state + state = self._state + return None if state.missing_state else state.state async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 37ab3123919..e5e78ca3bf1 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -2,9 +2,19 @@ from __future__ import annotations from asyncio import Event +from collections.abc import Callable +from typing import Any from unittest.mock import AsyncMock, Mock, patch -from aioesphomeapi import APIClient, APIVersion, DeviceInfo, ReconnectLogic +from aioesphomeapi import ( + APIClient, + APIVersion, + DeviceInfo, + EntityInfo, + EntityState, + ReconnectLogic, + UserService, +) import pytest from zeroconf import Zeroconf @@ -82,7 +92,7 @@ async def init_integration( @pytest.fixture -def mock_client(mock_device_info): +def mock_client(mock_device_info) -> APIClient: """Mock APIClient.""" mock_client = Mock(spec=APIClient) @@ -132,49 +142,72 @@ async def mock_dashboard(hass): yield data +async def _mock_generic_device_entry( + hass: HomeAssistant, + mock_client: APIClient, + mock_device_info: dict[str, Any], + mock_list_entities_services: tuple[list[EntityInfo], list[UserService]], + states: list[EntityState], +) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + ) + entry.add_to_hass(hass) + + device_info = DeviceInfo( + name="test", + friendly_name="Test", + mac_address="11:22:33:44:55:aa", + esphome_version="1.0.0", + **mock_device_info, + ) + + async def _subscribe_states(callback: Callable[[EntityState], None]) -> None: + """Subscribe to state.""" + for state in states: + callback(state) + + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) + mock_client.list_entities_services = AsyncMock( + return_value=mock_list_entities_services + ) + mock_client.subscribe_states = _subscribe_states + + try_connect_done = Event() + real_try_connect = ReconnectLogic._try_connect + + async def mock_try_connect(self): + """Set an event when ReconnectLogic._try_connect has been awaited.""" + result = await real_try_connect(self) + try_connect_done.set() + return result + + with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): + await hass.config_entries.async_setup(entry.entry_id) + await try_connect_done.wait() + + await hass.async_block_till_done() + + return entry + + @pytest.fixture async def mock_voice_assistant_entry( hass: HomeAssistant, - mock_client, -) -> MockConfigEntry: + mock_client: APIClient, +): """Set up an ESPHome entry with voice assistant.""" - async def _mock_voice_assistant_entry(version: int): - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "test.local", - CONF_PORT: 6053, - CONF_PASSWORD: "", - }, + async def _mock_voice_assistant_entry(version: int) -> MockConfigEntry: + return await _mock_generic_device_entry( + hass, mock_client, {"voice_assistant_version": version}, ([], []), [] ) - entry.add_to_hass(hass) - - device_info = DeviceInfo( - name="test", - friendly_name="Test", - voice_assistant_version=version, - mac_address="11:22:33:44:55:aa", - esphome_version="1.0.0", - ) - - mock_client.device_info = AsyncMock(return_value=device_info) - mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - - try_connect_done = Event() - real_try_connect = ReconnectLogic._try_connect - - async def mock_try_connect(self): - """Set an event when ReconnectLogic._try_connect has been awaited.""" - result = await real_try_connect(self) - try_connect_done.set() - return result - - with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): - await hass.config_entries.async_setup(entry.entry_id) - await try_connect_done.wait() - - return entry return _mock_voice_assistant_entry @@ -189,3 +222,22 @@ async def mock_voice_assistant_v1_entry(mock_voice_assistant_entry) -> MockConfi async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfigEntry: """Set up an ESPHome entry with voice assistant.""" return await mock_voice_assistant_entry(version=2) + + +@pytest.fixture +async def mock_generic_device_entry( + hass: HomeAssistant, +) -> MockConfigEntry: + """Set up an ESPHome entry.""" + + async def _mock_device_entry( + mock_client: APIClient, + entity_info: list[EntityInfo], + user_service: list[UserService], + states: list[EntityState], + ) -> MockConfigEntry: + return await _mock_generic_device_entry( + hass, mock_client, {}, (entity_info, user_service), states + ) + + return _mock_device_entry diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index dec321ced86..5f6974ec035 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -1,6 +1,16 @@ """Test ESPHome selects.""" +from unittest.mock import call + +from aioesphomeapi import APIClient, SelectInfo, SelectState + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -13,3 +23,37 @@ async def test_pipeline_selector( state = hass.states.get("select.test_assist_pipeline") assert state is not None assert state.state == "preferred" + + +async def test_select_generic_entity( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic select entity.""" + entity_info = [ + SelectInfo( + object_id="myselect", + key=1, + name="my select", + unique_id="my_select", + options=["a", "b"], + ) + ] + states = [SelectState(key=1, state="a")] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("select.test_my_select") + assert state is not None + assert state.state == "a" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_my_select", ATTR_OPTION: "b"}, + blocking=True, + ) + mock_client.select_command.assert_has_calls([call(1, "b")]) From 90f5b1c323102840b6dc553ae5d59cfc70715132 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 22 Jun 2023 03:33:23 +0200 Subject: [PATCH 434/857] Add TypeVar defaults for DataUpdateCoordinator and EntityComponent (#95026) --- .../bluetooth/passive_update_coordinator.py | 5 ++++- homeassistant/helpers/collection.py | 6 ++++-- homeassistant/helpers/entity_component.py | 5 +++-- homeassistant/helpers/update_coordinator.py | 19 +++++++++++-------- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 6f1749aeef2..e2a2f7aef24 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -1,7 +1,9 @@ """Passive update coordinator for the Bluetooth integration.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any + +from typing_extensions import TypeVar from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import ( @@ -20,6 +22,7 @@ if TYPE_CHECKING: _PassiveBluetoothDataUpdateCoordinatorT = TypeVar( "_PassiveBluetoothDataUpdateCoordinatorT", bound="PassiveBluetoothDataUpdateCoordinator", + default="PassiveBluetoothDataUpdateCoordinator", ) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index a0c47a04903..80b40cf4fa0 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -8,8 +8,9 @@ from dataclasses import dataclass from itertools import groupby import logging from operator import attrgetter -from typing import Any, Generic, TypedDict, TypeVar +from typing import Any, Generic, TypedDict +from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -35,6 +36,7 @@ CHANGE_REMOVED = "removed" _ItemT = TypeVar("_ItemT") _StoreT = TypeVar("_StoreT", bound="SerializedStorageCollection") _StorageCollectionT = TypeVar("_StorageCollectionT", bound="StorageCollection") +_EntityT = TypeVar("_EntityT", bound=Entity, default=Entity) @dataclass(slots=True) @@ -421,7 +423,7 @@ def sync_entity_lifecycle( hass: HomeAssistant, domain: str, platform: str, - entity_component: EntityComponent, + entity_component: EntityComponent[_EntityT], collection: StorageCollection | YamlCollection, entity_class: type[CollectionEntity], ) -> None: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index dc101a10b05..2e8e23fcee9 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -7,8 +7,9 @@ from datetime import timedelta from itertools import chain import logging from types import ModuleType -from typing import Any, Generic, TypeVar +from typing import Any, Generic +from typing_extensions import TypeVar import voluptuous as vol from homeassistant import config as conf_util @@ -30,7 +31,7 @@ from .typing import ConfigType, DiscoveryInfoType DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) DATA_INSTANCES = "entity_components" -_EntityT = TypeVar("_EntityT", bound=entity.Entity) +_EntityT = TypeVar("_EntityT", bound=entity.Entity, default=entity.Entity) @bind_hass diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 5706e34dc9c..911b714cae1 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -8,11 +8,12 @@ from datetime import datetime, timedelta import logging from random import randint from time import monotonic -from typing import Any, Generic, Protocol, TypeVar +from typing import Any, Generic, Protocol import urllib.error import aiohttp import requests +from typing_extensions import TypeVar from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -30,12 +31,14 @@ from .debounce import Debouncer REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True -_T = TypeVar("_T") +_DataT = TypeVar("_DataT", default=dict[str, Any]) _BaseDataUpdateCoordinatorT = TypeVar( "_BaseDataUpdateCoordinatorT", bound="BaseDataUpdateCoordinatorProtocol" ) _DataUpdateCoordinatorT = TypeVar( - "_DataUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" + "_DataUpdateCoordinatorT", + bound="DataUpdateCoordinator[Any]", + default="DataUpdateCoordinator[dict[str, Any]]", ) @@ -53,7 +56,7 @@ class BaseDataUpdateCoordinatorProtocol(Protocol): """Listen for data updates.""" -class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): +class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): """Class to manage fetching data from single endpoint.""" def __init__( @@ -63,7 +66,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): *, name: str, update_interval: timedelta | None = None, - update_method: Callable[[], Awaitable[_T]] | None = None, + update_method: Callable[[], Awaitable[_DataT]] | None = None, request_refresh_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, ) -> None: """Initialize global data updater.""" @@ -80,7 +83,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): # to make sure the first update was successful. # Set type to just T to remove annoying checks that data is not None # when it was already checked during setup. - self.data: _T = None # type: ignore[assignment] + self.data: _DataT = None # type: ignore[assignment] # Pick a random microsecond to stagger the refreshes # and avoid a thundering herd. @@ -235,7 +238,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): """ await self._debounced_refresh.async_call() - async def _async_update_data(self) -> _T: + async def _async_update_data(self) -> _DataT: """Fetch the latest data from the source.""" if self.update_method is None: raise NotImplementedError("Update method not implemented") @@ -383,7 +386,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): self.async_update_listeners() @callback - def async_set_updated_data(self, data: _T) -> None: + def async_set_updated_data(self, data: _DataT) -> None: """Manually update data, notify listeners and reset refresh interval.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() From e204e80528f35d7cf6efe06520d5a572b9c3fbeb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 22 Jun 2023 08:20:59 +0200 Subject: [PATCH 435/857] Teach vacuum device trigger about entity registry ids (#94989) --- .../components/vacuum/device_trigger.py | 4 +- .../components/vacuum/test_device_trigger.py | 126 +++++++++++++++--- 2 files changed, 108 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 6a2646922b6..95c1938ccfa 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -24,7 +24,7 @@ TRIGGER_TYPES = {"cleaning", "docked"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -48,7 +48,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, } for trigger in TRIGGER_TYPES diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 2ab0c80af14..2f27d299d7e 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -46,7 +46,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -55,7 +55,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["cleaning", "docked"] @@ -89,7 +89,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["cleaning", "docked"] @@ -145,9 +145,45 @@ async def test_get_trigger_capabilities( } -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a vacuum device.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 2 + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + + +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" - hass.states.async_set("vacuum.entity", STATE_DOCKED) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_DOCKED) assert await async_setup_component( hass, @@ -159,7 +195,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "vacuum.entity", + "entity_id": entry.id, "type": "cleaning", }, "action": { @@ -178,7 +214,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "vacuum.entity", + "entity_id": entry.id, "type": "docked", }, "action": { @@ -197,26 +233,31 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Fake that the entity is cleaning - hass.states.async_set("vacuum.entity", STATE_CLEANING) + hass.states.async_set(entry.entity_id, STATE_CLEANING) await hass.async_block_till_done() assert len(calls) == 1 assert ( - calls[0].data["some"] == "cleaning - device - vacuum.entity - docked - cleaning" + calls[0].data["some"] + == f"cleaning - device - {entry.entity_id} - docked - cleaning" ) # Fake that the entity is docked - hass.states.async_set("vacuum.entity", STATE_DOCKED) + hass.states.async_set(entry.entity_id, STATE_DOCKED) await hass.async_block_till_done() assert len(calls) == 2 assert ( - calls[1].data["some"] == "docked - device - vacuum.entity - cleaning - docked" + calls[1].data["some"] + == f"docked - device - {entry.entity_id} - cleaning - docked" ) -async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> None: - """Test for triggers firing with delay.""" - entity_id = f"{DOMAIN}.entity" - hass.states.async_set(entity_id, STATE_DOCKED) +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_DOCKED) assert await async_setup_component( hass, @@ -228,7 +269,53 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.entity_id, + "type": "cleaning", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "cleaning - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}}" + ) + }, + }, + }, + ] + }, + ) + + # Fake that the entity is cleaning + hass.states.async_set(entry.entity_id, STATE_CLEANING) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"cleaning - device - {entry.entity_id} - docked - cleaning" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_DOCKED) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "cleaning", "for": {"seconds": 5}, }, @@ -252,10 +339,9 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> }, ) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_DOCKED assert len(calls) == 0 - hass.states.async_set(entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, STATE_CLEANING) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -264,5 +350,5 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> await hass.async_block_till_done() assert ( calls[0].data["some"] - == f"turn_off device - {entity_id} - docked - cleaning - 0:00:05" + == f"turn_off device - {entry.entity_id} - docked - cleaning - 0:00:05" ) From b70040018370bec6d40d9a221d5c8304e5b0c13d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 09:05:57 +0200 Subject: [PATCH 436/857] Migrate esphome select platform to use _on_static_info_update (#95022) --- homeassistant/components/esphome/select.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index d7cecf07d9e..1323c9f5666 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -1,12 +1,12 @@ """Support for esphome selects.""" from __future__ import annotations -from aioesphomeapi import SelectInfo, SelectState +from aioesphomeapi import EntityInfo, SelectInfo, SelectState from homeassistant.components.assist_pipeline.select import AssistPipelineSelect from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( @@ -44,10 +44,11 @@ async def async_setup_entry( class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): """A select implementation for esphome.""" - @property - def options(self) -> list[str]: - """Return a set of selectable options.""" - return self._static_info.options + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + self._attr_options = self._static_info.options @property @esphome_state_property @@ -58,7 +59,7 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._client.select_command(self._static_info.key, option) + await self._client.select_command(self._key, option) class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): From 27da7d68de5c29eabc113f74b1b0d0a44e13bfb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 09:07:13 +0200 Subject: [PATCH 437/857] Migrate esphome fan platform to use _on_static_info_update (#95031) --- homeassistant/components/esphome/fan.py | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 01060630964..092bdc11811 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -4,7 +4,7 @@ from __future__ import annotations import math from typing import Any -from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState +from aioesphomeapi import EntityInfo, FanDirection, FanInfo, FanSpeed, FanState from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -13,7 +13,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, @@ -68,7 +68,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): await self.async_turn_off() return - data: dict[str, Any] = {"key": self._static_info.key, "state": True} + data: dict[str, Any] = {"key": self._key, "state": True} if percentage is not None: if self._supports_speed_levels: data["speed_level"] = math.ceil( @@ -94,18 +94,16 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self._client.fan_command(key=self._static_info.key, state=False) + await self._client.fan_command(key=self._key, state=False) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - await self._client.fan_command( - key=self._static_info.key, oscillating=oscillating - ) + await self._client.fan_command(key=self._key, oscillating=oscillating) async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" await self._client.fan_command( - key=self._static_info.key, direction=_FAN_DIRECTIONS.from_hass(direction) + key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) @property @@ -153,14 +151,16 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): return None return _FAN_DIRECTIONS.from_esphome(self._state.direction) - @property - def supported_features(self) -> FanEntityFeature: - """Flag supported features.""" + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info flags = FanEntityFeature(0) - if self._static_info.supports_oscillation: + if static_info.supports_oscillation: flags |= FanEntityFeature.OSCILLATE - if self._static_info.supports_speed: + if static_info.supports_speed: flags |= FanEntityFeature.SET_SPEED - if self._static_info.supports_direction: + if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION - return flags + self._attr_supported_features = flags From 3b24a943ab4be15b9ee83ac055c3e735ceb9825b Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Thu, 22 Jun 2023 09:35:53 +0200 Subject: [PATCH 438/857] Fix goodwe midnight error (#95041) --- homeassistant/components/goodwe/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index d76d6202832..4a4296bc526 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -243,7 +243,7 @@ class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity): In contrast to "total" sensors, these "daily" sensors need to be reset to 0 on midnight. """ if not self.coordinator.last_update_success: - self.coordinator.reset_sensor(self._sensor.id) + self.coordinator.reset_sensor(self._sensor.id_) self.async_write_ha_state() _LOGGER.debug("Goodwe reset %s to 0", self.name) next_midnight = dt_util.start_of_local_day( From 05c25d234937f9fb77afc4f3e8c8bde177cfd87f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jun 2023 09:38:32 +0200 Subject: [PATCH 439/857] Bump Wandalen/wretry.action from 1.0.36 to 1.2.0 (#95035) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a60c6807af8..cb2a3321d02 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1013,7 +1013,7 @@ jobs: uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: Wandalen/wretry.action@v1.0.36 + uses: Wandalen/wretry.action@v1.2.0 with: action: codecov/codecov-action@v3.1.3 with: | @@ -1023,7 +1023,7 @@ jobs: attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: Wandalen/wretry.action@v1.0.36 + uses: Wandalen/wretry.action@v1.2.0 with: action: codecov/codecov-action@v3.1.3 with: | From adc2df6b8ebb6056a4353577ed065941f47dfab4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 09:39:48 +0200 Subject: [PATCH 440/857] Callback esphome EntityInfo by platform instead of all platforms (#95021) --- homeassistant/components/esphome/__init__.py | 8 +---- .../components/esphome/entry_data.py | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index c91f63787f7..0c962d82074 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -703,10 +703,6 @@ async def platform_async_setup_entry( new_infos: dict[int, EntityInfo] = {} add_entities: list[_EntityT] = [] for info in infos: - if not isinstance(info, info_type): - # Filter out infos that don't belong to this platform. - continue - if info.key in old_infos: # Update existing entity old_infos.pop(info.key) @@ -737,9 +733,7 @@ async def platform_async_setup_entry( async_add_entities(add_entities) entry_data.cleanup_callbacks.append( - async_dispatcher_connect( - hass, entry_data.signal_static_info_updated, async_list_entities - ) + entry_data.async_register_static_info_callback(info_type, async_list_entities) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 4b4b359e15b..41c5687e661 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,7 +35,7 @@ from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store @@ -106,6 +106,9 @@ class RuntimeEntryData: default_factory=list ) assist_pipeline_state: bool = False + entity_info_callbacks: dict[ + type[EntityInfo], list[Callable[[list[EntityInfo]], None]] + ] = field(default_factory=dict) @property def name(self) -> str: @@ -135,6 +138,21 @@ class RuntimeEntryData: """Return the signal to listen to for updates on static info for a specific component_key and key.""" return f"esphome_{self.entry_id}_static_info_updated_{component_key}_{key}" + @callback + def async_register_static_info_callback( + self, + entity_info_type: type[EntityInfo], + callback_: Callable[[list[EntityInfo]], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when static info changes for an EntityInfo type.""" + callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) + callbacks.append(callback_) + + def _unsub() -> None: + callbacks.remove(callback_) + + return _unsub + @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: """Update the BLE connection limits.""" @@ -222,6 +240,21 @@ class RuntimeEntryData: break await self._ensure_platforms_loaded(hass, entry, needed_platforms) + # Make a dict of the EntityInfo by type and send + # them to the listeners for each specific EntityInfo type + infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {} + for info in infos: + info_type = type(info) + if info_type not in infos_by_type: + infos_by_type[info_type] = [] + infos_by_type[info_type].append(info) + + callbacks_by_type = self.entity_info_callbacks + for type_, entity_infos in infos_by_type.items(): + if callbacks_ := callbacks_by_type.get(type_): + for callback_ in callbacks_: + callback_(entity_infos) + # Then send dispatcher event async_dispatcher_send(hass, self.signal_static_info_updated, infos) From 69c2ac1facd240d2c237a1f87c23b17ff81186ac Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 22 Jun 2023 09:46:03 +0200 Subject: [PATCH 441/857] Update requests_mock to 1.11.0 (#94298) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b0138a2c502..203fdb64e85 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.2.1 pytest==7.3.1 -requests_mock==1.10.0 +requests_mock==1.11.0 respx==0.20.1 syrupy==4.0.2 tomli==2.0.1;python_version<"3.11" From 8f6cde5b32914939b776e55c945ceba5061fcdd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 11:07:51 +0200 Subject: [PATCH 442/857] Migrate esphome lock platform to use _on_static_info_update (#95030) --- homeassistant/components/esphome/lock.py | 41 +++++++++++------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 947ea4729bb..d13d2d333dc 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -3,12 +3,12 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import LockCommand, LockEntityState, LockInfo, LockState +from aioesphomeapi import EntityInfo, LockCommand, LockEntityState, LockInfo, LockState from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry @@ -32,24 +32,19 @@ async def async_setup_entry( class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): """A lock implementation for ESPHome.""" - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return self._static_info.assumed_state - - @property - def supported_features(self) -> LockEntityFeature: - """Flag supported features.""" - if self._static_info.supports_open: - return LockEntityFeature.OPEN - return LockEntityFeature(0) - - @property - def code_format(self) -> str | None: - """Regex for code format or None if no code is required.""" - if self._static_info.requires_code: - return self._static_info.code_format - return None + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_assumed_state = static_info.assumed_state + self._attr_supported_features = LockEntityFeature(0) + if static_info.supports_open: + self._attr_supported_features |= LockEntityFeature.OPEN + if static_info.requires_code: + self._attr_code_format = static_info.code_format + else: + self._attr_code_format = None @property @esphome_state_property @@ -77,13 +72,13 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._client.lock_command(self._static_info.key, LockCommand.LOCK) + await self._client.lock_command(self._key, LockCommand.LOCK) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" code = kwargs.get(ATTR_CODE, None) - await self._client.lock_command(self._static_info.key, LockCommand.UNLOCK, code) + await self._client.lock_command(self._key, LockCommand.UNLOCK, code) async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - await self._client.lock_command(self._static_info.key, LockCommand.OPEN) + await self._client.lock_command(self._key, LockCommand.OPEN) From 3863c561a64cadbc5aa419fe231e1589c03cf192 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 22 Jun 2023 11:13:14 +0200 Subject: [PATCH 443/857] Fix removal of orphaned Matter devices (#95044) --- homeassistant/components/matter/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 4c47cd4d235..59c5ec9efc8 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio +from contextlib import suppress import async_timeout from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion -from matter_server.common.errors import MatterError, NodeCommissionFailed +from matter_server.common.errors import MatterError, NodeCommissionFailed, NodeNotExists import voluptuous as vol from homeassistant.components.hassio import AddonError, AddonManager, AddonState @@ -207,7 +208,9 @@ async def async_remove_config_entry_device( ) matter = get_matter(hass) - await matter.matter_client.remove_node(node.node_id) + with suppress(NodeNotExists): + # ignore if the server has already removed the node. + await matter.matter_client.remove_node(node.node_id) return True From 5884afd485babd7fb7e6223ce1974bd8941f6749 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 22 Jun 2023 11:13:54 +0200 Subject: [PATCH 444/857] Teach sensor device trigger about entity registry ids (#94988) --- .../components/sensor/device_trigger.py | 13 +- .../components/deconz/test_device_trigger.py | 9 +- .../homekit_controller/test_device_trigger.py | 6 +- .../components/hue/test_device_trigger_v1.py | 10 +- .../components/hue/test_device_trigger_v2.py | 12 +- .../components/sensor/test_device_trigger.py | 272 +++++++++++++----- 6 files changed, 238 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 0d594e1b7c3..1bb41eb2d30 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -1,7 +1,10 @@ """Provides device triggers for sensors.""" import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, + async_get_entity_registry_entry_or_raise, +) from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -136,7 +139,7 @@ ENTITY_TRIGGERS = { TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ CONF_APPARENT_POWER, @@ -251,7 +254,7 @@ async def async_get_triggers( **automation, "platform": "device", "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": DOMAIN, } for automation in templates @@ -264,8 +267,10 @@ async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List trigger capabilities.""" + try: - unit_of_measurement = get_unit_of_measurement(hass, config[CONF_ENTITY_ID]) + entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) + unit_of_measurement = get_unit_of_measurement(hass, entry.entity_id) except HomeAssistantError: unit_of_measurement = None diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index f5ed20975e4..b2bff759085 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -84,6 +84,10 @@ async def test_get_triggers( device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) + entity_registry = er.async_get(hass) + battery_sensor_entry = entity_registry.async_get( + "sensor.tradfri_on_off_switch_battery" + ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id @@ -141,7 +145,7 @@ async def test_get_triggers( { CONF_DEVICE_ID: device.id, CONF_DOMAIN: SENSOR_DOMAIN, - ATTR_ENTITY_ID: "sensor.tradfri_on_off_switch_battery", + ATTR_ENTITY_ID: battery_sensor_entry.id, CONF_PLATFORM: "device", CONF_TYPE: ATTR_BATTERY_LEVEL, "metadata": {"secondary": True}, @@ -193,6 +197,7 @@ async def test_get_triggers_for_alarm_event( identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:00")} ) entity_registry = er.async_get(hass) + bat_entity = entity_registry.async_get("sensor.keypad_battery") low_bat_entity = entity_registry.async_get("binary_sensor.keypad_low_battery") tamper_entity = entity_registry.async_get("binary_sensor.keypad_tampered") @@ -236,7 +241,7 @@ async def test_get_triggers_for_alarm_event( { CONF_DEVICE_ID: device.id, CONF_DOMAIN: SENSOR_DOMAIN, - ATTR_ENTITY_ID: "sensor.keypad_battery", + ATTR_ENTITY_ID: bat_entity.id, CONF_PLATFORM: "device", CONF_TYPE: ATTR_BATTERY_LEVEL, "metadata": {"secondary": True}, diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index e38952785df..757823aba9b 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -101,7 +101,7 @@ async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: { "device_id": device.id, "domain": "sensor", - "entity_id": bat_sensor.entity_id, + "entity_id": bat_sensor.id, "platform": "device", "type": "battery_level", "metadata": {"secondary": True}, @@ -150,7 +150,7 @@ async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: { "device_id": device.id, "domain": "sensor", - "entity_id": bat_sensor.entity_id, + "entity_id": bat_sensor.id, "platform": "device", "type": "battery_level", "metadata": {"secondary": True}, @@ -198,7 +198,7 @@ async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: { "device_id": device.id, "domain": "sensor", - "entity_id": bat_sensor.entity_id, + "entity_id": bat_sensor.id, "platform": "device", "type": "battery_level", "metadata": {"secondary": True}, diff --git a/tests/components/hue/test_device_trigger_v1.py b/tests/components/hue/test_device_trigger_v1.py index 283215be328..aea91c06e88 100644 --- a/tests/components/hue/test_device_trigger_v1.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -5,6 +5,7 @@ from homeassistant.components import automation, hue from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.hue.v1 import device_trigger from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .conftest import setup_platform @@ -15,7 +16,9 @@ from tests.common import async_get_device_automations REMOTES_RESPONSE = {"7": HUE_TAP_REMOTE_1, "8": HUE_DIMMER_REMOTE_1} -async def test_get_triggers(hass: HomeAssistant, mock_bridge_v1, device_reg) -> None: +async def test_get_triggers( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge_v1, device_reg +) -> None: """Test we get the expected triggers from a hue remote.""" mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) await setup_platform(hass, mock_bridge_v1, ["sensor", "binary_sensor"]) @@ -49,6 +52,9 @@ async def test_get_triggers(hass: HomeAssistant, mock_bridge_v1, device_reg) -> hue_dimmer_device = device_reg.async_get_device( {(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")} ) + hue_bat_sensor = entity_registry.async_get( + "sensor.hue_dimmer_switch_1_battery_level" + ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, hue_dimmer_device.id ) @@ -58,7 +64,7 @@ async def test_get_triggers(hass: HomeAssistant, mock_bridge_v1, device_reg) -> "domain": "sensor", "device_id": hue_dimmer_device.id, "type": "battery_level", - "entity_id": "sensor.hue_dimmer_switch_1_battery_level", + "entity_id": hue_bat_sensor.id, "metadata": {"secondary": True}, } expected_triggers = [ diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index a15324a5c8f..26c323617d2 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -7,6 +7,7 @@ from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.hue.v2.device import async_setup_devices from homeassistant.components.hue.v2.hue_event import async_setup_hue_events from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import setup_platform @@ -47,7 +48,11 @@ async def test_hue_event( async def test_get_triggers( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data, device_reg + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, + device_reg, ) -> None: """Test we get the expected triggers from a hue remote.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -57,6 +62,9 @@ async def test_get_triggers( hue_wall_switch_device = device_reg.async_get_device( {(hue.DOMAIN, "3ff06175-29e8-44a8-8fe7-af591b0025da")} ) + hue_bat_sensor = entity_registry.async_get( + "sensor.wall_switch_with_2_controls_battery" + ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, hue_wall_switch_device.id ) @@ -66,7 +74,7 @@ async def test_get_triggers( "domain": "sensor", "device_id": hue_wall_switch_device.id, "type": "battery_level", - "entity_id": "sensor.wall_switch_with_2_controls_battery", + "entity_id": hue_bat_sensor.id, "metadata": {"secondary": True}, } diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index d2d3da7e8ff..7045d71fb78 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -93,6 +93,7 @@ async def test_get_triggers( platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + sensor_entries: dict[SensorDeviceClass, er.RegistryEntry] = {} config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -101,7 +102,7 @@ async def test_get_triggers( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) for device_class in SensorDeviceClass: - entity_registry.async_get_or_create( + sensor_entries[device_class] = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES[device_class].unique_id, @@ -114,7 +115,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger["type"], "device_id": device_entry.id, - "entity_id": platform.ENTITIES[device_class].entity_id, + "entity_id": sensor_entries[device_class].id, "metadata": {"secondary": False}, } for device_class in SensorDeviceClass @@ -152,7 +153,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -167,7 +168,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["value"] @@ -203,7 +204,7 @@ async def test_get_triggers_no_unit_or_stateclass( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -217,7 +218,7 @@ async def test_get_triggers_no_unit_or_stateclass( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in trigger_types @@ -298,8 +299,22 @@ async def test_get_trigger_capabilities( assert capabilities == expected_capabilities -async def test_get_trigger_capabilities_none( - hass: HomeAssistant, enable_custom_integrations: None +@pytest.mark.parametrize( + ("set_state", "device_class_reg", "device_class_state", "unit_reg", "unit_state"), + [ + (False, SensorDeviceClass.BATTERY, None, PERCENTAGE, None), + (True, None, SensorDeviceClass.BATTERY, None, PERCENTAGE), + ], +) +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + set_state, + device_class_reg, + device_class_state, + unit_reg, + unit_state, ) -> None: """Test we get the expected capabilities from a sensor trigger.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -307,6 +322,71 @@ async def test_get_trigger_capabilities_none( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_id = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, + original_device_class=device_class_reg, + unit_of_measurement=unit_reg, + ).entity_id + if set_state: + hass.states.async_set( + entity_id, + None, + {"device_class": device_class_state, "unit_of_measurement": unit_state}, + ) + + expected_capabilities = { + "extra_fields": [ + { + "description": {"suffix": PERCENTAGE}, + "name": "above", + "optional": True, + "type": "float", + }, + { + "description": {"suffix": PERCENTAGE}, + "name": "below", + "optional": True, + "type": "float", + }, + {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 1 + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + +async def test_get_trigger_capabilities_none( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test we get the expected capabilities from a sensor trigger.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + + entry_none = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["none"].unique_id, + ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() @@ -316,14 +396,14 @@ async def test_get_trigger_capabilities_none( "platform": "device", "device_id": "8770c43885354d5fa27604db6817f63f", "domain": "sensor", - "entity_id": "sensor.beer", + "entity_id": "01234567890123456789012345678901", "type": "is_battery_level", }, { "platform": "device", "device_id": "8770c43885354d5fa27604db6817f63f", "domain": "sensor", - "entity_id": platform.ENTITIES["none"].entity_id, + "entity_id": entry_none.id, "type": "is_battery_level", }, ] @@ -338,17 +418,13 @@ async def test_get_trigger_capabilities_none( async def test_if_fires_not_on_above_below( hass: HomeAssistant, + entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - sensor1 = platform.ENTITIES["battery"] + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -360,7 +436,7 @@ async def test_if_fires_not_on_above_below( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "battery_level", }, "action": {"service": "test.automation"}, @@ -372,15 +448,15 @@ async def test_if_fires_not_on_above_below( async def test_if_fires_on_state_above( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - sensor1 = platform.ENTITIES["battery"] + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) assert await async_setup_component( hass, @@ -392,7 +468,7 @@ async def test_if_fires_on_state_above( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "battery_level", "above": 10, }, @@ -416,31 +492,30 @@ async def test_if_fires_on_state_above( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 9) + hass.states.async_set(entry.entity_id, 9) await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 11) + hass.states.async_set(entry.entity_id, 11) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data["some"] == "bat_low device - {} - 9 - 11 - None".format( - sensor1.entity_id + assert ( + calls[0].data["some"] == f"bat_low device - {entry.entity_id} - 9 - 11 - None" ) async def test_if_fires_on_state_below( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - sensor1 = platform.ENTITIES["battery"] + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) assert await async_setup_component( hass, @@ -452,7 +527,7 @@ async def test_if_fires_on_state_below( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "battery_level", "below": 10, }, @@ -476,31 +551,30 @@ async def test_if_fires_on_state_below( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 11) + hass.states.async_set(entry.entity_id, 11) await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 9) + hass.states.async_set(entry.entity_id, 9) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data["some"] == "bat_low device - {} - 11 - 9 - None".format( - sensor1.entity_id + assert ( + calls[0].data["some"] == f"bat_low device - {entry.entity_id} - 11 - 9 - None" ) async def test_if_fires_on_state_between( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - sensor1 = platform.ENTITIES["battery"] + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) assert await async_setup_component( hass, @@ -512,7 +586,7 @@ async def test_if_fires_on_state_between( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "battery_level", "above": 10, "below": 20, @@ -537,43 +611,41 @@ async def test_if_fires_on_state_between( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 9) + hass.states.async_set(entry.entity_id, 9) await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 11) + hass.states.async_set(entry.entity_id, 11) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data["some"] == "bat_low device - {} - 9 - 11 - None".format( - sensor1.entity_id + assert ( + calls[0].data["some"] == f"bat_low device - {entry.entity_id} - 9 - 11 - None" ) - hass.states.async_set(sensor1.entity_id, 21) + hass.states.async_set(entry.entity_id, 21) await hass.async_block_till_done() assert len(calls) == 1 - hass.states.async_set(sensor1.entity_id, 19) + hass.states.async_set(entry.entity_id, 19) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data["some"] == "bat_low device - {} - 21 - 19 - None".format( - sensor1.entity_id + assert ( + calls[1].data["some"] == f"bat_low device - {entry.entity_id} - 21 - 19 - None" ) -async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None +async def test_if_fires_on_state_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: - """Test for triggers firing with delay.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + """Test for value triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - sensor1 = platform.ENTITIES["battery"] + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) assert await async_setup_component( hass, @@ -585,7 +657,66 @@ async def test_if_fires_on_state_change_with_for( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.entity_id, + "type": "battery_level", + "above": 10, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "bat_low {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + } + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, 9) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, 11) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] == f"bat_low device - {entry.entity_id} - 9 - 11 - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "battery_level", "above": 10, "for": {"seconds": 5}, @@ -610,11 +741,10 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 10) - hass.states.async_set(sensor1.entity_id, 11) + hass.states.async_set(entry.entity_id, 10) + hass.states.async_set(entry.entity_id, 11) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -623,5 +753,5 @@ async def test_if_fires_on_state_change_with_for( await hass.async_block_till_done() assert ( calls[0].data["some"] - == f"turn_off device - {sensor1.entity_id} - 10 - 11 - 0:00:05" + == f"turn_off device - {entry.entity_id} - 10 - 11 - 0:00:05" ) From cd5fdb97c0276ef0ebe33e71131ae3891ccce2d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 11:14:33 +0200 Subject: [PATCH 445/857] Small cleanups to esphome sensor and binary_sensor (#95042) --- homeassistant/components/esphome/binary_sensor.py | 9 +++------ homeassistant/components/esphome/sensor.py | 14 +++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 6d99349a461..7f1ccb6fc7d 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -51,9 +51,8 @@ class EsphomeBinarySensor( return self._entry_data.available if not self._has_state: return None - if self._state.missing_state: - return None - return self._state.state + state = self._state + return None if state.missing_state else state.state @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: @@ -66,9 +65,7 @@ class EsphomeBinarySensor( @property def available(self) -> bool: """Return True if entity is available.""" - if self._static_info.is_status_binary_sensor: - return True - return super().available + return self._static_info.is_status_binary_sensor or super().available class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity): diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 46b8111ddeb..47757247557 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -94,13 +94,14 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): @esphome_state_property def native_value(self) -> datetime | str | None: """Return the state of the entity.""" - if math.isnan(self._state.state): + state = self._state + if math.isnan(state.state): return None - if self._state.missing_state: + if state.missing_state: return None if self._attr_device_class == SensorDeviceClass.TIMESTAMP: - return dt_util.utc_from_timestamp(self._state.state) - return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" + return dt_util.utc_from_timestamp(state.state) + return f"{state.state:.{self._static_info.accuracy_decimals}f}" class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): @@ -110,6 +111,5 @@ class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEn @esphome_state_property def native_value(self) -> str | None: """Return the state of the entity.""" - if self._state.missing_state: - return None - return self._state.state + state = self._state + return None if state.missing_state else state.state From ed55632a66bdf742818353b79bb5b4aa5b6e1c74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 11:29:59 +0200 Subject: [PATCH 446/857] Add esphome fan platform tests and remove unreachable code (#95025) --- .coveragerc | 1 - homeassistant/components/esphome/fan.py | 4 - tests/components/esphome/test_fan.py | 318 ++++++++++++++++++++++++ 3 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 tests/components/esphome/test_fan.py diff --git a/.coveragerc b/.coveragerc index 17af3760817..9fdeefefbba 100644 --- a/.coveragerc +++ b/.coveragerc @@ -314,7 +314,6 @@ omit = homeassistant/components/esphome/cover.py homeassistant/components/esphome/domain_data.py homeassistant/components/esphome/entry_data.py - homeassistant/components/esphome/fan.py homeassistant/components/esphome/light.py homeassistant/components/esphome/lock.py homeassistant/components/esphome/media_player.py diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 092bdc11811..040e6585a4b 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -139,16 +139,12 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @esphome_state_property def oscillating(self) -> bool | None: """Return the oscillation state.""" - if not self._static_info.supports_oscillation: - return None return self._state.oscillating @property @esphome_state_property def current_direction(self) -> str | None: """Return the current fan direction.""" - if not self._static_info.supports_direction: - return None return _FAN_DIRECTIONS.from_esphome(self._state.direction) @callback diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py new file mode 100644 index 00000000000..4f8f3918a1b --- /dev/null +++ b/tests/components/esphome/test_fan.py @@ -0,0 +1,318 @@ +"""Test ESPHome fans.""" + + +from unittest.mock import call + +from aioesphomeapi import ( + APIClient, + APIVersion, + FanDirection, + FanInfo, + FanSpeed, + FanState, +) + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + DOMAIN as FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + + +async def test_fan_entity_with_all_features_old_api( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic fan entity that uses the old api and has all features.""" + entity_info = [ + FanInfo( + object_id="myfan", + key=1, + name="my fan", + unique_id="my_fan", + supports_direction=True, + supports_speed=True, + supports_oscillation=True, + ) + ] + states = [ + FanState( + key=1, + state=True, + oscillating=True, + speed=FanSpeed.MEDIUM, + direction=FanDirection.REVERSE, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("fan.test_my_fan") + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed=FanSpeed.LOW, state=True)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed=FanSpeed.MEDIUM, state=True)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed=FanSpeed.LOW, state=True)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed=FanSpeed.HIGH, state=True)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed=FanSpeed.HIGH, state=True)] + ) + mock_client.fan_command.reset_mock() + + +async def test_fan_entity_with_all_features_new_api( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic fan entity that uses the new api and has all features.""" + mock_client.api_version = APIVersion(1, 4) + entity_info = [ + FanInfo( + object_id="myfan", + key=1, + name="my fan", + unique_id="my_fan", + supported_speed_levels=4, + supports_direction=True, + supports_speed=True, + supports_oscillation=True, + ) + ] + states = [ + FanState( + key=1, + state=True, + oscillating=True, + speed_level=3, + direction=FanDirection.REVERSE, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("fan.test_my_fan") + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, speed_level=1, state=True)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 0}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: True}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, oscillating=True)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: False}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, oscillating=False)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "forward"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, direction=FanDirection.FORWARD)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "reverse"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, direction=FanDirection.REVERSE)] + ) + mock_client.fan_command.reset_mock() + + +async def test_fan_entity_with_no_features_new_api( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic fan entity that uses the new api and has no features.""" + mock_client.api_version = APIVersion(1, 4) + entity_info = [ + FanInfo( + object_id="myfan", + key=1, + name="my fan", + unique_id="my_fan", + supports_direction=False, + supports_speed=False, + supports_oscillation=False, + ) + ] + states = [FanState(key=1, state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("fan.test_my_fan") + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, state=True)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.reset_mock() From e2f5a707cea0b44adea1bfca555ddda790da865e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 22 Jun 2023 11:30:19 +0200 Subject: [PATCH 447/857] Fix use_device_name in case device device class translations are used (#95010) Co-authored-by: Erik --- homeassistant/helpers/entity.py | 4 +- tests/helpers/test_entity.py | 200 ++++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index b80e244cb8a..97b3485c893 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -355,14 +355,14 @@ class Entity(ABC): if hasattr(self, "entity_description"): if not (name := self.entity_description.name): return True - if name is UNDEFINED: + if name is UNDEFINED and not self._default_to_device_class_name(): # Backwards compatibility with leaving EntityDescription.name unassigned # for device name. # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 report_implicit_device_name() return True return False - if self.name is UNDEFINED: + if self.name is UNDEFINED and not self._default_to_device_class_name(): # Backwards compatibility with not overriding name property for device name. # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 report_implicit_device_name() diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 1168f4b40f8..1861dc54844 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1,8 +1,10 @@ """Test the entity helper.""" import asyncio +from collections.abc import Iterable import dataclasses from datetime import timedelta import threading +from typing import Any from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -953,8 +955,6 @@ async def _test_friendly_name( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ent: entity.Entity, - has_entity_name: bool, - entity_name: str | None, expected_friendly_name: str | None, warn_implicit_name: bool, ) -> None: @@ -1017,8 +1017,6 @@ async def test_friendly_name_attr( hass, caplog, ent, - has_entity_name, - entity_name, expected_friendly_name, warn_implicit_name, ) @@ -1060,13 +1058,78 @@ async def test_friendly_name_description( hass, caplog, ent, - has_entity_name, - entity_name, expected_friendly_name, warn_implicit_name, ) +@pytest.mark.parametrize( + ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ( + (False, "Entity Blu", "Entity Blu", False), + (False, None, None, False), + (False, UNDEFINED, None, False), + (True, "Entity Blu", "Device Bla Entity Blu", False), + (True, None, "Device Bla", False), + (True, UNDEFINED, "Device Bla English cls", False), + ), +) +async def test_friendly_name_description_device_class_name( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + has_entity_name: bool, + entity_name: str | None, + expected_friendly_name: str | None, + warn_implicit_name: bool, +) -> None: + """Test friendly name when the entity has an entity description.""" + + translations = { + "en": {"component.test_domain.entity_component.test_class.name": "English cls"}, + } + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + class DeviceClassNameMockEntity(MockEntity): + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class.""" + return True + + ent = DeviceClassNameMockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + ) + ent.entity_description = entity.EntityDescription( + "test", + device_class="test_class", + has_entity_name=has_entity_name, + name=entity_name, + ) + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + await _test_friendly_name( + hass, + caplog, + ent, + expected_friendly_name, + warn_implicit_name, + ) + + @pytest.mark.parametrize( ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), ( @@ -1102,13 +1165,134 @@ async def test_friendly_name_property( hass, caplog, ent, - has_entity_name, - entity_name, expected_friendly_name, warn_implicit_name, ) +@pytest.mark.parametrize( + ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ( + (False, "Entity Blu", "Entity Blu", False), + (False, None, None, False), + (False, UNDEFINED, None, False), + (True, "Entity Blu", "Device Bla Entity Blu", False), + (True, None, "Device Bla", False), + # Won't use the device class name because the entity overrides the name property + (True, UNDEFINED, "Device Bla None", False), + ), +) +async def test_friendly_name_property_device_class_name( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + has_entity_name: bool, + entity_name: str | None, + expected_friendly_name: str | None, + warn_implicit_name: bool, +) -> None: + """Test friendly name when the entity has overridden the name property.""" + + translations = { + "en": {"component.test_domain.entity_component.test_class.name": "English cls"}, + } + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + class DeviceClassNameMockEntity(MockEntity): + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class.""" + return True + + ent = DeviceClassNameMockEntity( + unique_id="qwer", + device_class="test_class", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ) + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + await _test_friendly_name( + hass, + caplog, + ent, + expected_friendly_name, + warn_implicit_name, + ) + + +@pytest.mark.parametrize( + ("has_entity_name", "expected_friendly_name", "warn_implicit_name"), + ( + (False, None, False), + (True, "Device Bla English cls", False), + ), +) +async def test_friendly_name_device_class_name( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + has_entity_name: bool, + expected_friendly_name: str | None, + warn_implicit_name: bool, +) -> None: + """Test friendly name when the entity has not set the name in any way.""" + + translations = { + "en": {"component.test_domain.entity_component.test_class.name": "English cls"}, + } + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + class DeviceClassNameMockEntity(MockEntity): + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class.""" + return True + + ent = DeviceClassNameMockEntity( + unique_id="qwer", + device_class="test_class", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + ) + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + await _test_friendly_name( + hass, + caplog, + ent, + expected_friendly_name, + warn_implicit_name, + ) + + @pytest.mark.parametrize( ( "entity_name", From 04dc85b754d67ae9f67601901a98165128364355 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 22 Jun 2023 11:56:44 +0200 Subject: [PATCH 448/857] Use device class for AirQ entities (#95037) --- homeassistant/components/airq/sensor.py | 15 +------- homeassistant/components/airq/strings.json | 45 ++-------------------- 2 files changed, 5 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index dca06be67af..9974307b4cd 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -109,7 +109,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="co2", - translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, @@ -196,7 +195,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity", - translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -233,7 +231,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="n2o", - translation_key="nitrous_oxide", device_class=SensorDeviceClass.NITROUS_OXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -241,7 +238,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="no_M250", - translation_key="nitrogen_monoxide", device_class=SensorDeviceClass.NITROGEN_MONOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -249,7 +245,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="no2", - translation_key="nitrogen_dioxide", device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -272,7 +267,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="o3", - translation_key="ozone", device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -295,7 +289,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pm1", - translation_key="pm1", device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -304,7 +297,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pm2_5", - translation_key="pm25", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -313,7 +305,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pm10", - translation_key="pm10", device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -322,7 +313,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pressure", - translation_key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, @@ -359,7 +349,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="so2", - translation_key="sulphur_dioxide", device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -391,7 +380,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="temperature", - translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -399,7 +387,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="tvoc", - translation_key="volatile_organic_compounds", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc"), @@ -407,6 +395,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="tvoc_ionsc", translation_key="industrial_volatile_organic_compounds", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 4216e4df60e..8628ede4116 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -42,15 +42,12 @@ "chlorine_dioxide": { "name": "Chlorine dioxide" }, - "carbon_monoxide": { - "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" - }, - "carbon_dioxide": { - "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" - }, "carbon_disulfide": { "name": "Carbon disulfide" }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, "dew_point": { "name": "Dew point" }, @@ -81,9 +78,6 @@ "health_index": { "name": "Health Index" }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "absolute_humidity": { "name": "Absolute humidity" }, @@ -96,42 +90,18 @@ "methane": { "name": "Methane" }, - "nitrous_oxide": { - "name": "[%key:component::sensor::entity_component::nitrous_oxide::name%]" - }, - "nitrogen_monoxide": { - "name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]" - }, - "nitrogen_dioxide": { - "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" - }, "organic_acid": { "name": "Organic acid" }, "oxygen": { "name": "Oxygen" }, - "ozone": { - "name": "[%key:component::sensor::entity_component::ozone::name%]" - }, "performance_index": { "name": "Performance Index" }, "hydrogen_phosphide": { "name": "Hydrogen Phosphide" }, - "pm1": { - "name": "[%key:component::sensor::entity_component::pm1::name%]" - }, - "pm25": { - "name": "[%key:component::sensor::entity_component::pm25::name%]" - }, - "pm10": { - "name": "[%key:component::sensor::entity_component::pm10::name%]" - }, - "pressure": { - "name": "[%key:component::sensor::entity_component::pressure::name%]" - }, "relative_pressure": { "name": "Relative pressure" }, @@ -144,9 +114,6 @@ "silicon_hydride": { "name": "Silicon Hydride" }, - "sulphur_dioxide": { - "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" - }, "noise": { "name": "Noise" }, @@ -156,12 +123,6 @@ "radon": { "name": "Radon" }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "volatile_organic_compounds": { - "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" - }, "industrial_volatile_organic_compounds": { "name": "VOCs (Industrial)" }, From 8987e023a0b7d93b167859ee40758ea3e15430b3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 22 Jun 2023 12:01:33 +0200 Subject: [PATCH 449/857] Add entity translations for Acmeda (#94170) --- homeassistant/components/acmeda/base.py | 6 +----- homeassistant/components/acmeda/cover.py | 4 +++- homeassistant/components/acmeda/sensor.py | 7 +------ 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 138587bbad3..2fc106f75f5 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -14,6 +14,7 @@ class AcmedaBase(entity.Entity): """Base representation of an Acmeda roller.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, roller: aiopulse.Roller) -> None: """Initialize the roller.""" @@ -72,11 +73,6 @@ class AcmedaBase(entity.Entity): """Return the ID of this roller.""" return self.roller.id - @property - def name(self) -> str | None: - """Return the name of roller.""" - return self.roller.name - @property def device_info(self) -> entity.DeviceInfo: """Return the device info.""" diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 15a20cf6932..2af985033b6 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -45,7 +45,9 @@ async def async_setup_entry( class AcmedaCover(AcmedaBase, CoverEntity): - """Representation of a Acmeda cover device.""" + """Representation of an Acmeda cover device.""" + + _attr_name = None @property def current_cover_position(self) -> int | None: diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index f92d9fcf57b..e8ccb30ada4 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -40,16 +40,11 @@ async def async_setup_entry( class AcmedaBattery(AcmedaBase, SensorEntity): - """Representation of a Acmeda cover device.""" + """Representation of an Acmeda cover sensor.""" _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE - @property - def name(self) -> str: - """Return the name of roller.""" - return f"{super().name} Battery" - @property def native_value(self) -> float | int | None: """Return the state of the device.""" From 3c86497bc80f8dff9193f2e523b78d038aafe127 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 22 Jun 2023 13:46:14 +0200 Subject: [PATCH 450/857] Improve mqtt climate turn_on and turn_off service (#94832) * Improve mqtt climate turn_on and turn_off service * Remove POWER_COMMAND_TOPIC when mode is changed * Call super --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/climate.py | 46 +++-- tests/components/mqtt/test_climate.py | 174 ++++++++++++++++-- 3 files changed, 195 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 5e44f9409b8..257e0fe95ae 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -159,6 +159,7 @@ ABBREVIATIONS = { "pos_clsd": "position_closed", "pos_open": "position_open", "pow_cmd_t": "power_command_topic", + "pow_cmd_tpl": "power_command_template", "pow_stat_t": "power_state_topic", "pow_stat_tpl": "power_state_template", "pr_mode_cmd_t": "preset_mode_command_topic", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 98fd344a30c..095b1d958a9 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -109,13 +109,15 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" -# CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE +# CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # was already removed or never added support was deprecated with release 2023.2 # and will be removed with release 2023.8 -CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_STATE_TEMPLATE = "power_state_template" CONF_POWER_STATE_TOPIC = "power_state_topic" + +CONF_POWER_COMMAND_TOPIC = "power_command_topic" +CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" @@ -183,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = { CONF_FAN_MODE_COMMAND_TEMPLATE, CONF_HUMIDITY_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, @@ -300,6 +303,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_POWER_STATE_TEMPLATE): cv.template, vol.Optional(CONF_POWER_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRECISION): vol.In( @@ -348,11 +352,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE + # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # was already removed or never added support was deprecated with release 2023.2 # and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_COMMAND_TOPIC), cv.deprecated(CONF_POWER_STATE_TEMPLATE), cv.deprecated(CONF_POWER_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, @@ -365,10 +368,9 @@ _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA DISCOVERY_SCHEMA = vol.All( _DISCOVERY_SCHEMA_BASE, - # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, + # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added # support was deprecated with release 2023.2 and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_COMMAND_TOPIC), cv.deprecated(CONF_POWER_STATE_TEMPLATE), cv.deprecated(CONF_POWER_STATE_TOPIC), valid_preset_mode_configuration, @@ -962,13 +964,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - if hvac_mode == HVACMode.OFF: - await self._publish( - CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_OFF] - ) - else: - await self._publish(CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_ON]) - payload = self._command_templates[CONF_MODE_COMMAND_TEMPLATE](hvac_mode) await self._publish(CONF_MODE_COMMAND_TOPIC, payload) @@ -1013,3 +1008,28 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" await self._set_aux_heat(False) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_ON] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) + return + # Fall back to default behavior without power command topic + await super().async_turn_on() + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_OFF] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) + if self._optimistic: + self._attr_hvac_mode = HVACMode.OFF + self.async_write_ha_state() + return + # Fall back to default behavior without power command topic + await super().async_turn_off() diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index de55bf71bed..4a6d1bf64d4 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -355,9 +355,6 @@ async def test_set_operation_optimistic( assert state.state == "heat" -# CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, -# support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added -# support was deprecated with release 2023.2 and will be removed with release 2023.8 @pytest.mark.parametrize( "hass_config", [ @@ -377,17 +374,134 @@ async def test_set_operation_with_power_command( await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.state == "cool" - mqtt_mock.async_publish.assert_has_calls( - [call("power-command", "ON", 0, False), call("mode-topic", "cool", 0, False)] - ) + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "cool", 0, False)]) mqtt_mock.async_publish.reset_mock() await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" - mqtt_mock.async_publish.assert_has_calls( - [call("power-command", "OFF", 0, False), call("mode-topic", "off", 0, False)] - ) + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "off", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_on(hass, ENTITY_CLIMATE) + # the hvac_mode is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "ON", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_off(hass, ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_has_calls([call("power-command", "OFF", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"power_command_topic": "power-command", "optimistic": True},), + ) + ], +) +async def test_turn_on_and_off_optimistic_with_power_command( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting of turn on/off with power command enabled.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "cool", 0, False)]) + mqtt_mock.async_publish.reset_mock() + await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + await common.async_turn_on(hass, ENTITY_CLIMATE) + # the hvac_mode is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "ON", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + await common.async_turn_off(hass, ENTITY_CLIMATE) + # the hvac_mode is updated optimistically + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "OFF", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + ("hass_config", "climate_on", "climate_off"), + [ + ( + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["heat", "cool"]},) + ), + "heat", + None, + ), + ( + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "dry"]},) + ), + None, + "off", + ), + ( + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "cool"]},) + ), + "cool", + "off", + ), + ], +) +async def test_turn_on_and_off_without_power_command( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + climate_on: str | None, + climate_off: str | None, +) -> None: + """Test setting of turn on/off with power command enabled.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + await common.async_turn_on(hass, ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert climate_on is None or state.state == climate_on + if climate_on: + mqtt_mock.async_publish.assert_has_calls( + [call("mode-topic", climate_on, 0, False)] + ) + else: + mqtt_mock.async_publish.assert_has_calls([]) + + await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_off(hass, ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert climate_off is None or state.state == climate_off + if climate_off: + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "off", 0, False)]) + else: + assert state.state == "cool" + mqtt_mock.async_publish.assert_has_calls([]) mqtt_mock.async_publish.reset_mock() @@ -1480,6 +1594,7 @@ async def test_get_with_templates( climate.DOMAIN: { "name": "test", "mode_command_topic": "mode-topic", + "power_command_topic": "power-topic", "target_humidity_command_topic": "humidity-topic", "temperature_command_topic": "temperature-topic", "temperature_low_command_topic": "temperature-low-topic", @@ -1499,6 +1614,7 @@ async def test_get_with_templates( ], # Create simple templates "fan_mode_command_template": "fan_mode: {{ value }}", + "power_command_template": "power: {{ value }}", "preset_mode_command_template": "preset_mode: {{ value }}", "mode_command_template": "mode: {{ value }}", "swing_mode_command_template": "swing_mode: {{ value }}", @@ -1540,13 +1656,38 @@ async def test_set_and_templates( # Mode await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with( - "mode-topic", "mode: cool", 0, False - ) + mqtt_mock.async_publish.assert_any_call("mode-topic", "mode: cool", 0, False) + assert mqtt_mock.async_publish.call_count == 1 mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "cool" + await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_any_call("mode-topic", "mode: off", 0, False) + assert mqtt_mock.async_publish.call_count == 1 + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + # Power + await common.async_turn_on(hass, ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_any_call("power-topic", "power: ON", 0, False) + # Only power command is sent + # the mode is not updated when power_command_topic is set + assert mqtt_mock.async_publish.call_count == 1 + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + await common.async_turn_off(hass, ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_any_call("power-topic", "power: OFF", 0, False) + # Only power command is sent + # the mode is not updated when power_command_topic is set + assert mqtt_mock.async_publish.call_count == 1 + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + # Swing Mode await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) mqtt_mock.async_publish.assert_called_once_with( @@ -2141,10 +2282,17 @@ async def test_precision_whole( ( climate.SERVICE_TURN_ON, "power_command_topic", - None, + {}, "ON", None, ), + ( + climate.SERVICE_TURN_OFF, + "power_command_topic", + {}, + "OFF", + None, + ), ( climate.SERVICE_SET_HVAC_MODE, "mode_command_topic", From e4c8a94aafa09d271958abd01742888973b5e367 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 22 Jun 2023 08:27:18 -0400 Subject: [PATCH 451/857] Add persistent_notification.dismiss_all service call (#95004) --- .../persistent_notification/__init__.py | 21 ++++++++ .../persistent_notification/services.yaml | 4 ++ .../persistent_notification/test_init.py | 51 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 960d0a5ca59..581720c2730 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -140,6 +140,20 @@ def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: ) +@callback +def async_dismiss_all(hass: HomeAssistant) -> None: + """Remove all notifications.""" + notifications = _async_get_or_create_notifications(hass) + notifications_copy = notifications.copy() + notifications.clear() + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.REMOVED, + notifications_copy, + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" @@ -158,6 +172,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Handle the dismiss notification service call.""" async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID]) + @callback + def dismiss_all_service(call: ServiceCall) -> None: + """Handle the dismiss all notification service call.""" + async_dismiss_all(hass) + hass.services.async_register( DOMAIN, "create", @@ -175,6 +194,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, "dismiss", dismiss_service, SCHEMA_SERVICE_NOTIFICATION ) + hass.services.async_register(DOMAIN, "dismiss_all", dismiss_all_service, None) + websocket_api.async_register_command(hass, websocket_get_notifications) websocket_api.async_register_command(hass, websocket_subscribe_notifications) diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 60dbf5c864a..5ebd7e34409 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -33,3 +33,7 @@ dismiss: example: 1234 selector: text: + +dismiss_all: + name: Dismiss All + description: Remove all notifications. diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 71a0fcae917..921f4b12045 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -54,11 +54,31 @@ async def test_dismiss_notification(hass: HomeAssistant) -> None: pn.async_create(hass, "test", notification_id="Beer 2") assert len(notifications) == 1 + pn.async_dismiss(hass, notification_id="Does Not Exist") + + assert len(notifications) == 1 + pn.async_dismiss(hass, notification_id="Beer 2") assert len(notifications) == 0 +async def test_dismiss_all_notifications(hass: HomeAssistant) -> None: + """Ensure removal of all notifications.""" + notifications = pn._async_get_or_create_notifications(hass) + assert len(notifications) == 0 + + pn.async_create(hass, "test", notification_id="Beer 2") + pn.async_create(hass, "test", notification_id="Beer 3") + pn.async_create(hass, "test", notification_id="Beer 4") + pn.async_create(hass, "test", notification_id="Beer 5") + + assert len(notifications) == 4 + pn.async_dismiss_all(hass) + + assert len(notifications) == 0 + + async def test_ws_get_notifications( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -169,3 +189,34 @@ async def test_manual_notification_id_round_trip(hass: HomeAssistant) -> None: ) assert len(notifications) == 0 + + +async def test_manual_dismiss_all(hass: HomeAssistant) -> None: + """Test the dismiss all service.""" + notifications = pn._async_get_or_create_notifications(hass) + assert len(notifications) == 0 + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "Beer 1", "message": "test"}, + blocking=True, + ) + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "Beer 2", "message": "test 2"}, + blocking=True, + ) + + assert len(notifications) == 2 + + await hass.services.async_call( + pn.DOMAIN, + "dismiss_all", + None, + blocking=True, + ) + + assert len(notifications) == 0 From c503becd9a3fe16d53c8582e34534e0296eaa0f3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 22 Jun 2023 15:58:14 +0200 Subject: [PATCH 452/857] Make AirNow use device class (#94986) --- homeassistant/components/airnow/sensor.py | 6 +++--- homeassistant/components/airnow/strings.json | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 31bb3d793a1..f3d29cc65df 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -57,10 +58,9 @@ class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMi SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_AQI, - translation_key="aqi", icon="mdi:blur", - native_unit_of_measurement="aqi", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.AQI, value_fn=lambda data: data.get(ATTR_API_AQI), extra_state_attributes_fn=lambda data: { ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION], @@ -69,10 +69,10 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( ), AirNowEntityDescription( key=ATTR_API_PM25, - translation_key="pm25", icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM25, value_fn=lambda data: data.get(ATTR_API_PM25), extra_state_attributes_fn=None, ), diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index ff1ba6481c8..aed12596176 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -23,12 +23,6 @@ }, "entity": { "sensor": { - "aqi": { - "name": "[%key:component::sensor::entity_component::aqi::name%]" - }, - "pm25": { - "name": "[%key:component::sensor::entity_component::pm25::name%]" - }, "o3": { "name": "[%key:component::sensor::entity_component::ozone::name%]" } From ede84d74c718d306d465afcf1421390ac114723b Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 22 Jun 2023 15:59:48 +0200 Subject: [PATCH 453/857] Fix Meteo France blocked config entry when weather alert API fails (#94911) * Fix: do not block config entry when weather alert API fails * PR review --- homeassistant/components/meteo_france/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 3b82399f217..ccd23762850 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -133,10 +133,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator_alert.async_refresh() - if not coordinator_alert.last_update_success: - raise ConfigEntryNotReady - - hass.data[DOMAIN][department] = True + if coordinator_alert.last_update_success: + hass.data[DOMAIN][department] = True else: _LOGGER.warning( ( @@ -158,11 +156,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: undo_listener = entry.add_update_listener(_async_update_listener) hass.data[DOMAIN][entry.entry_id] = { + UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, COORDINATOR_RAIN: coordinator_rain, - COORDINATOR_ALERT: coordinator_alert, - UNDO_UPDATE_LISTENER: undo_listener, } + if coordinator_alert and coordinator_alert.last_update_success: + hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) From acdf309c47d323fe82cc883d6db8d3f20dc9bb1f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 22 Jun 2023 16:01:51 +0200 Subject: [PATCH 454/857] Add entity translations for Aladdin Connect (#95051) --- .../components/aladdin_connect/cover.py | 10 +++---- .../components/aladdin_connect/sensor.py | 27 ++++++++----------- .../components/aladdin_connect/strings.json | 10 +++++++ .../components/aladdin_connect/test_sensor.py | 10 +++---- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 32eb34333c9..25d601cf299 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -40,26 +40,24 @@ class AladdinDevice(CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE _attr_supported_features = SUPPORTED_FEATURES + _attr_has_entity_name = True + _attr_name = None def __init__( self, acc: AladdinConnectClient, device: DoorDevice, entry: ConfigEntry ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc - self._entry_id = entry.entry_id self._device_id = device["device_id"] self._number = device["door_number"] - self._name = device["name"] self._serial = device["serial"] - self._model = device["model"] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=self._name, + name=device["name"], manufacturer="Overhead Door", - model=self._model, + model=device["model"], ) - self._attr_has_entity_name = True self._attr_unique_id = f"{self._device_id}-{self._number}" async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index 51ae5154302..395bbbb04a8 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -40,7 +40,6 @@ class AccSensorEntityDescription( SENSORS: tuple[AccSensorEntityDescription, ...] = ( AccSensorEntityDescription( key="battery_level", - name="Battery level", device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, @@ -49,7 +48,7 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = ( ), AccSensorEntityDescription( key="rssi", - name="Wi-Fi RSSI", + translation_key="wifi_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -58,7 +57,7 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = ( ), AccSensorEntityDescription( key="ble_strength", - name="BLE Strength", + translation_key="ble_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -89,8 +88,8 @@ async def async_setup_entry( class AladdinConnectSensor(SensorEntity): """A sensor implementation for Aladdin Connect devices.""" - _device: AladdinConnectSensor entity_description: AccSensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -101,24 +100,20 @@ class AladdinConnectSensor(SensorEntity): """Initialize a sensor for an Aladdin Connect device.""" self._device_id = device["device_id"] self._number = device["door_number"] - self._name = device["name"] - self._model = device["model"] self._acc = acc self.entity_description = description self._attr_unique_id = f"{self._device_id}-{self._number}-{description.key}" - self._attr_has_entity_name = True - if self._model == "01" and description.key in ("battery_level", "ble_strength"): - self._attr_entity_registry_enabled_default = True - - @property - def device_info(self) -> DeviceInfo | None: - """Device information for Aladdin Connect sensors.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=self._name, + name=device["name"], manufacturer="Overhead Door", - model=self._model, + model=device["model"], ) + if device["model"] == "01" and description.key in ( + "battery_level", + "ble_strength", + ): + self._attr_entity_registry_enabled_default = True @property def native_value(self) -> float | None: diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index ff42ca14bc3..bfe932b039c 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -25,5 +25,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "wifi_strength": { + "name": "Wi-Fi RSSI" + }, + "ble_strength": { + "name": "BLE Strength" + } + } } } diff --git a/tests/components/aladdin_connect/test_sensor.py b/tests/components/aladdin_connect/test_sensor.py index c01d6c5c781..dca8ecaa513 100644 --- a/tests/components/aladdin_connect/test_sensor.py +++ b/tests/components/aladdin_connect/test_sensor.py @@ -47,7 +47,7 @@ async def test_sensors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get("sensor.home_battery_level") + entry = entity_registry.async_get("sensor.home_battery") assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -57,7 +57,7 @@ async def test_sensors( await hass.async_block_till_done() assert update_entry != entry assert update_entry.disabled is False - state = hass.states.get("sensor.home_battery_level") + state = hass.states.get("sensor.home_battery") assert state is None async_fire_time_changed( @@ -65,7 +65,7 @@ async def test_sensors( utcnow() + SCAN_INTERVAL, ) await hass.async_block_till_done() - state = hass.states.get("sensor.home_battery_level") + state = hass.states.get("sensor.home_battery") assert state entry = entity_registry.async_get("sensor.home_wi_fi_rssi") @@ -121,11 +121,11 @@ async def test_sensors_model_01( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get("sensor.home_battery_level") + entry = entity_registry.async_get("sensor.home_battery") assert entry assert entry.disabled is False assert entry.disabled_by is None - state = hass.states.get("sensor.home_battery_level") + state = hass.states.get("sensor.home_battery") assert state entry = entity_registry.async_get("sensor.home_wi_fi_rssi") From 8e93045857cc0c0fb5f1a4a4031b770bc389f378 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 22 Jun 2023 16:02:45 +0200 Subject: [PATCH 455/857] Add entity translations to Airthings (#95052) --- homeassistant/components/airthings/sensor.py | 20 ++++++------------- .../components/airthings/strings.json | 16 +++++++++++++++ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 5212ff51fe8..9c9859306ca 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -35,62 +35,56 @@ SENSORS: dict[str, SensorEntityDescription] = { "radonShortTermAvg": SensorEntityDescription( key="radonShortTermAvg", native_unit_of_measurement="Bq/m³", - name="Radon", + translation_key="radon", ), "temp": SensorEntityDescription( key="temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, - name="Pressure", ), "battery": SensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - name="Battery", ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - name="CO2", ), "voc": SensorEntityDescription( key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, - name="VOC", ), "light": SensorEntityDescription( key="light", native_unit_of_measurement=PERCENTAGE, - name="Light", + translation_key="light", ), "virusRisk": SensorEntityDescription( key="virusRisk", - name="Virus Risk", + translation_key="virus_risk", ), "mold": SensorEntityDescription( key="mold", - name="Mold", + translation_key="mold", ), "rssi": SensorEntityDescription( key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, device_class=SensorDeviceClass.SIGNAL_STRENGTH, - name="RSSI", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -98,13 +92,11 @@ SENSORS: dict[str, SensorEntityDescription] = { key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, - name="PM1", ), "pm25": SensorEntityDescription( key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, - name="PM25", ), } @@ -134,6 +126,7 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): """Representation of a Airthings Sensor device.""" _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True def __init__( self, @@ -146,7 +139,6 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): self.entity_description = entity_description - self._attr_name = f"{airthings_device.name} {entity_description.name}" self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json index af1200baa58..610891fff10 100644 --- a/homeassistant/components/airthings/strings.json +++ b/homeassistant/components/airthings/strings.json @@ -17,5 +17,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "entity": { + "sensor": { + "radon": { + "name": "Radon" + }, + "light": { + "name": "Light" + }, + "virus_risk": { + "name": "Virus Risk" + }, + "mold": { + "name": "Mold" + } + } } } From 6ceb9736069de997405750b20ab6dba93196ff97 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 22 Jun 2023 16:03:28 +0200 Subject: [PATCH 456/857] Add entity translations for Abode (#94169) --- homeassistant/components/abode/sensor.py | 4 +--- tests/components/abode/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index 546d57ab3e7..11821773938 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -22,17 +22,14 @@ from .const import DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CONST.TEMP_STATUS_KEY, - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key=CONST.HUMI_STATUS_KEY, - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, ), SensorEntityDescription( key=CONST.LUX_STATUS_KEY, - name="Lux", device_class=SensorDeviceClass.ILLUMINANCE, ), ) @@ -56,6 +53,7 @@ class AbodeSensor(AbodeDevice, SensorEntity): """A sensor implementation for Abode devices.""" _device: AbodeSense + _attr_has_entity_name = True def __init__( self, diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py index 67892dfafb4..755dfbf584e 100644 --- a/tests/components/abode/test_sensor.py +++ b/tests/components/abode/test_sensor.py @@ -37,7 +37,7 @@ async def test_attributes(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Environment Sensor Humidity" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY - state = hass.states.get("sensor.environment_sensor_lux") + state = hass.states.get("sensor.environment_sensor_illuminance") assert state.state == "1.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "lx" From 5265584159292f00d3ff0a098fb13c6ff4032e68 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 22 Jun 2023 10:10:36 -0400 Subject: [PATCH 457/857] Fix zwave_js device diagnostics dump (#94999) * Fix zwave_js device diagnostics dump * Update tests/components/zwave_js/test_diagnostics.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_diagnostics.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_diagnostics.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_diagnostics.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_diagnostics.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_diagnostics.py Co-authored-by: Martin Hjelmare * improve test --------- Co-authored-by: Martin Hjelmare --- .../components/zwave_js/diagnostics.py | 8 +++- tests/components/zwave_js/test_diagnostics.py | 43 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 4f52c41a085..2fe2b17fe1b 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -65,7 +65,7 @@ def redact_node_state(node_state: NodeDataType) -> NodeDataType: def get_device_entities( - hass: HomeAssistant, node: Node, device: dr.DeviceEntry + hass: HomeAssistant, node: Node, config_entry: ConfigEntry, device: dr.DeviceEntry ) -> list[dict[str, Any]]: """Get entities for a device.""" entity_entries = er.async_entries_for_device( @@ -73,6 +73,10 @@ def get_device_entities( ) entities = [] for entry in entity_entries: + # Skip entities that are not part of this integration + if entry.config_entry_id != config_entry.entry_id: + continue + # If the value ID returns as None, we don't need to include this entity if (value_id := get_value_id_from_unique_id(entry.unique_id)) is None: continue @@ -142,7 +146,7 @@ async def async_get_device_diagnostics( if node_id is None or node_id not in driver.controller.nodes: raise ValueError(f"Node for device {device.id} can't be found") node = driver.controller.nodes[node_id] - entities = get_device_entities(hass, node, device) + entities = get_device_entities(hass, node, config_entry, device) assert client.version node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) return { diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index e7d7d9594bd..aa5ec77b798 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -18,11 +18,11 @@ from homeassistant.components.zwave_js.helpers import ( get_value_id_from_unique_id, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get as async_get_dev_reg -from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg +from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import PROPERTY_ULTRAVIOLET +from tests.common import MockConfigEntry from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -57,10 +57,26 @@ async def test_device_diagnostics( version_state, ) -> None: """Test the device level diagnostics data dump.""" - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) assert device + # Create mock config entry for fake entity + mock_config_entry = MockConfigEntry(domain="test_integration") + mock_config_entry.add_to_hass(hass) + + # Add an entity entry to the device that is not part of this config entry + ent_reg = er.async_get(hass) + ent_reg.async_get_or_create( + "test", + "test_integration", + "test_unique_id", + suggested_object_id="unrelated_entity", + config_entry=mock_config_entry, + device_id=device.id, + ) + assert ent_reg.async_get("test.unrelated_entity") + # Update a value and ensure it is reflected in the node state event = Event( type="value updated", @@ -92,16 +108,27 @@ async def test_device_diagnostics( } # Assert that we only have the entities that were discovered for this device # Entities that are created outside of discovery (e.g. node status sensor and - # ping button) should not be in dump. + # ping button) as well as helper entities created from other integrations should + # not be in dump. assert len(diagnostics_data["entities"]) == len( list(async_discover_node_values(multisensor_6, device, {device.id: set()})) ) + assert any( + entity.entity_id == "test.unrelated_entity" + for entity in er.async_entries_for_device(ent_reg, device.id) + ) + # Explicitly check that the entity that is not part of this config entry is not + # in the dump. + assert not any( + entity["entity_id"] == "test.unrelated_entity" + for entity in diagnostics_data["entities"] + ) assert diagnostics_data["state"] == multisensor_6.data async def test_device_diagnostics_error(hass: HomeAssistant, integration) -> None: """Test the device diagnostics raises exception when an invalid device is used.""" - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( config_entry_id=integration.entry_id, identifiers={("test", "test")} ) @@ -123,12 +150,12 @@ async def test_device_diagnostics_missing_primary_value( hass_client: ClientSessionGenerator, ) -> None: """Test that device diagnostics handles an entity with a missing primary value.""" - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) assert device entity_id = "sensor.multisensor_6_air_temperature" - ent_reg = async_get_ent_reg(hass) + ent_reg = er.async_get(hass) entry = ent_reg.async_get(entity_id) # check that the primary value for the entity exists in the diagnostics @@ -212,7 +239,7 @@ async def test_device_diagnostics_secret_value( client.driver.controller.nodes[node.node_id] = node client.driver.controller.emit("node added", {"node": node}) await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device = dev_reg.async_get_device({get_device_id(client.driver, node)}) assert device From 6ec6369c27560f809b0c600b6d5a10cc3ee4688d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 22 Jun 2023 16:46:06 +0200 Subject: [PATCH 458/857] Add entity translations to AirThings BLE (#95061) --- homeassistant/components/airthings_ble/sensor.py | 16 +++++----------- .../components/airthings_ble/strings.json | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index b6c8c25491b..98190df6b8d 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -39,26 +39,26 @@ _LOGGER = logging.getLogger(__name__) SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { "radon_1day_avg": SensorEntityDescription( key="radon_1day_avg", + translation_key="radon_1day_avg", native_unit_of_measurement=VOLUME_BECQUEREL, - name="Radon 1-day average", state_class=SensorStateClass.MEASUREMENT, icon="mdi:radioactive", ), "radon_longterm_avg": SensorEntityDescription( key="radon_longterm_avg", + translation_key="radon_longterm_avg", native_unit_of_measurement=VOLUME_BECQUEREL, - name="Radon longterm average", state_class=SensorStateClass.MEASUREMENT, icon="mdi:radioactive", ), "radon_1day_level": SensorEntityDescription( key="radon_1day_level", - name="Radon 1-day level", + translation_key="radon_1day_level", icon="mdi:radioactive", ), "radon_longterm_level": SensorEntityDescription( key="radon_longterm_level", - name="Radon longterm level", + translation_key="radon_longterm_level", icon="mdi:radioactive", ), "temperature": SensorEntityDescription( @@ -66,21 +66,18 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, - name="Pressure", ), "battery": SensorEntityDescription( key="battery", @@ -88,20 +85,18 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - name="Battery", ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - name="co2", ), "voc": SensorEntityDescription( key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - name="VOC", icon="mdi:cloud", ), "illuminance": SensorEntityDescription( @@ -109,7 +104,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, state_class=SensorStateClass.MEASUREMENT, - name="Illuminance", ), } diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index 1cfc4ccd592..b1159e6f251 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -19,5 +19,21 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "radon_1day_avg": { + "name": "Radon 1-day average" + }, + "radon_longterm_avg": { + "name": "Radon longterm average" + }, + "radon_1day_level": { + "name": "Radon 1-day level" + }, + "radon_longterm_level": { + "name": "Radon longterm level" + } + } } } From 1459bf4011470cb0b588eeef2f2346129cc22191 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 16:50:21 +0200 Subject: [PATCH 459/857] Fix async_scanner_devices_by_address unexpectedly combining Bluetooth scanners (#94990) --- homeassistant/components/bluetooth/manager.py | 71 +++++++++---------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 3210822e795..f1221290c74 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -246,9 +246,12 @@ class BluetoothManager: self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: """Get BluetoothScannerDevice by address.""" - scanners = self._get_scanners_by_type(True) if not connectable: - scanners.extend(self._get_scanners_by_type(False)) + scanners: Iterable[BaseHaScanner] = itertools.chain( + self._connectable_scanners, self._non_connectable_scanners + ) + else: + scanners = self._connectable_scanners return [ BluetoothScannerDevice(scanner, *device_adv) for scanner in scanners @@ -267,21 +270,19 @@ class BluetoothManager: """ yield from itertools.chain.from_iterable( scanner.discovered_devices_and_advertisement_data - for scanner in self._get_scanners_by_type(True) + for scanner in self._connectable_scanners ) if not connectable: yield from itertools.chain.from_iterable( scanner.discovered_devices_and_advertisement_data - for scanner in self._get_scanners_by_type(False) + for scanner in self._non_connectable_scanners ) @hass_callback def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: """Return all of combined best path to discovered from all the scanners.""" - return [ - history.device - for history in self._get_history_by_type(connectable).values() - ] + histories = self._connectable_history if connectable else self._all_history + return [history.device for history in histories.values()] @hass_callback def async_setup_unavailable_tracking(self) -> None: @@ -303,7 +304,10 @@ class BluetoothManager: intervals = tracker.intervals for connectable in (True, False): - unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) + if connectable: + unavailable_callbacks = self._connectable_unavailable_callbacks + else: + unavailable_callbacks = self._unavailable_callbacks history = connectable_history if connectable else all_history disappeared = set(history).difference( self._async_all_discovered_addresses(connectable) @@ -583,7 +587,10 @@ class BluetoothManager: connectable: bool, ) -> Callable[[], None]: """Register a callback.""" - unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) + if connectable: + unavailable_callbacks = self._connectable_unavailable_callbacks + else: + unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks.setdefault(address, []).append(callback) @hass_callback @@ -620,13 +627,13 @@ class BluetoothManager: # If we have history for the subscriber, we can trigger the callback # immediately with the last packet so the subscriber can see the # device. - all_history = self._get_history_by_type(connectable) + history = self._connectable_history if connectable else self._all_history service_infos: Iterable[BluetoothServiceInfoBleak] = [] if address := callback_matcher.get(ADDRESS): - if service_info := all_history.get(address): + if service_info := history.get(address): service_infos = [service_info] else: - service_infos = all_history.values() + service_infos = history.values() for service_info in service_infos: if ble_device_matches(callback_matcher, service_info): @@ -642,29 +649,32 @@ class BluetoothManager: self, address: str, connectable: bool ) -> BLEDevice | None: """Return the BLEDevice if present.""" - all_history = self._get_history_by_type(connectable) - if history := all_history.get(address): + histories = self._connectable_history if connectable else self._all_history + if history := histories.get(address): return history.device return None @hass_callback def async_address_present(self, address: str, connectable: bool) -> bool: """Return if the address is present.""" - return address in self._get_history_by_type(connectable) + histories = self._connectable_history if connectable else self._all_history + return address in histories @hass_callback def async_discovered_service_info( self, connectable: bool ) -> Iterable[BluetoothServiceInfoBleak]: """Return all the discovered services info.""" - return self._get_history_by_type(connectable).values() + histories = self._connectable_history if connectable else self._all_history + return histories.values() @hass_callback def async_last_service_info( self, address: str, connectable: bool ) -> BluetoothServiceInfoBleak | None: """Return the last service info for an address.""" - return self._get_history_by_type(connectable).get(address) + histories = self._connectable_history if connectable else self._all_history + return histories.get(address) def _async_trigger_matching_discovery( self, service_info: BluetoothServiceInfoBleak @@ -688,26 +698,6 @@ class BluetoothManager: if service_info := self._all_history.get(address): self._async_trigger_matching_discovery(service_info) - def _get_scanners_by_type(self, connectable: bool) -> list[BaseHaScanner]: - """Return the scanners by type.""" - if connectable: - return self._connectable_scanners - return self._non_connectable_scanners - - def _get_unavailable_callbacks_by_type( - self, connectable: bool - ) -> dict[str, list[Callable[[BluetoothServiceInfoBleak], None]]]: - """Return the unavailable callbacks by type.""" - if connectable: - return self._connectable_unavailable_callbacks - return self._unavailable_callbacks - - def _get_history_by_type( - self, connectable: bool - ) -> dict[str, BluetoothServiceInfoBleak]: - """Return the history by type.""" - return self._connectable_history if connectable else self._all_history - def async_register_scanner( self, scanner: BaseHaScanner, @@ -716,7 +706,10 @@ class BluetoothManager: ) -> CALLBACK_TYPE: """Register a new scanner.""" _LOGGER.debug("Registering scanner %s", scanner.name) - scanners = self._get_scanners_by_type(connectable) + if connectable: + scanners = self._connectable_scanners + else: + scanners = self._non_connectable_scanners def _unregister_scanner() -> None: _LOGGER.debug("Unregistering scanner %s", scanner.name) From 38614bc3f08faf03d25a9408ce52b718550b7596 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 22 Jun 2023 11:24:59 -0500 Subject: [PATCH 460/857] Add websocket command to test intent recognition for default agent (#94674) * Add websocket command to test intent recognition for default agent * Return results as a list * Only check intent name/entities in test * Less verbose output in debug API --- .../components/conversation/__init__.py | 55 +++++++++++++++++++ .../components/conversation/default_agent.py | 35 ++++++++---- .../conversation/snapshots/test_init.ambr | 40 ++++++++++++++ tests/components/conversation/test_init.py | 40 ++++++++++++++ 4 files changed, 159 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index ea1eb041fe5..f0cd6cb504c 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -186,6 +186,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_prepare) websocket_api.async_register_command(hass, websocket_get_agent_info) websocket_api.async_register_command(hass, websocket_list_agents) + websocket_api.async_register_command(hass, websocket_hass_agent_debug) return True @@ -297,6 +298,60 @@ async def websocket_list_agents( connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/homeassistant/debug", + vol.Required("sentences"): [str], + vol.Optional("language"): str, + vol.Optional("device_id"): vol.Any(str, None), + } +) +@websocket_api.async_response +async def websocket_hass_agent_debug( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Return intents that would be matched by the default agent for a list of sentences.""" + agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) + assert isinstance(agent, DefaultAgent) + results = [ + await agent.async_recognize( + ConversationInput( + text=sentence, + context=connection.context(msg), + conversation_id=None, + device_id=msg.get("device_id"), + language=msg.get("language", hass.config.language), + ) + ) + for sentence in msg["sentences"] + ] + + # Return results for each sentence in the same order as the input. + connection.send_result( + msg["id"], + { + "results": [ + { + "intent": { + "name": result.intent.name, + }, + "entities": { + entity_key: { + "name": entity.name, + "value": entity.value, + "text": entity.text, + } + for entity_key, entity in result.entities.items() + }, + } + if result is not None + else None + for result in results + ] + }, + ) + + class ConversationProcessView(http.HomeAssistantView): """View to process text.""" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 44b13522412..b0bbc8e7fec 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -143,11 +143,12 @@ class DefaultAgent(AbstractConversationAgent): self.hass, DOMAIN, self._async_exposed_entities_updated ) - async def async_process(self, user_input: ConversationInput) -> ConversationResult: - """Process a sentence.""" + async def async_recognize( + self, user_input: ConversationInput + ) -> RecognizeResult | None: + """Recognize intent from user input.""" language = user_input.language or self.hass.config.language lang_intents = self._lang_intents.get(language) - conversation_id = None # Not supported # Reload intents if missing or new components if lang_intents is None or ( @@ -159,21 +160,26 @@ class DefaultAgent(AbstractConversationAgent): if lang_intents is None: # No intents loaded _LOGGER.warning("No intents were loaded for language: %s", language) - return _make_error_result( - language, - intent.IntentResponseErrorCode.NO_INTENT_MATCH, - _DEFAULT_ERROR_TEXT, - conversation_id, - ) + return None slot_lists = self._make_slot_lists() - result = await self.hass.async_add_executor_job( self._recognize, user_input, lang_intents, slot_lists, ) + + return result + + async def async_process(self, user_input: ConversationInput) -> ConversationResult: + """Process a sentence.""" + language = user_input.language or self.hass.config.language + conversation_id = None # Not supported + + result = await self.async_recognize(user_input) + lang_intents = self._lang_intents.get(language) + if result is None: _LOGGER.debug("No intent was matched for '%s'", user_input.text) return _make_error_result( @@ -183,6 +189,10 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) + # Will never happen because result will be None when no intents are + # loaded in async_recognize. + assert lang_intents is not None + try: intent_response = await intent.async_handle( self.hass, @@ -585,9 +595,12 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists def _get_error_text( - self, response_type: ResponseType, lang_intents: LanguageIntents + self, response_type: ResponseType, lang_intents: LanguageIntents | None ) -> str: """Get response error text by type.""" + if lang_intents is None: + return _DEFAULT_ERROR_TEXT + response_key = response_type.value response_str = lang_intents.error_responses.get(response_key) return response_str or _DEFAULT_ERROR_TEXT diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 61e499b15da..38a7ed92b52 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -249,3 +249,43 @@ 'message': "invalid agent ID for dictionary value @ data['agent_id']. Got 'not_exist'", }) # --- +# name: test_ws_hass_agent_debug + dict({ + 'results': list([ + dict({ + 'entities': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'my cool light', + 'value': 'my cool light', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOn', + }), + }), + dict({ + 'entities': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'my cool light', + 'value': 'my cool light', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOff', + }), + }), + None, + ]), + }) +# --- +# name: test_ws_hass_agent_debug.1 + dict({ + 'name': dict({ + 'name': 'name', + 'text': 'my cool light', + 'value': 'my cool light', + }), + }) +# --- diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index e0243b1841c..7c0cc54b91d 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1626,3 +1626,43 @@ async def test_ws_get_agent_info( msg = await client.receive_json() assert not msg["success"] assert msg["error"] == snapshot + + +async def test_ws_hass_agent_debug( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command.""" + client = await hass_ws_client(hass) + + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) + hass.states.async_set("light.kitchen", "off") + + on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + off_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") + + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "turn on my cool light", + "turn my cool light off", + "this will not match anything", # null in results + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + # Light state should not have been changed + assert len(on_calls) == 0 + assert len(off_calls) == 0 From 6ad3b60adf37f1696926393438820dc5d8c2702f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 22 Jun 2023 19:52:14 +0200 Subject: [PATCH 461/857] Implement Apparent temperature in Weather entity component (#95070) --- homeassistant/components/weather/__init__.py | 43 +++++++++++++++++++ homeassistant/components/weather/const.py | 1 + homeassistant/components/weather/strings.json | 3 ++ tests/components/weather/test_init.py | 20 ++++++++- .../custom_components/test/weather.py | 7 +++ 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 0a99b6aaaf7..0efaea949e1 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRECIPITATION_UNIT, @@ -73,6 +74,8 @@ ATTR_FORECAST_PRECIPITATION: Final = "precipitation" ATTR_FORECAST_PRECIPITATION_PROBABILITY: Final = "precipitation_probability" ATTR_FORECAST_NATIVE_PRESSURE: Final = "native_pressure" ATTR_FORECAST_PRESSURE: Final = "pressure" +ATTR_FORECAST_NATIVE_APPARENT_TEMP: Final = "native_apparent_temperature" +ATTR_FORECAST_APPARENT_TEMP: Final = "apparent_temperature" ATTR_FORECAST_NATIVE_TEMP: Final = "native_temperature" ATTR_FORECAST_TEMP: Final = "temperature" ATTR_FORECAST_NATIVE_TEMP_LOW: Final = "native_templow" @@ -199,6 +202,7 @@ class WeatherEntity(Entity): _attr_native_pressure: float | None = None _attr_native_pressure_unit: str | None = None + _attr_native_apparent_temperature: float | None = None _attr_native_temperature: float | None = None _attr_native_temperature_unit: str | None = None _attr_native_visibility: float | None = None @@ -272,6 +276,11 @@ class WeatherEntity(Entity): return self.async_registry_entry_updated() + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature in native units.""" + return self._attr_native_temperature + @final @property def temperature(self) -> float | None: @@ -600,6 +609,20 @@ class WeatherEntity(Entity): except (TypeError, ValueError): data[ATTR_WEATHER_TEMPERATURE] = temperature + if (apparent_temperature := self.native_apparent_temperature) is not None: + from_unit = self.native_temperature_unit or self._default_temperature_unit + to_unit = self._temperature_unit + try: + apparent_temperature_f = float(apparent_temperature) + value_apparent_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + apparent_temperature_f, from_unit, to_unit + ) + data[ATTR_WEATHER_APPARENT_TEMPERATURE] = round_temperature( + value_apparent_temp, precision + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_APPARENT_TEMPERATURE] = apparent_temperature + data[ATTR_WEATHER_TEMPERATURE_UNIT] = self._temperature_unit if (humidity := self.humidity) is not None: @@ -686,6 +709,26 @@ class WeatherEntity(Entity): value_temp, precision ) + if ( + forecast_apparent_temp := forecast_entry.pop( + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + forecast_entry.get(ATTR_FORECAST_NATIVE_APPARENT_TEMP), + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_apparent_temp = float(forecast_apparent_temp) + value_apparent_temp = UNIT_CONVERSIONS[ + ATTR_WEATHER_TEMPERATURE_UNIT + ]( + forecast_apparent_temp, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_APPARENT_TEMP] = round_temperature( + value_apparent_temp, precision + ) + if ( forecast_temp_low := forecast_entry.pop( ATTR_FORECAST_NATIVE_TEMP_LOW, diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index 2dcfd8a2ddc..95094850ff2 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -22,6 +22,7 @@ ATTR_WEATHER_HUMIDITY = "humidity" ATTR_WEATHER_OZONE = "ozone" ATTR_WEATHER_PRESSURE = "pressure" ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" +ATTR_WEATHER_APPARENT_TEMPERATURE = "apparent_temperature" ATTR_WEATHER_TEMPERATURE = "temperature" ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" ATTR_WEATHER_VISIBILITY = "visibility" diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 461f715c8db..e319d42c943 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -39,6 +39,9 @@ "pressure_unit": { "name": "Pressure unit" }, + "apparent_temperature": { + "name": "Apparent temperature" + }, "temperature": { "name": "Temperature" }, diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 76483491bf8..6ac27f1c2c9 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -6,12 +6,14 @@ import pytest from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, + ATTR_FORECAST_APPARENT_TEMP, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRECIPITATION_UNIT, ATTR_WEATHER_PRESSURE, @@ -63,6 +65,7 @@ class MockWeatherEntity(WeatherEntity): self._attr_native_pressure = 10 self._attr_native_pressure_unit = UnitOfPressure.HPA self._attr_native_temperature = 20 + self._attr_native_apparent_temperature = 25 self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS self._attr_native_visibility = 30 self._attr_native_visibility_unit = UnitOfLength.KILOMETERS @@ -85,6 +88,7 @@ class MockWeatherEntityPrecision(WeatherEntity): super().__init__() self._attr_condition = ATTR_CONDITION_SUNNY self._attr_native_temperature = 20.3 + self._attr_native_apparent_temperature = 25.3 self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS self._attr_precision = PRECISION_HALVES @@ -153,21 +157,35 @@ async def test_temperature( """Test temperature.""" hass.config.units = unit_system native_value = 38 + apparent_native_value = 45 state_value = TemperatureConverter.convert(native_value, native_unit, state_unit) + apparent_state_value = TemperatureConverter.convert( + apparent_native_value, native_unit, state_unit + ) entity0 = await create_entity( - hass, native_temperature=native_value, native_temperature_unit=native_unit + hass, + native_temperature=native_value, + native_temperature_unit=native_unit, + native_apparent_temperature=apparent_native_value, ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] expected = state_value + apparent_expected = apparent_state_value assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == pytest.approx( expected, rel=0.1 ) + assert float(state.attributes[ATTR_WEATHER_APPARENT_TEMPERATURE]) == pytest.approx( + apparent_expected, rel=0.1 + ) assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit assert float(forecast[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) + assert float(forecast[ATTR_FORECAST_APPARENT_TEMP]) == pytest.approx( + apparent_expected, rel=0.1 + ) assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == pytest.approx(expected, rel=0.1) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index a2977e114e4..df9a3faea3f 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -5,6 +5,7 @@ Call init before using it in your tests to ensure clean test data. from __future__ import annotations from homeassistant.components.weather import ( + ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -46,6 +47,11 @@ class MockWeather(MockEntity, WeatherEntity): """Return the platform temperature.""" return self._handle("native_temperature") + @property + def native_apparent_temperature(self) -> float | None: + """Return the platform apparent temperature.""" + return self._handle("native_apparent_temperature") + @property def native_temperature_unit(self) -> str | None: """Return the unit of measurement for temperature.""" @@ -195,6 +201,7 @@ class MockWeatherMockForecast(MockWeather): return [ { ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, From 1cf4a008c38f52130a06cef1c37e8ed75b8335a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 19:55:28 +0200 Subject: [PATCH 462/857] Add coverage for binary_sensor platform to esphome (#95067) --- .coveragerc | 1 - .../components/esphome/binary_sensor.py | 6 +- .../components/esphome/test_binary_sensor.py | 87 ++++++++++++++++++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/.coveragerc b/.coveragerc index 9fdeefefbba..1041a0a05b0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -306,7 +306,6 @@ omit = homeassistant/components/escea/discovery.py homeassistant/components/esphome/__init__.py homeassistant/components/esphome/alarm_control_panel.py - homeassistant/components/esphome/binary_sensor.py homeassistant/components/esphome/bluetooth/* homeassistant/components/esphome/button.py homeassistant/components/esphome/camera.py diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 7f1ccb6fc7d..ce77c28e349 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -49,10 +49,10 @@ class EsphomeBinarySensor( # Status binary sensors indicated connected state. # So in their case what's usually _availability_ is now state return self._entry_data.available - if not self._has_state: - return None state = self._state - return None if state.missing_state else state.state + if not self._has_state or state.missing_state: + return None + return state.state @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 3f780f3003d..8f1d5a670c4 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,7 +1,9 @@ """Test ESPHome binary sensors.""" - +from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState +import pytest from homeassistant.components.esphome import DomainData +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -26,3 +28,86 @@ async def test_assist_in_progress( state = hass.states.get("binary_sensor.test_assist_in_progress") assert state.state == "off" + + +@pytest.mark.parametrize( + "binary_state", ((True, STATE_ON), (False, STATE_OFF), (None, STATE_UNKNOWN)) +) +async def test_binary_sensor_generic_entity( + hass: HomeAssistant, + mock_client: APIClient, + binary_state: tuple[bool, str], + mock_generic_device_entry, +) -> None: + """Test a generic binary_sensor entity.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ) + ] + esphome_state, hass_state = binary_state + states = [BinarySensorState(key=1, state=esphome_state)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == hass_state + + +async def test_status_binary_sensor( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic binary_sensor entity.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + is_status_binary_sensor=True, + ) + ] + states = [BinarySensorState(key=1, state=None)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_ON + + +async def test_binary_sensor_missing_state( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic binary_sensor that is missing state.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ) + ] + states = [BinarySensorState(key=1, state=True, missing_state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN From dd0e6d64814d97cfab01ccd814961f70994265d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 19:57:09 +0200 Subject: [PATCH 463/857] Migrate esphome media_player platform to use _on_static_info_update (#95071) --- .../components/esphome/media_player.py | 50 +++++++++---------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index d818e040965..d554207f563 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from aioesphomeapi import ( + EntityInfo, MediaPlayerCommand, MediaPlayerEntityState, MediaPlayerInfo, @@ -21,7 +22,7 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry @@ -61,6 +62,21 @@ class EsphomeMediaPlayer( _attr_device_class = MediaPlayerDeviceClass.SPEAKER + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + flags = ( + MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + ) + if self._static_info.supports_pause: + flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY + self._attr_supported_features = flags + @property @esphome_state_property def state(self) -> MediaPlayerState | None: @@ -79,20 +95,6 @@ class EsphomeMediaPlayer( """Volume level of the media player (0..1).""" return self._state.volume - @property - def supported_features(self) -> MediaPlayerEntityFeature: - """Flag supported features.""" - flags = ( - MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.BROWSE_MEDIA - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE - ) - if self._static_info.supports_pause: - flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY - return flags - async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -106,7 +108,7 @@ class EsphomeMediaPlayer( media_id = async_process_play_media_url(self.hass, media_id) await self._client.media_player_command( - self._static_info.key, + self._key, media_url=media_id, ) @@ -124,35 +126,29 @@ class EsphomeMediaPlayer( async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - await self._client.media_player_command( - self._static_info.key, - volume=volume, - ) + await self._client.media_player_command(self._key, volume=volume) async def async_media_pause(self) -> None: """Send pause command.""" await self._client.media_player_command( - self._static_info.key, - command=MediaPlayerCommand.PAUSE, + self._key, command=MediaPlayerCommand.PAUSE ) async def async_media_play(self) -> None: """Send play command.""" await self._client.media_player_command( - self._static_info.key, - command=MediaPlayerCommand.PLAY, + self._key, command=MediaPlayerCommand.PLAY ) async def async_media_stop(self) -> None: """Send stop command.""" await self._client.media_player_command( - self._static_info.key, - command=MediaPlayerCommand.STOP, + self._key, command=MediaPlayerCommand.STOP ) async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" await self._client.media_player_command( - self._static_info.key, + self._key, command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE, ) From c7f2dab13c81b77b7a9389b8e9021b0e8d55ef07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 20:31:55 +0200 Subject: [PATCH 464/857] Add climate tests to esphome (#95045) --- .coveragerc | 1 - tests/components/esphome/test_climate.py | 314 +++++++++++++++++++++++ 2 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 tests/components/esphome/test_climate.py diff --git a/.coveragerc b/.coveragerc index 1041a0a05b0..2d8405a8dbd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -309,7 +309,6 @@ omit = homeassistant/components/esphome/bluetooth/* homeassistant/components/esphome/button.py homeassistant/components/esphome/camera.py - homeassistant/components/esphome/climate.py homeassistant/components/esphome/cover.py homeassistant/components/esphome/domain_data.py homeassistant/components/esphome/entry_data.py diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py new file mode 100644 index 00000000000..59072dc2e59 --- /dev/null +++ b/tests/components/esphome/test_climate.py @@ -0,0 +1,314 @@ +"""Test ESPHome climates.""" + + +from unittest.mock import call + +from aioesphomeapi import ( + APIClient, + ClimateAction, + ClimateFanMode, + ClimateInfo, + ClimateMode, + ClimatePreset, + ClimateState, + ClimateSwingMode, +) + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + SWING_BOTH, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + + +async def test_climate_entity( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.COOL, + action=ClimateAction.COOLING, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.state == HVACMode.COOL + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) + mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_step_and_two_point( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + visual_target_temperature_step=2, + visual_current_temperature_step=2, + supports_action=False, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.AUTO], + supported_presets=[ClimatePreset.AWAY, ClimatePreset.ACTIVITY], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.COOL, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.state == HVACMode.COOL + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_HVAC_MODE: HVACMode.AUTO, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + mode=ClimateMode.AUTO, + target_temperature_low=20.0, + target_temperature_high=30.0, + ) + ] + ) + mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_step_and_target_temp( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + visual_target_temperature_step=2, + visual_current_temperature_step=2, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_fan_modes=[ClimateFanMode.LOW, ClimateFanMode.HIGH], + supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.AUTO], + supported_presets=[ClimatePreset.AWAY, ClimatePreset.ACTIVITY], + supported_custom_presets=["preset1", "preset2"], + supported_custom_fan_modes=["fan1", "fan2"], + supported_swing_modes=[ClimateSwingMode.BOTH, ClimateSwingMode.OFF], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.COOL, + action=ClimateAction.COOLING, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.state == HVACMode.COOL + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_HVAC_MODE: HVACMode.AUTO, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + mode=ClimateMode.AUTO, + target_temperature_low=20.0, + target_temperature_high=30.0, + ) + ] + ) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + mode=ClimateMode.HEAT, + ) + ] + ) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "away"}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + preset=ClimatePreset.AWAY, + ) + ] + ) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "preset1"}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, custom_preset="preset1")]) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [call(key=1, fan_mode=ClimateFanMode.HIGH)] + ) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: "fan2"}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, custom_fan_mode="fan2")]) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_SWING_MODE: SWING_BOTH}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [call(key=1, swing_mode=ClimateSwingMode.BOTH)] + ) + mock_client.climate_command.reset_mock() From 66b2214c552be476a81f7f33524db98d101ede2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 21:02:47 +0200 Subject: [PATCH 465/857] Add esphome sensor tests (#95077) --- .coveragerc | 1 - homeassistant/components/esphome/sensor.py | 4 +- tests/components/esphome/test_sensor.py | 182 +++++++++++++++++++++ 3 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 tests/components/esphome/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 2d8405a8dbd..237571a212f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -316,7 +316,6 @@ omit = homeassistant/components/esphome/lock.py homeassistant/components/esphome/media_player.py homeassistant/components/esphome/number.py - homeassistant/components/esphome/sensor.py homeassistant/components/esphome/switch.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 47757247557..6c1fca1ffef 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -95,9 +95,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): def native_value(self) -> datetime | str | None: """Return the state of the entity.""" state = self._state - if math.isnan(state.state): - return None - if state.missing_state: + if math.isnan(state.state) or state.missing_state: return None if self._attr_device_class == SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(state.state) diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py new file mode 100644 index 00000000000..5517198341a --- /dev/null +++ b/tests/components/esphome/test_sensor.py @@ -0,0 +1,182 @@ +"""Test ESPHome sensors.""" +from aioesphomeapi import ( + APIClient, + LastResetType, + SensorInfo, + SensorState, + SensorStateClass as ESPHomeSensorStateClass, + TextSensorInfo, + TextSensorState, +) + +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + + +async def test_generic_numeric_sensor( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic sensor entity.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "50" + + +async def test_generic_numeric_sensor_state_class_measurement( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic sensor entity.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + state_class=ESPHomeSensorStateClass.MEASUREMENT, + device_class="power", + unit_of_measurement="W", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "50" + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + +async def test_generic_numeric_sensor_device_class_timestamp( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a sensor entity that uses timestamp (epoch).""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + device_class="timestamp", + ) + ] + states = [SensorState(key=1, state=1687459432.466624)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "2023-06-22T18:43:52+00:00" + + +async def test_generic_numeric_sensor_legacy_last_reset_convert( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a state class of measurement with last reset type of auto is converted to total increasing.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + last_reset_type=LastResetType.AUTO, + state_class=ESPHomeSensorStateClass.MEASUREMENT, + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "50" + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING + + +async def test_generic_numeric_sensor_missing_state( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic numeric sensor that is missing state.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + ) + ] + states = [SensorState(key=1, state=True, missing_state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_generic_text_sensor( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text sensor entity.""" + entity_info = [ + TextSensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + ) + ] + states = [TextSensorState(key=1, state="i am a teapot")] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "i am a teapot" From 733bca88f12c86e06057d6f312bc35a9922fa3b8 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 22 Jun 2023 21:13:10 +0200 Subject: [PATCH 466/857] Fix KNX device trigger passing info data (#95076) --- .../components/knx/device_trigger.py | 3 ++- tests/components/knx/test_device_trigger.py | 20 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 8a074b43b7d..1abafb221db 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -84,6 +84,7 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" + trigger_data = trigger_info["trigger_data"] dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) job = HassJob(action, f"KNX device trigger {trigger_info}") knx: KNXModule = hass.data[DOMAIN] @@ -95,7 +96,7 @@ async def async_attach_trigger( return hass.async_run_hass_job( job, - {"trigger": telegram}, + {"trigger": {**trigger_data, **telegram}}, ) return knx.telegrams.async_listen_telegram( diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index c7063997585..c3d3ed67b03 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -56,6 +56,7 @@ async def test_if_fires_on_telegram( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) + # "id" field added to action to test if `trigger_data` passed correctly in `async_attach_trigger` assert await async_setup_component( hass, automation.DOMAIN, @@ -71,7 +72,8 @@ async def test_if_fires_on_telegram( "action": { "service": "test.automation", "data_template": { - "catch_all": ("telegram - {{ trigger.destination }}") + "catch_all": ("telegram - {{ trigger.destination }}"), + "id": (" {{ trigger.id }}"), }, }, }, @@ -82,11 +84,13 @@ async def test_if_fires_on_telegram( "device_id": device_entry.id, "type": "telegram", "destination": ["1/2/3", "1/2/4"], + "id": "test-id", }, "action": { "service": "test.automation", "data_template": { - "specific": ("telegram - {{ trigger.destination }}") + "specific": ("telegram - {{ trigger.destination }}"), + "id": (" {{ trigger.id }}"), }, }, }, @@ -96,12 +100,18 @@ async def test_if_fires_on_telegram( await knx.receive_write("0/0/1", (0x03, 0x2F)) assert len(calls) == 1 - assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 0/0/1" + assert test_call.data["id"] == 0 await knx.receive_write("1/2/4", (0x03, 0x2F)) assert len(calls) == 2 - assert calls.pop().data["specific"] == "telegram - 1/2/4" - assert calls.pop().data["catch_all"] == "telegram - 1/2/4" + test_call = calls.pop() + assert test_call.data["specific"] == "telegram - 1/2/4" + assert test_call.data["id"] == "test-id" + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 1/2/4" + assert test_call.data["id"] == 0 async def test_remove_device_trigger( From 3d12c7409d3a5d934e4e3ee204b96dc78ff1a70b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 21:22:29 +0200 Subject: [PATCH 467/857] Add basic light tests to esphome (#95029) --- homeassistant/components/esphome/light.py | 7 +- tests/components/esphome/test_light.py | 139 ++++++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 tests/components/esphome/test_light.py diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 9ba7e474e8c..b44bac0b933 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -135,11 +135,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" _native_supported_color_modes: list[int] - - @property - def _supports_color_mode(self) -> bool: - """Return whether the client supports the new color mode system natively.""" - return self._api_version >= APIVersion(1, 6) + _supports_color_mode = False @property @esphome_state_property @@ -364,6 +360,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """Set attrs from static info.""" super()._on_static_info_update(static_info) static_info = self._static_info + self._supports_color_mode = self._api_version >= APIVersion(1, 6) self._native_supported_color_modes = static_info.supported_color_modes_compat( self._api_version ) diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py new file mode 100644 index 00000000000..df307259e53 --- /dev/null +++ b/tests/components/esphome/test_light.py @@ -0,0 +1,139 @@ +"""Test ESPHome lights.""" + + +from unittest.mock import call + +from aioesphomeapi import ( + APIClient, + APIVersion, + LightColorCapability, + LightInfo, + LightState, +) + +from homeassistant.components.light import ( + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_MIN_MIREDS, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + + +async def test_light_no_color_temp( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity that does not support color temp.""" + mock_client.api_version = APIVersion(1, 7) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[LightColorCapability.BRIGHTNESS], + ) + ] + states = [LightState(key=1, state=True, brightness=100)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_my_light") + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_my_light"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + ) + mock_client.light_command.reset_mock() + + +async def test_light_color_temp( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity that does supports color temp.""" + mock_client.api_version = APIVersion(1, 7) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[ + LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ], + ) + ] + states = [ + LightState( + key=1, + state=True, + brightness=100, + color_temperature=153, + color_mode=LightColorCapability.COLOR_TEMPERATURE, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_my_light") + assert state is not None + assert state.state == STATE_ON + attributes = state.attributes + + assert attributes[ATTR_MAX_MIREDS] == 400 + assert attributes[ATTR_MIN_MIREDS] == 153 + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2500 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6535 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_my_light"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS, + ) + ] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_my_light"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.reset_mock() From fe71ed8c50bb7c0a9a185322ee50017b64fdd04b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jun 2023 21:28:58 +0200 Subject: [PATCH 468/857] Add esphome media player tests (#95069) --- .coveragerc | 1 - tests/components/esphome/test_media_player.py | 250 ++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 tests/components/esphome/test_media_player.py diff --git a/.coveragerc b/.coveragerc index 237571a212f..44ff59bbbde 100644 --- a/.coveragerc +++ b/.coveragerc @@ -314,7 +314,6 @@ omit = homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/light.py homeassistant/components/esphome/lock.py - homeassistant/components/esphome/media_player.py homeassistant/components/esphome/number.py homeassistant/components/esphome/switch.py homeassistant/components/etherscan/sensor.py diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py new file mode 100644 index 00000000000..bcef78e9345 --- /dev/null +++ b/tests/components/esphome/test_media_player.py @@ -0,0 +1,250 @@ +"""Test ESPHome media_players.""" + +from unittest.mock import AsyncMock, Mock, call + +from aioesphomeapi import ( + APIClient, + MediaPlayerCommand, + MediaPlayerEntityState, + MediaPlayerInfo, + MediaPlayerState, +) +import pytest + +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_STOP, + SERVICE_PLAY_MEDIA, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + BrowseMedia, + MediaClass, + MediaType, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import ( + mock_platform, +) +from tests.typing import WebSocketGenerator + + +async def test_media_player_entity( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic media_player entity.""" + entity_info = [ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + ) + ] + states = [ + MediaPlayerEntityState( + key=1, volume=50, muted=True, state=MediaPlayerState.PAUSED + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("media_player.test_my_media_player") + assert state is not None + assert state.state == "paused" + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_MEDIA_VOLUME_MUTED: True, + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.MUTE)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_MEDIA_VOLUME_MUTED: True, + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.MUTE)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_MEDIA_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls([call(1, volume=0.5)]) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PAUSE)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PLAY)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.STOP)] + ) + mock_client.media_player_command.reset_mock() + + +async def test_media_player_entity_with_source( + hass: HomeAssistant, + mock_client: APIClient, + hass_ws_client: WebSocketGenerator, + mock_generic_device_entry, +) -> None: + """Test a generic media_player entity media source.""" + esphome_platform_mock = Mock( + async_get_media_browser_root_object=AsyncMock( + return_value=[ + BrowseMedia( + title="Spotify", + media_class=MediaClass.APP, + media_content_id="", + media_content_type="spotify", + thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", + can_play=False, + can_expand=True, + ) + ] + ), + async_browse_media=AsyncMock( + return_value=BrowseMedia( + title="Spotify Favourites", + media_class=MediaClass.PLAYLIST, + media_content_id="", + media_content_type="spotify", + can_play=True, + can_expand=False, + ) + ), + async_play_media=AsyncMock(return_value=False), + ) + mock_platform(hass, "test.esphome", esphome_platform_mock) + await async_setup_component(hass, "test", {"test": {}}) + await async_setup_component(hass, "media_source", {"media_source": {}}) + await hass.async_block_till_done() + + entity_info = [ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + ) + ] + states = [ + MediaPlayerEntityState( + key=1, volume=50, muted=True, state=MediaPlayerState.PLAYING + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("media_player.test_my_media_player") + assert state is not None + assert state.state == "playing" + + with pytest.raises(media_source.error.Unresolvable): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: "media-source://local/xz", + }, + blocking=True, + ) + + mock_client.media_player_command.reset_mock() + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_my_media_player", + } + ) + response = await client.receive_json() + assert response["success"] + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, + ATTR_MEDIA_CONTENT_ID: "media-source://tts?message=hello", + }, + blocking=True, + ) + + mock_client.media_player_command.assert_has_calls( + [call(1, media_url="media-source://tts?message=hello")] + ) From b8de7df6098e257cf6d0a347d141e5f40cae66ca Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 22 Jun 2023 21:34:23 +0200 Subject: [PATCH 469/857] Implement dew point in weather entity component (#95072) --- homeassistant/components/weather/__init__.py | 44 +++++++++++++++++++ homeassistant/components/weather/const.py | 1 + homeassistant/components/weather/strings.json | 3 ++ tests/components/weather/test_init.py | 34 +++++++++++++- .../custom_components/test/weather.py | 7 +++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 0efaea949e1..5002cf47bb9 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -31,6 +31,7 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRECIPITATION_UNIT, @@ -84,6 +85,8 @@ ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" +ATTR_FORECAST_NATIVE_DEW_POINT: Final = "native_dew_point" +ATTR_FORECAST_DEW_POINT: Final = "dew_point" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -132,6 +135,7 @@ class Forecast(TypedDict, total=False): wind_bearing: float | str | None native_wind_speed: float | None wind_speed: None + native_dew_point: float | None async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -210,6 +214,7 @@ class WeatherEntity(Entity): _attr_native_precipitation_unit: str | None = None _attr_native_wind_speed: float | None = None _attr_native_wind_speed_unit: str | None = None + _attr_native_dew_point: float | None = None _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None @@ -306,6 +311,11 @@ class WeatherEntity(Entity): return self._attr_native_temperature_unit + @property + def native_dew_point(self) -> float | None: + """Return the dew point temperature in native units.""" + return self._attr_native_dew_point + @final @property def temperature_unit(self) -> str | None: @@ -623,6 +633,20 @@ class WeatherEntity(Entity): except (TypeError, ValueError): data[ATTR_WEATHER_APPARENT_TEMPERATURE] = apparent_temperature + if (dew_point := self.native_dew_point) is not None: + from_unit = self.native_temperature_unit or self._default_temperature_unit + to_unit = self._temperature_unit + try: + dew_point_f = float(dew_point) + value_dew_point = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + dew_point_f, from_unit, to_unit + ) + data[ATTR_WEATHER_DEW_POINT] = round_temperature( + value_dew_point, precision + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_DEW_POINT] = dew_point + data[ATTR_WEATHER_TEMPERATURE_UNIT] = self._temperature_unit if (humidity := self.humidity) is not None: @@ -749,6 +773,26 @@ class WeatherEntity(Entity): value_temp_low, precision ) + if ( + forecast_dew_point := forecast_entry.pop( + ATTR_FORECAST_NATIVE_DEW_POINT, + None, + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_dew_point_f = float(forecast_dew_point) + value_dew_point = UNIT_CONVERSIONS[ + ATTR_WEATHER_TEMPERATURE_UNIT + ]( + forecast_dew_point_f, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_DEW_POINT] = round_temperature( + value_dew_point, precision + ) + if ( forecast_pressure := forecast_entry.pop( ATTR_FORECAST_NATIVE_PRESSURE, diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index 95094850ff2..980a6ced2d8 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -20,6 +20,7 @@ from homeassistant.util.unit_conversion import ( ATTR_WEATHER_HUMIDITY = "humidity" ATTR_WEATHER_OZONE = "ozone" +ATTR_WEATHER_DEW_POINT = "dew_point" ATTR_WEATHER_PRESSURE = "pressure" ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" ATTR_WEATHER_APPARENT_TEMPERATURE = "apparent_temperature" diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index e319d42c943..7f05594d2dd 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -42,6 +42,9 @@ "apparent_temperature": { "name": "Apparent temperature" }, + "dew_point": { + "name": "Dew point temperature" + }, "temperature": { "name": "Temperature" }, diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 6ac27f1c2c9..d22bf2c64be 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -7,6 +7,7 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, ATTR_FORECAST_APPARENT_TEMP, + ATTR_FORECAST_DEW_POINT, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, @@ -30,6 +31,7 @@ from homeassistant.components.weather import ( WeatherEntity, round_temperature, ) +from homeassistant.components.weather.const import ATTR_WEATHER_DEW_POINT from homeassistant.const import ( ATTR_FRIENDLY_NAME, PRECISION_HALVES, @@ -66,6 +68,7 @@ class MockWeatherEntity(WeatherEntity): self._attr_native_pressure_unit = UnitOfPressure.HPA self._attr_native_temperature = 20 self._attr_native_apparent_temperature = 25 + self._attr_native_dew_point = 2 self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS self._attr_native_visibility = 30 self._attr_native_visibility_unit = UnitOfLength.KILOMETERS @@ -76,6 +79,7 @@ class MockWeatherEntity(WeatherEntity): datetime=datetime(2022, 6, 20, 20, 00, 00), native_precipitation=1, native_temperature=20, + native_dew_point=2, ) ] @@ -89,6 +93,7 @@ class MockWeatherEntityPrecision(WeatherEntity): self._attr_condition = ATTR_CONDITION_SUNNY self._attr_native_temperature = 20.3 self._attr_native_apparent_temperature = 25.3 + self._attr_native_dew_point = 2.3 self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS self._attr_precision = PRECISION_HALVES @@ -158,16 +163,22 @@ async def test_temperature( hass.config.units = unit_system native_value = 38 apparent_native_value = 45 + dew_point_native_value = 32 state_value = TemperatureConverter.convert(native_value, native_unit, state_unit) apparent_state_value = TemperatureConverter.convert( apparent_native_value, native_unit, state_unit ) + state_value = TemperatureConverter.convert(native_value, native_unit, state_unit) + dew_point_state_value = TemperatureConverter.convert( + dew_point_native_value, native_unit, state_unit + ) entity0 = await create_entity( hass, native_temperature=native_value, native_temperature_unit=native_unit, native_apparent_temperature=apparent_native_value, + native_dew_point=dew_point_native_value, ) state = hass.states.get(entity0.entity_id) @@ -175,17 +186,24 @@ async def test_temperature( expected = state_value apparent_expected = apparent_state_value + dew_point_expected = dew_point_state_value assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == pytest.approx( expected, rel=0.1 ) assert float(state.attributes[ATTR_WEATHER_APPARENT_TEMPERATURE]) == pytest.approx( apparent_expected, rel=0.1 ) + assert float(state.attributes[ATTR_WEATHER_DEW_POINT]) == pytest.approx( + dew_point_expected, rel=0.1 + ) assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit assert float(forecast[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) assert float(forecast[ATTR_FORECAST_APPARENT_TEMP]) == pytest.approx( apparent_expected, rel=0.1 ) + assert float(forecast[ATTR_FORECAST_DEW_POINT]) == pytest.approx( + dew_point_expected, rel=0.1 + ) assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == pytest.approx(expected, rel=0.1) @@ -207,21 +225,33 @@ async def test_temperature_no_unit( """Test temperature when the entity does not declare a native unit.""" hass.config.units = unit_system native_value = 38 + dew_point_native_value = 32 state_value = native_value + dew_point_state_value = dew_point_native_value entity0 = await create_entity( - hass, native_temperature=native_value, native_temperature_unit=native_unit + hass, + native_temperature=native_value, + native_temperature_unit=native_unit, + native_dew_point=dew_point_native_value, ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] expected = state_value + dew_point_expected = dew_point_state_value assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == pytest.approx( expected, rel=0.1 ) + assert float(state.attributes[ATTR_WEATHER_DEW_POINT]) == pytest.approx( + dew_point_expected, rel=0.1 + ) assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit assert float(forecast[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) + assert float(forecast[ATTR_FORECAST_DEW_POINT]) == pytest.approx( + dew_point_expected, rel=0.1 + ) assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == pytest.approx(expected, rel=0.1) @@ -943,7 +973,9 @@ async def test_precision_for_temperature(hass: HomeAssistant) -> None: assert weather.condition == ATTR_CONDITION_SUNNY assert weather.native_temperature == 20.3 + assert weather.native_dew_point == 2.3 assert weather._temperature_unit == UnitOfTemperature.CELSIUS assert weather.precision == PRECISION_HALVES assert weather.state_attributes[ATTR_WEATHER_TEMPERATURE] == 20.5 + assert weather.state_attributes[ATTR_WEATHER_DEW_POINT] == 2.5 diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index df9a3faea3f..121d43c2996 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -6,6 +6,7 @@ from __future__ import annotations from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -52,6 +53,11 @@ class MockWeather(MockEntity, WeatherEntity): """Return the platform apparent temperature.""" return self._handle("native_apparent_temperature") + @property + def native_dew_point(self) -> float | None: + """Return the platform dewpoint temperature.""" + return self._handle("native_dew_point") + @property def native_temperature_unit(self) -> str | None: """Return the unit of measurement for temperature.""" @@ -203,6 +209,7 @@ class MockWeatherMockForecast(MockWeather): ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, ATTR_FORECAST_WIND_BEARING: self.wind_bearing, From eafddaae83d34d11b5cc7a92545b349c2b5df342 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 22 Jun 2023 23:10:36 +0200 Subject: [PATCH 470/857] Implement Cloud coverage in Weather entity component (#95068) --- homeassistant/components/weather/__init__.py | 14 +++++++++++++- homeassistant/components/weather/const.py | 1 + homeassistant/components/weather/strings.json | 3 +++ tests/components/weather/test_init.py | 16 ++++++++++++---- .../custom_components/test/weather.py | 7 +++++++ 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 5002cf47bb9..351f61600f9 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -31,6 +31,7 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, @@ -87,6 +88,7 @@ ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" ATTR_FORECAST_NATIVE_DEW_POINT: Final = "native_dew_point" ATTR_FORECAST_DEW_POINT: Final = "dew_point" +ATTR_FORECAST_CLOUD_COVERAGE: Final = "cloud_coverage" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -124,6 +126,7 @@ class Forecast(TypedDict, total=False): condition: str | None datetime: Required[str] precipitation_probability: int | None + cloud_coverage: int | None native_precipitation: float | None precipitation: None native_pressure: float | None @@ -173,6 +176,7 @@ class WeatherEntity(Entity): _attr_forecast: list[Forecast] | None = None _attr_humidity: float | None = None _attr_ozone: float | None = None + _attr_cloud_coverage: int | None = None _attr_precision: float _attr_pressure: None = ( None # Provide backwards compatibility. Use _attr_native_pressure @@ -481,6 +485,11 @@ class WeatherEntity(Entity): """Return the ozone level.""" return self._attr_ozone + @property + def cloud_coverage(self) -> float | None: + """Return the Cloud coverage in %.""" + return self._attr_cloud_coverage + @final @property def visibility(self) -> float | None: @@ -596,7 +605,7 @@ class WeatherEntity(Entity): @final @property - def state_attributes(self) -> dict[str, Any]: + def state_attributes(self) -> dict[str, Any]: # noqa: C901 """Return the state attributes, converted. Attributes are configured from native units to user-configured units. @@ -655,6 +664,9 @@ class WeatherEntity(Entity): if (ozone := self.ozone) is not None: data[ATTR_WEATHER_OZONE] = ozone + if (cloud_coverage := self.cloud_coverage) is not None: + data[ATTR_WEATHER_CLOUD_COVERAGE] = cloud_coverage + if (pressure := self.native_pressure) is not None: from_unit = self.native_pressure_unit or self._default_pressure_unit to_unit = self._pressure_unit diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index 980a6ced2d8..a0b3ad58750 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -32,6 +32,7 @@ ATTR_WEATHER_WIND_BEARING = "wind_bearing" ATTR_WEATHER_WIND_SPEED = "wind_speed" ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" +ATTR_WEATHER_CLOUD_COVERAGE = "cloud_coverage" DOMAIN: Final = "weather" diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 7f05594d2dd..33507191e01 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -30,6 +30,9 @@ "ozone": { "name": "Ozone" }, + "cloud_coverage": { + "name": "Cloud coverage" + }, "precipitation_unit": { "name": "Precipitation unit" }, diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index d22bf2c64be..bdabc7e9c08 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -31,7 +31,10 @@ from homeassistant.components.weather import ( WeatherEntity, round_temperature, ) -from homeassistant.components.weather.const import ATTR_WEATHER_DEW_POINT +from homeassistant.components.weather.const import ( + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, +) from homeassistant.const import ( ATTR_FRIENDLY_NAME, PRECISION_HALVES, @@ -522,21 +525,26 @@ async def test_precipitation_no_unit( ) -async def test_wind_bearing_and_ozone( +async def test_wind_bearing_ozone_and_cloud_coverage( hass: HomeAssistant, enable_custom_integrations: None, ) -> None: - """Test wind bearing.""" + """Test wind bearing, ozone and cloud coverage.""" wind_bearing_value = 180 ozone_value = 10 + cloud_coverage = 75 entity0 = await create_entity( - hass, wind_bearing=wind_bearing_value, ozone=ozone_value + hass, + wind_bearing=wind_bearing_value, + ozone=ozone_value, + cloud_coverage=cloud_coverage, ) state = hass.states.get(entity0.entity_id) assert float(state.attributes[ATTR_WEATHER_WIND_BEARING]) == 180 assert float(state.attributes[ATTR_WEATHER_OZONE]) == 10 + assert float(state.attributes[ATTR_WEATHER_CLOUD_COVERAGE]) == 75 async def test_none_forecast( diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 121d43c2996..344e879dc08 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -5,6 +5,7 @@ Call init before using it in your tests to ensure clean test data. from __future__ import annotations from homeassistant.components.weather import ( + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -98,6 +99,11 @@ class MockWeather(MockEntity, WeatherEntity): """Return the ozone level.""" return self._handle("ozone") + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage in %.""" + return self._handle("cloud_coverage") + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -210,6 +216,7 @@ class MockWeatherMockForecast(MockWeather): ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, ATTR_FORECAST_WIND_BEARING: self.wind_bearing, From 893b74d77eb3958bdbb729f6ae0a07bbce8e913d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 22 Jun 2023 23:19:51 +0200 Subject: [PATCH 471/857] Add missing test for Apparent temperature in Weather component (#95080) --- tests/components/weather/test_init.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index bdabc7e9c08..af2e1a5d6cb 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -229,14 +229,17 @@ async def test_temperature_no_unit( hass.config.units = unit_system native_value = 38 dew_point_native_value = 32 + apparent_temp_native_value = 45 state_value = native_value dew_point_state_value = dew_point_native_value + apparent_temp_state_value = apparent_temp_native_value entity0 = await create_entity( hass, native_temperature=native_value, native_temperature_unit=native_unit, native_dew_point=dew_point_native_value, + native_apparent_temperature=apparent_temp_native_value, ) state = hass.states.get(entity0.entity_id) @@ -244,18 +247,25 @@ async def test_temperature_no_unit( expected = state_value dew_point_expected = dew_point_state_value + expected_apparent_temp = apparent_temp_state_value assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == pytest.approx( expected, rel=0.1 ) assert float(state.attributes[ATTR_WEATHER_DEW_POINT]) == pytest.approx( dew_point_expected, rel=0.1 ) + assert float(state.attributes[ATTR_WEATHER_APPARENT_TEMPERATURE]) == pytest.approx( + expected_apparent_temp, rel=0.1 + ) assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit assert float(forecast[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) assert float(forecast[ATTR_FORECAST_DEW_POINT]) == pytest.approx( dew_point_expected, rel=0.1 ) assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == pytest.approx(expected, rel=0.1) + assert float(forecast[ATTR_FORECAST_APPARENT_TEMP]) == pytest.approx( + expected_apparent_temp, rel=0.1 + ) @pytest.mark.parametrize("native_unit", (UnitOfPressure.INHG, UnitOfPressure.INHG)) From e68916b2fc6f8084ff2306019ab629782d7a1c65 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 22 Jun 2023 23:48:49 +0200 Subject: [PATCH 472/857] Name unnamed numbers by their device class (#95083) --- homeassistant/components/number/__init__.py | 7 ++ tests/components/number/test_init.py | 129 ++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 2ad63c75e04..9bf1a656efd 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -258,6 +258,13 @@ class NumberEntity(Entity): ATTR_MODE: self.mode, } + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For sensors this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index b039f3c7eb5..80a567df696 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -1,4 +1,5 @@ """The tests for the Number component.""" +from collections.abc import Generator from typing import Any from unittest.mock import MagicMock @@ -14,6 +15,7 @@ from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, + NumberMode, ) from homeassistant.components.number.const import ( DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, @@ -22,6 +24,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, SensorDeviceClass, ) +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -30,16 +33,25 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( + MockConfigEntry, MockEntityPlatform, + MockModule, + MockPlatform, async_mock_restore_state_shutdown_restart, + mock_config_flow, + mock_integration, + mock_platform, mock_restore_cache_with_extra_data, ) +TEST_DOMAIN = "test" + class MockDefaultNumberEntity(NumberEntity): """Mock NumberEntity device to use in tests. @@ -935,3 +947,120 @@ def test_device_classes_aligned() -> None: SENSOR_DEVICE_CLASS_UNITS[device_class] == NUMBER_DEVICE_CLASS_UNITS[device_class] ) + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +async def test_name(hass: HomeAssistant) -> None: + """Test number name.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed sensor without device class -> no name + entity1 = NumberEntity() + entity1.entity_id = "number.test1" + + # Unnamed sensor with device class but has_entity_name False -> no name + entity2 = NumberEntity() + entity2.entity_id = "number.test2" + entity2._attr_device_class = NumberDeviceClass.TEMPERATURE + + # Unnamed sensor with device class and has_entity_name True -> named + entity3 = NumberEntity() + entity3.entity_id = "number.test3" + entity3._attr_device_class = NumberDeviceClass.TEMPERATURE + entity3._attr_has_entity_name = True + + # Unnamed sensor with device class and has_entity_name True -> named + entity4 = NumberEntity() + entity4.entity_id = "number.test4" + entity4.entity_description = NumberEntityDescription( + "test", + NumberDeviceClass.TEMPERATURE, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test number platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state + assert state.attributes == { + "max": 100.0, + "min": 0.0, + "mode": NumberMode.AUTO, + "step": 1.0, + } + + state = hass.states.get(entity2.entity_id) + assert state + assert state.attributes == { + "device_class": "temperature", + "max": 100.0, + "min": 0.0, + "mode": NumberMode.AUTO, + "step": 1.0, + } + + state = hass.states.get(entity3.entity_id) + assert state + assert state.attributes == { + "device_class": "temperature", + "friendly_name": "Temperature", + "max": 100.0, + "min": 0.0, + "mode": NumberMode.AUTO, + "step": 1.0, + } + + state = hass.states.get(entity4.entity_id) + assert state + assert state.attributes == { + "device_class": "temperature", + "friendly_name": "Temperature", + "max": 100.0, + "min": 0.0, + "mode": NumberMode.AUTO, + "step": 1.0, + } From a48030f5dd0203d16a2f60c845e0da83a64103c0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 22 Jun 2023 23:51:41 +0200 Subject: [PATCH 473/857] Name unnamed buttons by their device class (#95084) --- homeassistant/components/button/__init__.py | 7 ++ tests/components/button/test_init.py | 115 +++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 99fe02f7a9d..44fa72c1a67 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -88,6 +88,13 @@ class ButtonEntity(RestoreEntity): _attr_state: None = None __last_pressed: datetime | None = None + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For sensors this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py index 94b2bae88e0..24f893578ce 100644 --- a/tests/components/button/test_init.py +++ b/tests/components/button/test_init.py @@ -1,15 +1,34 @@ """The tests for the Button component.""" +from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.button import DOMAIN, SERVICE_PRESS, ButtonEntity +from homeassistant.components.button import ( + DOMAIN, + SERVICE_PRESS, + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import mock_restore_cache +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) + +TEST_DOMAIN = "test" async def test_button(hass: HomeAssistant) -> None: @@ -68,3 +87,95 @@ async def test_restore_state( await hass.async_block_till_done() assert hass.states.get("button.button_1").state == "2021-01-01T23:59:59+00:00" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +async def test_name(hass: HomeAssistant) -> None: + """Test button name.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed button without device class -> no name + entity1 = ButtonEntity() + entity1.entity_id = "button.test1" + + # Unnamed button with device class but has_entity_name False -> no name + entity2 = ButtonEntity() + entity2.entity_id = "button.test2" + entity2._attr_device_class = ButtonDeviceClass.RESTART + + # Unnamed button with device class and has_entity_name True -> named + entity3 = ButtonEntity() + entity3.entity_id = "button.test3" + entity3._attr_device_class = ButtonDeviceClass.RESTART + entity3._attr_has_entity_name = True + + # Unnamed button with device class and has_entity_name True -> named + entity4 = ButtonEntity() + entity4.entity_id = "sensor.test4" + entity4.entity_description = ButtonEntityDescription( + "test", + ButtonDeviceClass.RESTART, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test button platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state + assert state.attributes == {} + + state = hass.states.get(entity2.entity_id) + assert state + assert state.attributes == {"device_class": "restart"} + + state = hass.states.get(entity3.entity_id) + assert state + assert state.attributes == {"device_class": "restart", "friendly_name": "Restart"} + + state = hass.states.get(entity4.entity_id) + assert state + assert state.attributes == {"device_class": "restart", "friendly_name": "Restart"} From d804d3fca3ab8053412e3b158b2b140206304c22 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 22 Jun 2023 23:56:09 +0200 Subject: [PATCH 474/857] Use snapshot for devolo Home Network diagnostics test (#94966) --- .../devolo_home_network/__init__.py | 2 +- .../snapshots/test_diagnostics.ambr | 37 +++++++++++++++++++ .../devolo_home_network/test_diagnostics.py | 21 ++--------- 3 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 tests/components/devolo_home_network/snapshots/test_diagnostics.ambr diff --git a/tests/components/devolo_home_network/__init__.py b/tests/components/devolo_home_network/__init__.py index 9340f7d2283..ac6a960fd8f 100644 --- a/tests/components/devolo_home_network/__init__.py +++ b/tests/components/devolo_home_network/__init__.py @@ -14,7 +14,7 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: CONF_IP_ADDRESS: IP, CONF_PASSWORD: "test", } - entry = MockConfigEntry(domain=DOMAIN, data=config) + entry = MockConfigEntry(domain=DOMAIN, data=config, entry_id="123456") entry.add_to_hass(hass) return entry diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..236588b87ad --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'device_api': True, + 'features': list([ + 'intmtg1', + 'led', + 'reset', + 'restart', + 'update', + 'wifi1', + ]), + 'firmware': '5.6.1', + 'mt_number': '2730', + 'plcnet_api': True, + 'product': 'dLAN pro 1200+ WiFi ac', + }), + 'entry': dict({ + 'data': dict({ + 'ip_address': '192.0.2.1', + 'password': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'devolo_home_network', + 'entry_id': '123456', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/devolo_home_network/test_diagnostics.py b/tests/components/devolo_home_network/test_diagnostics.py index f56f73aac0a..0248d755e22 100644 --- a/tests/components/devolo_home_network/test_diagnostics.py +++ b/tests/components/devolo_home_network/test_diagnostics.py @@ -2,14 +2,12 @@ from __future__ import annotations import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.devolo_home_network.diagnostics import TO_REDACT -from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import configure_integration -from .const import DISCOVERY_INFO from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,6 +17,7 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = configure_integration(hass) @@ -27,19 +26,5 @@ async def test_entry_diagnostics( assert entry.state == ConfigEntryState.LOADED - entry_dict = entry.as_dict() - for key in TO_REDACT: - entry_dict["data"][key] = REDACTED - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result == { - "entry": entry_dict, - "device_info": { - "mt_number": DISCOVERY_INFO.properties["MT"], - "product": DISCOVERY_INFO.properties["Product"], - "firmware": DISCOVERY_INFO.properties["FirmwareVersion"], - "device_api": True, - "plcnet_api": True, - "features": DISCOVERY_INFO.properties["Features"].split(","), - }, - } + assert result == snapshot From 52a4561c7e430a0f8a6958253adfb48e8d19a183 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Thu, 22 Jun 2023 23:58:43 +0200 Subject: [PATCH 475/857] Code quality update for EDL21 (#94885) --- homeassistant/components/edl21/sensor.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index f3152ce8230..251a25ccc24 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, DEGREE, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -304,12 +303,11 @@ class EDL21: self._registered_obis: set[tuple[str, str]] = set() self._hass = hass self._async_add_entities = async_add_entities - self._name = config.get(CONF_NAME) + self._serial_port = config[CONF_SERIAL_PORT] self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) self._proto.add_listener(self.event, ["SmlGetListResponse"]) LOGGER.debug( - "Initialized EDL21 for %s on %s", - config.get(CONF_NAME), + "Initialized EDL21 on %s", config[CONF_SERIAL_PORT], ) @@ -320,12 +318,14 @@ class EDL21: def event(self, message_body) -> None: """Handle events from pysml.""" assert isinstance(message_body, SmlGetListResponse) - LOGGER.debug("Received sml message for %s: %s", self._name, message_body) + LOGGER.debug("Received sml message on %s: %s", self._serial_port, message_body) electricity_id = message_body["serverId"] if electricity_id is None: - LOGGER.debug("No electricity id found in sml message for %s", self._name) + LOGGER.debug( + "No electricity id found in sml message on %s", self._serial_port + ) return electricity_id = electricity_id.replace(" ", "") @@ -341,14 +341,10 @@ class EDL21: else: entity_description = SENSORS.get(obis) if entity_description: - # self._name is only used for backwards YAML compatibility - # This needs to be cleaned up when YAML support is removed - device_name = self._name or DEFAULT_DEVICE_NAME new_entities.append( EDL21Entity( electricity_id, obis, - device_name, entity_description, telegram, ) @@ -372,7 +368,7 @@ class EDL21Entity(SensorEntity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, electricity_id, obis, device_name, entity_description, telegram): + def __init__(self, electricity_id, obis, entity_description, telegram): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis @@ -384,7 +380,7 @@ class EDL21Entity(SensorEntity): self._attr_unique_id = f"{electricity_id}_{obis}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._electricity_id)}, - name=device_name, + name=DEFAULT_DEVICE_NAME, ) async def async_added_to_hass(self) -> None: From 3db8bcdd500dd57510e32df4ee38a444e29c534d Mon Sep 17 00:00:00 2001 From: Jonas Bergler Date: Fri, 23 Jun 2023 10:00:27 +1200 Subject: [PATCH 476/857] Bump pyemby to 1.9 (#94743) --- homeassistant/components/emby/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json index 340f2395033..f90dda79352 100644 --- a/homeassistant/components/emby/manifest.json +++ b/homeassistant/components/emby/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/emby", "iot_class": "local_push", "loggers": ["pyemby"], - "requirements": ["pyEmby==1.8"] + "requirements": ["pyEmby==1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa23c771068..a2ff678d275 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1522,7 +1522,7 @@ pyEight==0.3.2 pyElectra==1.2.0 # homeassistant.components.emby -pyEmby==1.8 +pyEmby==1.9 # homeassistant.components.hikvision pyHik==0.3.2 From e5afff7f989e686631127bc85854d4c791103e7a Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Thu, 22 Jun 2023 19:04:51 -0300 Subject: [PATCH 477/857] Add the device of the source entity in the helper entities for Riemann sum integral (#94727) --- .../components/integration/sensor.py | 31 ++++++++++- tests/components/integration/test_sensor.py | 52 ++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index b28b426d3af..ad0f96dd540 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -28,7 +28,12 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -140,6 +145,27 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) + source_entity = er.EntityRegistry.async_get(registry, source_entity_id) + dev_reg = dr.async_get(hass) + # Resolve source entity device + if ( + (source_entity is not None) + and (source_entity.device_id is not None) + and ( + ( + device := dev_reg.async_get( + device_id=source_entity.device_id, + ) + ) + is not None + ) + ): + device_info = DeviceInfo( + identifiers=device.identifiers, + ) + else: + device_info = None + unit_prefix = config_entry.options[CONF_UNIT_PREFIX] if unit_prefix == "none": unit_prefix = None @@ -152,6 +178,7 @@ async def async_setup_entry( unique_id=config_entry.entry_id, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], + device_info=device_info, ) async_add_entities([integral]) @@ -194,6 +221,7 @@ class IntegrationSensor(RestoreSensor): unique_id: str | None, unit_prefix: str | None, unit_time: UnitOfTime, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the integration sensor.""" self._attr_unique_id = unique_id @@ -211,6 +239,7 @@ class IntegrationSensor(RestoreSensor): self._attr_icon = "mdi:chart-histogram" self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None + self._attr_device_info = device_info def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 355d13c84d6..5b3734bd1be 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta from freezegun import freeze_time import pytest +from homeassistant.components.integration.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -16,10 +17,15 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data +from tests.common import ( + MockConfigEntry, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) @@ -671,3 +677,47 @@ async def test_calc_errors(hass: HomeAssistant, method) -> None: state = hass.states.get("sensor.integration") assert state is not None assert round(float(state.state)) == 0 if method != "right" else 1 + + +async def test_device_id(hass: HomeAssistant) -> None: + """Test for source entity device for Riemann sum integral.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + source_config_entry = MockConfigEntry() + source_device_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + ) + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + integration_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "integration", + "round": 1.0, + "source": "sensor.test_source", + "unit_prefix": "k", + "unit_time": "min", + }, + title="Integration", + ) + + integration_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity = entity_registry.async_get("sensor.integration") + assert integration_entity is not None + assert integration_entity.device_id == source_entity.device_id From 29ef925d7352f8643952011c3f7577eae85b19da Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Fri, 23 Jun 2023 00:22:07 +0200 Subject: [PATCH 478/857] Add humidity to weather forecast (#95064) * allow humidty in forecast * Add tests * float --------- Co-authored-by: G Johansson --- homeassistant/components/weather/__init__.py | 14 ++++++++++++++ tests/components/weather/test_init.py | 17 +++++++++++++++++ .../custom_components/test/weather.py | 2 ++ 3 files changed, 33 insertions(+) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 351f61600f9..8640e90c639 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -71,6 +71,7 @@ ATTR_CONDITION_WINDY = "windy" ATTR_CONDITION_WINDY_VARIANT = "windy-variant" ATTR_FORECAST = "forecast" ATTR_FORECAST_CONDITION: Final = "condition" +ATTR_FORECAST_HUMIDITY: Final = "humidity" ATTR_FORECAST_NATIVE_PRECIPITATION: Final = "native_precipitation" ATTR_FORECAST_PRECIPITATION: Final = "precipitation" ATTR_FORECAST_PRECIPITATION_PROBABILITY: Final = "precipitation_probability" @@ -125,6 +126,7 @@ class Forecast(TypedDict, total=False): condition: str | None datetime: Required[str] + humidity: float | None precipitation_probability: int | None cloud_coverage: int | None native_precipitation: float | None @@ -869,6 +871,18 @@ class WeatherEntity(Entity): ROUNDING_PRECISION, ) + if ( + forecast_humidity := forecast_entry.pop( + ATTR_FORECAST_HUMIDITY, + None, + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_humidity_f = float(forecast_humidity) + forecast_entry[ATTR_FORECAST_HUMIDITY] = round( + forecast_humidity_f + ) + forecast.append(forecast_entry) data[ATTR_FORECAST] = forecast diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index af2e1a5d6cb..37f0fd62328 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST, ATTR_FORECAST_APPARENT_TEMP, ATTR_FORECAST_DEW_POINT, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, @@ -34,6 +35,7 @@ from homeassistant.components.weather import ( from homeassistant.components.weather.const import ( ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, ) from homeassistant.const import ( ATTR_FRIENDLY_NAME, @@ -557,6 +559,21 @@ async def test_wind_bearing_ozone_and_cloud_coverage( assert float(state.attributes[ATTR_WEATHER_CLOUD_COVERAGE]) == 75 +async def test_humidity( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test humidity.""" + humidity_value = 80.2 + + entity0 = await create_entity(hass, humidity=humidity_value) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + assert float(state.attributes[ATTR_WEATHER_HUMIDITY]) == 80 + assert float(forecast[ATTR_FORECAST_HUMIDITY]) == 80 + + async def test_none_forecast( hass: HomeAssistant, enable_custom_integrations: None, diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 344e879dc08..e4c7e380663 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -6,6 +6,7 @@ from __future__ import annotations from homeassistant.components.weather import ( ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -223,6 +224,7 @@ class MockWeatherMockForecast(MockWeather): ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( "native_precipitation" ), + ATTR_FORECAST_HUMIDITY: self.humidity, } ] From d811fa0e74bcc98a3be182f4ef09ba0cf8be7882 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 22 Jun 2023 18:29:34 -0500 Subject: [PATCH 479/857] Sentence trigger (#94613) * Add async_register_trigger_sentences for default agent * Add trigger response and trigger handler * Check callback in test * Clean up and move response to callback * Add trigger test * Drop TriggerAction * Test we pass sentence to callback * Match triggers once, allow multiple sentences * Don't use trigger id * Use async callback * No response for now * Use asyncio.gather for callback responses * Fix after rebase * Use a list for trigger sentences --------- Co-authored-by: Paulus Schoutsen --- .../components/conversation/default_agent.py | 114 +++++++++++- .../components/conversation/trigger.py | 59 +++++++ .../conversation/test_default_agent.py | 44 ++++- tests/components/conversation/test_trigger.py | 167 ++++++++++++++++++ 4 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/conversation/trigger.py create mode 100644 tests/components/conversation/test_trigger.py diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index b0bbc8e7fec..336d6287f18 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -3,8 +3,9 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass +import functools import logging from pathlib import Path import re @@ -42,6 +43,9 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) +TRIGGER_CALLBACK_TYPE = Callable[ # pylint: disable=invalid-name + [str], Awaitable[str | None] +] def json_load(fp: IO[str]) -> JsonObjectType: @@ -60,6 +64,14 @@ class LanguageIntents: loaded_components: set[str] +@dataclass(slots=True) +class TriggerData: + """List of sentences and the callback for a trigger.""" + + sentences: list[str] + callback: TRIGGER_CALLBACK_TYPE + + def _get_language_variations(language: str) -> Iterable[str]: """Generate language codes with and without region.""" yield language @@ -110,6 +122,10 @@ class DefaultAgent(AbstractConversationAgent): self._config_intents: dict[str, Any] = {} self._slot_lists: dict[str, SlotList] | None = None + # Sentences that will trigger a callback (skipping intent recognition) + self._trigger_sentences: list[TriggerData] = [] + self._trigger_intents: Intents | None = None + @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" @@ -174,6 +190,9 @@ class DefaultAgent(AbstractConversationAgent): async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" + if trigger_result := await self._match_triggers(user_input.text): + return trigger_result + language = user_input.language or self.hass.config.language conversation_id = None # Not supported @@ -605,6 +624,99 @@ class DefaultAgent(AbstractConversationAgent): response_str = lang_intents.error_responses.get(response_key) return response_str or _DEFAULT_ERROR_TEXT + def register_trigger( + self, + sentences: list[str], + callback: TRIGGER_CALLBACK_TYPE, + ) -> core.CALLBACK_TYPE: + """Register a list of sentences that will trigger a callback when recognized.""" + trigger_data = TriggerData(sentences=sentences, callback=callback) + self._trigger_sentences.append(trigger_data) + + # Force rebuild on next use + self._trigger_intents = None + + unregister = functools.partial(self._unregister_trigger, trigger_data) + return unregister + + def _rebuild_trigger_intents(self) -> None: + """Rebuild the HassIL intents object from the current trigger sentences.""" + intents_dict = { + "language": self.hass.config.language, + "intents": { + # Use trigger data index as a virtual intent name for HassIL. + # This works because the intents are rebuilt on every + # register/unregister. + str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]} + for trigger_id, trigger_data in enumerate(self._trigger_sentences) + }, + } + + self._trigger_intents = Intents.from_dict(intents_dict) + _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict) + + def _unregister_trigger(self, trigger_data: TriggerData) -> None: + """Unregister a set of trigger sentences.""" + self._trigger_sentences.remove(trigger_data) + + # Force rebuild on next use + self._trigger_intents = None + + async def _match_triggers(self, sentence: str) -> ConversationResult | None: + """Try to match sentence against registered trigger sentences. + + Calls the registered callbacks if there's a match and returns a positive + conversation result. + """ + if not self._trigger_sentences: + # No triggers registered + return None + + if self._trigger_intents is None: + # Need to rebuild intents before matching + self._rebuild_trigger_intents() + + assert self._trigger_intents is not None + + matched_triggers: set[int] = set() + for result in recognize_all(sentence, self._trigger_intents): + trigger_id = int(result.intent.name) + if trigger_id in matched_triggers: + # Already matched a sentence from this trigger + break + + matched_triggers.add(trigger_id) + + if not matched_triggers: + # Sentence did not match any trigger sentences + return None + + _LOGGER.debug( + "'%s' matched %s trigger(s): %s", + sentence, + len(matched_triggers), + matched_triggers, + ) + + # Gather callback responses in parallel + trigger_responses = await asyncio.gather( + *( + self._trigger_sentences[trigger_id].callback(sentence) + for trigger_id in matched_triggers + ) + ) + + # Use last non-empty result as speech response + speech: str | None = None + for trigger_response in trigger_responses: + speech = speech or trigger_response + + response = intent.IntentResponse(language=self.hass.config.language) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(speech or "") + + return ConversationResult(response=response) + def _make_error_result( language: str, diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py new file mode 100644 index 00000000000..c12808efa53 --- /dev/null +++ b/homeassistant/components/conversation/trigger.py @@ -0,0 +1,59 @@ +"""Offer sentence based automation rules.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.const import CONF_COMMAND, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import HOME_ASSISTANT_AGENT, _get_agent_manager +from .const import DOMAIN +from .default_agent import DefaultAgent + +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): DOMAIN, + vol.Required(CONF_COMMAND): vol.All(cv.ensure_list, [cv.string]), + } +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for events based on configuration.""" + trigger_data = trigger_info["trigger_data"] + sentences = config.get(CONF_COMMAND, []) + + job = HassJob(action) + + @callback + async def call_action(sentence: str) -> str | None: + """Call action with right context.""" + trigger_input: dict[str, Any] = { # Satisfy type checker + **trigger_data, + "platform": DOMAIN, + "sentence": sentence, + } + + # Wait for the automation to complete + if future := hass.async_run_hass_job( + job, + {"trigger": trigger_input}, + ): + await future + + return None + + default_agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) + assert isinstance(default_agent, DefaultAgent) + + return default_agent.register_trigger(sentences, call_action) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 58fe9371e11..899fd761d5e 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -1,5 +1,5 @@ """Test for the default agent.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -223,3 +223,45 @@ async def test_unexposed_entities_skipped( assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(result.response.matched_states) == 1 assert result.response.matched_states[0].entity_id == exposed_light.entity_id + + +async def test_trigger_sentences(hass: HomeAssistant, init_components) -> None: + """Test registering/unregistering/matching a few trigger sentences.""" + trigger_sentences = ["It's party time", "It is time to party"] + trigger_response = "Cowabunga!" + + agent = await conversation._get_agent_manager(hass).async_get_agent( + conversation.HOME_ASSISTANT_AGENT + ) + assert isinstance(agent, conversation.DefaultAgent) + + callback = AsyncMock(return_value=trigger_response) + unregister = agent.register_trigger(trigger_sentences, callback) + + result = await conversation.async_converse(hass, "Not the trigger", None, Context()) + assert result.response.response_type == intent.IntentResponseType.ERROR + + # Using different case and including punctuation + test_sentences = ["it's party time!", "IT IS TIME TO PARTY."] + for sentence in test_sentences: + callback.reset_mock() + result = await conversation.async_converse(hass, sentence, None, Context()) + callback.assert_called_once_with(sentence) + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), sentence + assert result.response.speech == { + "plain": {"speech": trigger_response, "extra_data": None} + } + + unregister() + + # Should produce errors now + callback.reset_mock() + for sentence in test_sentences: + result = await conversation.async_converse(hass, sentence, None, Context()) + assert ( + result.response.response_type == intent.IntentResponseType.ERROR + ), sentence + + assert len(callback.mock_calls) == 0 diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py new file mode 100644 index 00000000000..74a5e4df8e2 --- /dev/null +++ b/tests/components/conversation/test_trigger.py @@ -0,0 +1,167 @@ +"""Test conversation triggers.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +@pytest.fixture(autouse=True) +async def setup_comp(hass): + """Initialize components.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + + +async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None: + """Test the firing of events.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": [ + "Hey yo", + "Ha ha ha", + ], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + } + }, + ) + + await hass.services.async_call( + "conversation", + "process", + { + "text": "Ha ha ha", + }, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["data"] == { + "alias": None, + "id": "0", + "idx": "0", + "platform": "conversation", + "sentence": "Ha ha ha", + } + + +async def test_same_trigger_multiple_sentences( + hass: HomeAssistant, calls, setup_comp +) -> None: + """Test matching of multiple sentences from the same trigger.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["hello", "hello[ world]"], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + } + }, + ) + + await hass.services.async_call( + "conversation", + "process", + { + "text": "hello", + }, + blocking=True, + ) + + # Only triggers once + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["data"] == { + "alias": None, + "id": "0", + "idx": "0", + "platform": "conversation", + "sentence": "hello", + } + + +async def test_same_sentence_multiple_triggers( + hass: HomeAssistant, calls, setup_comp +) -> None: + """Test use of the same sentence in multiple triggers.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + { + "trigger": { + "id": "trigger1", + "platform": "conversation", + "command": [ + "hello", + ], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + }, + { + "trigger": { + "id": "trigger2", + "platform": "conversation", + "command": [ + "hello[ world]", + ], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "conversation", + "process", + { + "text": "hello", + }, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + + # The calls may come in any order + call_datas: set[tuple[str, str, str]] = set() + for call in calls: + call_data = call.data["data"] + call_datas.add((call_data["id"], call_data["platform"], call_data["sentence"])) + + assert call_datas == { + ("trigger1", "conversation", "hello"), + ("trigger2", "conversation", "hello"), + } From 5d365ecb6b8039a7d3eef8bad0019f1762993c4e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 23 Jun 2023 08:22:46 +0200 Subject: [PATCH 480/857] Implement Wind Gust speed in Weather entity component (#95065) * Weather wind gust speed * strings * No compat --- homeassistant/components/weather/__init__.py | 45 ++++++++++++++++++ homeassistant/components/weather/const.py | 1 + homeassistant/components/weather/strings.json | 3 ++ tests/components/weather/test_init.py | 46 +++++++++++++++++++ .../custom_components/test/weather.py | 7 +++ 5 files changed, 102 insertions(+) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 8640e90c639..f24fdc35c5d 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -43,6 +43,7 @@ from .const import ( ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_VISIBILITY_UNIT, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN, @@ -85,6 +86,8 @@ ATTR_FORECAST_NATIVE_TEMP_LOW: Final = "native_templow" ATTR_FORECAST_TEMP_LOW: Final = "templow" ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" +ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: Final = "native_wind_gust_speed" +ATTR_FORECAST_WIND_GUST_SPEED: Final = "wind_gust_speed" ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" ATTR_FORECAST_NATIVE_DEW_POINT: Final = "native_dew_point" @@ -138,6 +141,7 @@ class Forecast(TypedDict, total=False): native_templow: float | None templow: None wind_bearing: float | str | None + native_wind_gust_speed: float | None native_wind_speed: float | None wind_speed: None native_dew_point: float | None @@ -218,6 +222,7 @@ class WeatherEntity(Entity): _attr_native_visibility: float | None = None _attr_native_visibility_unit: str | None = None _attr_native_precipitation_unit: str | None = None + _attr_native_wind_gust_speed: float | None = None _attr_native_wind_speed: float | None = None _attr_native_wind_speed_unit: str | None = None _attr_native_dew_point: float | None = None @@ -418,6 +423,11 @@ class WeatherEntity(Entity): """Return the humidity in native units.""" return self._attr_humidity + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed in native units.""" + return self._attr_native_wind_gust_speed + @final @property def wind_speed(self) -> float | None: @@ -686,6 +696,20 @@ class WeatherEntity(Entity): if (wind_bearing := self.wind_bearing) is not None: data[ATTR_WEATHER_WIND_BEARING] = wind_bearing + if (wind_gust_speed := self.native_wind_gust_speed) is not None: + from_unit = self.native_wind_speed_unit or self._default_wind_speed_unit + to_unit = self._wind_speed_unit + try: + wind_gust_speed_f = float(wind_gust_speed) + value_wind_gust_speed = UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + wind_gust_speed_f, from_unit, to_unit + ) + data[ATTR_WEATHER_WIND_GUST_SPEED] = round( + value_wind_gust_speed, ROUNDING_PRECISION + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_WIND_GUST_SPEED] = wind_gust_speed + if (wind_speed := self.native_wind_speed) is not None: from_unit = self.native_wind_speed_unit or self._default_wind_speed_unit to_unit = self._wind_speed_unit @@ -828,6 +852,27 @@ class WeatherEntity(Entity): ROUNDING_PRECISION, ) + if ( + forecast_wind_gust_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + None, + ) + ) is not None: + from_wind_speed_unit = ( + self.native_wind_speed_unit or self._default_wind_speed_unit + ) + to_wind_speed_unit = self._wind_speed_unit + with suppress(TypeError, ValueError): + forecast_wind_gust_speed_f = float(forecast_wind_gust_speed) + forecast_entry[ATTR_FORECAST_WIND_GUST_SPEED] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + forecast_wind_gust_speed_f, + from_wind_speed_unit, + to_wind_speed_unit, + ), + ROUNDING_PRECISION, + ) + if ( forecast_wind_speed := forecast_entry.pop( ATTR_FORECAST_NATIVE_WIND_SPEED, diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index a0b3ad58750..b995ce2b729 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -29,6 +29,7 @@ ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" ATTR_WEATHER_VISIBILITY = "visibility" ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" ATTR_WEATHER_WIND_BEARING = "wind_bearing" +ATTR_WEATHER_WIND_GUST_SPEED = "wind_gust_speed" ATTR_WEATHER_WIND_SPEED = "wind_speed" ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 33507191e01..53eca9c7f91 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -63,6 +63,9 @@ "wind_bearing": { "name": "Wind bearing" }, + "wind_gust_speed": { + "name": "Wind gust speed" + }, "wind_speed": { "name": "Wind speed" }, diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 37f0fd62328..5ed6a02f24b 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_OZONE, @@ -25,6 +26,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_VISIBILITY_UNIT, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, ROUNDING_PRECISION, @@ -77,6 +79,7 @@ class MockWeatherEntity(WeatherEntity): self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS self._attr_native_visibility = 30 self._attr_native_visibility_unit = UnitOfLength.KILOMETERS + self._attr_native_wind_gust_speed = 10 self._attr_native_wind_speed = 3 self._attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND self._attr_forecast = [ @@ -373,6 +376,49 @@ async def test_wind_speed( ) +@pytest.mark.parametrize( + "native_unit", + ( + UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.METERS_PER_SECOND, + ), +) +@pytest.mark.parametrize( + ("state_unit", "unit_system"), + ( + (UnitOfSpeed.KILOMETERS_PER_HOUR, METRIC_SYSTEM), + (UnitOfSpeed.MILES_PER_HOUR, US_CUSTOMARY_SYSTEM), + ), +) +async def test_wind_gust_speed( + hass: HomeAssistant, + enable_custom_integrations: None, + native_unit: str, + state_unit: str, + unit_system, +) -> None: + """Test wind speed.""" + hass.config.units = unit_system + native_value = 10 + state_value = SpeedConverter.convert(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_wind_gust_speed=native_value, native_wind_speed_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(state.attributes[ATTR_WEATHER_WIND_GUST_SPEED]) == pytest.approx( + expected, rel=1e-2 + ) + assert float(forecast[ATTR_FORECAST_WIND_GUST_SPEED]) == pytest.approx( + expected, rel=1e-2 + ) + + @pytest.mark.parametrize("native_unit", (None,)) @pytest.mark.parametrize( ("state_unit", "unit_system"), diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index e4c7e380663..a5c49fb92c2 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -13,6 +13,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, @@ -80,6 +81,11 @@ class MockWeather(MockEntity, WeatherEntity): """Return the humidity.""" return self._handle("humidity") + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("native_wind_gust_speed") + @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" @@ -219,6 +225,7 @@ class MockWeatherMockForecast(MockWeather): ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, ATTR_FORECAST_WIND_BEARING: self.wind_bearing, ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( From 071679f91f4e03c554ab749ac2265c32685653b8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 23 Jun 2023 11:32:20 +0200 Subject: [PATCH 481/857] Use new attributes in Smhi (#95096) --- homeassistant/components/smhi/const.py | 2 -- homeassistant/components/smhi/weather.py | 19 ++++++++--------- tests/components/smhi/test_weather.py | 26 ++++++++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index ad788923c7d..cc1c4550723 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -3,8 +3,6 @@ from typing import Final from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -ATTR_SMHI_CLOUDINESS: Final = "cloudiness" -ATTR_SMHI_WIND_GUST_SPEED: Final = "wind_gust_speed" ATTR_SMHI_THUNDER_PROBABILITY: Final = "thunder_probability" DOMAIN = "smhi" diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 12781df6891..f8bd03e4e67 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -27,15 +27,17 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ROUNDING_PRECISION, Forecast, WeatherEntity, ) @@ -58,12 +60,9 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.util import Throttle, slugify -from homeassistant.util.unit_conversion import SpeedConverter from .const import ( - ATTR_SMHI_CLOUDINESS, ATTR_SMHI_THUNDER_PROBABILITY, - ATTR_SMHI_WIND_GUST_SPEED, DOMAIN, ENTITY_ID_SENSOR_FORMAT, ) @@ -156,14 +155,7 @@ class SmhiWeather(WeatherEntity): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional attributes.""" if self._forecasts: - wind_gust = SpeedConverter.convert( - self._forecasts[0].wind_gust, - UnitOfSpeed.METERS_PER_SECOND, - self._wind_speed_unit, - ) return { - ATTR_SMHI_CLOUDINESS: self._forecasts[0].cloudiness, - ATTR_SMHI_WIND_GUST_SPEED: round(wind_gust, ROUNDING_PRECISION), ATTR_SMHI_THUNDER_PROBABILITY: self._forecasts[0].thunder, } return None @@ -189,6 +181,8 @@ class SmhiWeather(WeatherEntity): self._attr_wind_bearing = self._forecasts[0].wind_direction self._attr_native_visibility = self._forecasts[0].horizontal_visibility self._attr_native_pressure = self._forecasts[0].pressure + self._attr_native_wind_gust_speed = self._forecasts[0].wind_gust + self._attr_cloud_coverage = self._forecasts[0].cloudiness self._attr_condition = next( ( k @@ -227,6 +221,9 @@ class SmhiWeather(WeatherEntity): ATTR_FORECAST_NATIVE_PRESSURE: forecast.pressure, ATTR_FORECAST_WIND_BEARING: forecast.wind_direction, ATTR_FORECAST_NATIVE_WIND_SPEED: forecast.wind_speed, + ATTR_FORECAST_HUMIDITY: forecast.humidity, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast.wind_gust, + ATTR_FORECAST_CLOUD_COVERAGE: forecast.cloudiness, } ) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 57998b725f9..55b07530c39 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -6,14 +6,11 @@ from unittest.mock import patch import pytest from smhi.smhi_lib import APIURL_TEMPLATE, SmhiForecast, SmhiForecastException -from homeassistant.components.smhi.const import ( - ATTR_SMHI_CLOUDINESS, - ATTR_SMHI_THUNDER_PROBABILITY, - ATTR_SMHI_WIND_GUST_SPEED, -) +from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY from homeassistant.components.smhi.weather import CONDITION_CLASSES, RETRY_TIMEOUT from homeassistant.components.weather import ( ATTR_FORECAST, + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, @@ -21,6 +18,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, @@ -31,6 +29,10 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, ) +from homeassistant.components.weather.const import ( + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_WIND_GUST_SPEED, +) from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN, UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -64,9 +66,9 @@ async def test_setup_hass( assert state assert state.state == "sunny" - assert state.attributes[ATTR_SMHI_CLOUDINESS] == 50 + assert state.attributes[ATTR_WEATHER_CLOUD_COVERAGE] == 50 assert state.attributes[ATTR_SMHI_THUNDER_PROBABILITY] == 33 - assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 16.92 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 16.92 assert state.attributes[ATTR_ATTRIBUTION].find("SMHI") >= 0 assert state.attributes[ATTR_WEATHER_HUMIDITY] == 55 assert state.attributes[ATTR_WEATHER_PRESSURE] == 1024 @@ -85,6 +87,8 @@ async def test_setup_hass( assert forecast[ATTR_FORECAST_PRESSURE] == 1026 assert forecast[ATTR_FORECAST_WIND_BEARING] == 203 assert forecast[ATTR_FORECAST_WIND_SPEED] == 6.12 + assert forecast[ATTR_FORECAST_WIND_GUST_SPEED] == 18.36 + assert forecast[ATTR_FORECAST_CLOUD_COVERAGE] == 100 async def test_properties_no_data(hass: HomeAssistant) -> None: @@ -112,9 +116,9 @@ async def test_properties_no_data(hass: HomeAssistant) -> None: assert ATTR_WEATHER_WIND_SPEED not in state.attributes assert ATTR_WEATHER_WIND_BEARING not in state.attributes assert ATTR_FORECAST not in state.attributes - assert ATTR_SMHI_CLOUDINESS not in state.attributes + assert ATTR_WEATHER_CLOUD_COVERAGE not in state.attributes assert ATTR_SMHI_THUNDER_PROBABILITY not in state.attributes - assert ATTR_SMHI_WIND_GUST_SPEED not in state.attributes + assert ATTR_WEATHER_WIND_GUST_SPEED not in state.attributes async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @@ -337,7 +341,7 @@ async def test_custom_speed_unit( assert state assert state.name == "test" - assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 16.92 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 16.92 entity_reg = er.async_get(hass) entity_reg.async_update_entity_options( @@ -349,4 +353,4 @@ async def test_custom_speed_unit( await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 4.7 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 4.7 From 9656173d5c5be9c5cf9f4b214c287da555e0a135 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 23 Jun 2023 11:37:52 +0200 Subject: [PATCH 482/857] Fix Smhi name (#95097) smhi no name --- homeassistant/components/smhi/weather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index f8bd03e4e67..ece0e4f6d5c 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -127,6 +127,7 @@ class SmhiWeather(WeatherEntity): _attr_native_pressure_unit = UnitOfPressure.HPA _attr_has_entity_name = True + _attr_name = None def __init__( self, From 3d8bf33d039fca4b491078345c6f6b2de494bb86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Jun 2023 12:59:05 +0100 Subject: [PATCH 483/857] Add test coverage for esphome alarm control panels (#95090) --- .coveragerc | 1 - .../esphome/test_alarm_control_panel.py | 211 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/components/esphome/test_alarm_control_panel.py diff --git a/.coveragerc b/.coveragerc index 44ff59bbbde..a5153373770 100644 --- a/.coveragerc +++ b/.coveragerc @@ -305,7 +305,6 @@ omit = homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py homeassistant/components/esphome/__init__.py - homeassistant/components/esphome/alarm_control_panel.py homeassistant/components/esphome/bluetooth/* homeassistant/components/esphome/button.py homeassistant/components/esphome/camera.py diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py new file mode 100644 index 00000000000..ddca7bf60ac --- /dev/null +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -0,0 +1,211 @@ +"""Test ESPHome alarm_control_panels.""" +from unittest.mock import call + +from aioesphomeapi import ( + AlarmControlPanelCommand, + AlarmControlPanelEntityState, + AlarmControlPanelInfo, + AlarmControlPanelState, + APIClient, +) + +from homeassistant.components.alarm_control_panel import ( + ATTR_CODE, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) +from homeassistant.components.esphome.alarm_control_panel import EspHomeACPFeatures +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_ALARM_ARMED_AWAY, +) +from homeassistant.core import HomeAssistant + + +async def test_generic_alarm_control_panel_requires_code( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic alarm_control_panel entity that requires a code.""" + entity_info = [ + AlarmControlPanelInfo( + object_id="myalarm_control_panel", + key=1, + name="my alarm_control_panel", + unique_id="my_alarm_control_panel", + supported_features=EspHomeACPFeatures.ARM_AWAY + | EspHomeACPFeatures.ARM_CUSTOM_BYPASS + | EspHomeACPFeatures.ARM_HOME + | EspHomeACPFeatures.ARM_NIGHT + | EspHomeACPFeatures.ARM_VACATION + | EspHomeACPFeatures.TRIGGER, + requires_code=True, + requires_code_to_arm=True, + ) + ] + states = [ + AlarmControlPanelEntityState(key=1, state=AlarmControlPanelState.ARMED_AWAY) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") + assert state is not None + assert state.state == STATE_ALARM_ARMED_AWAY + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_CODE: 1234, + }, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.ARM_AWAY, "1234")] + ) + mock_client.alarm_control_panel_command.reset_mock() + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_CODE: 1234, + }, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, "1234")] + ) + mock_client.alarm_control_panel_command.reset_mock() + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_CODE: 1234, + }, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.ARM_HOME, "1234")] + ) + mock_client.alarm_control_panel_command.reset_mock() + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_NIGHT, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_CODE: 1234, + }, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.ARM_NIGHT, "1234")] + ) + mock_client.alarm_control_panel_command.reset_mock() + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_VACATION, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_CODE: 1234, + }, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.ARM_VACATION, "1234")] + ) + mock_client.alarm_control_panel_command.reset_mock() + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_TRIGGER, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_CODE: 1234, + }, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.TRIGGER, "1234")] + ) + mock_client.alarm_control_panel_command.reset_mock() + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_CODE: 1234, + }, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.DISARM, "1234")] + ) + mock_client.alarm_control_panel_command.reset_mock() + + +async def test_generic_alarm_control_panel_no_code( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic alarm_control_panel entity that does not require a code.""" + entity_info = [ + AlarmControlPanelInfo( + object_id="myalarm_control_panel", + key=1, + name="my alarm_control_panel", + unique_id="my_alarm_control_panel", + supported_features=EspHomeACPFeatures.ARM_AWAY + | EspHomeACPFeatures.ARM_CUSTOM_BYPASS + | EspHomeACPFeatures.ARM_HOME + | EspHomeACPFeatures.ARM_NIGHT + | EspHomeACPFeatures.ARM_VACATION + | EspHomeACPFeatures.TRIGGER, + requires_code=False, + requires_code_to_arm=False, + ) + ] + states = [ + AlarmControlPanelEntityState(key=1, state=AlarmControlPanelState.ARMED_AWAY) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") + assert state is not None + assert state.state == STATE_ALARM_ARMED_AWAY + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel"}, + blocking=True, + ) + mock_client.alarm_control_panel_command.assert_has_calls( + [call(1, AlarmControlPanelCommand.DISARM, None)] + ) + mock_client.alarm_control_panel_command.reset_mock() From 167f4b475d0ef4e61516077775948c1f74e16c38 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 23 Jun 2023 14:01:31 +0200 Subject: [PATCH 484/857] Clean up device class based entity translations in Verisure (#95082) --- homeassistant/components/verisure/sensor.py | 2 -- homeassistant/components/verisure/strings.json | 8 -------- 2 files changed, 10 deletions(-) diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 7c9639b6542..a4f4d1b4e43 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -47,7 +47,6 @@ class VerisureThermometer( _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_has_entity_name = True - _attr_translation_key = "temperature" _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT @@ -99,7 +98,6 @@ class VerisureHygrometer( _attr_device_class = SensorDeviceClass.HUMIDITY _attr_has_entity_name = True - _attr_translation_key = "humidity" _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index 17feb4a7fe9..85b3f4015b5 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -62,14 +62,6 @@ "ethernet": { "name": "Ethernet status" } - }, - "sensor": { - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - } } } } From e581d6c90b5f54b580d9a7b43084d1989a099187 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jun 2023 14:20:17 +0200 Subject: [PATCH 485/857] Bump Wandalen/wretry.action from 1.2.0 to 1.3.0 (#95098) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cb2a3321d02..3ab93b7128e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1013,7 +1013,7 @@ jobs: uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: Wandalen/wretry.action@v1.2.0 + uses: Wandalen/wretry.action@v1.3.0 with: action: codecov/codecov-action@v3.1.3 with: | @@ -1023,7 +1023,7 @@ jobs: attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: Wandalen/wretry.action@v1.2.0 + uses: Wandalen/wretry.action@v1.3.0 with: action: codecov/codecov-action@v3.1.3 with: | From e5c1ce65df2d5775f16b11b4d7ec90885927d7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 23 Jun 2023 14:26:38 +0200 Subject: [PATCH 486/857] Use entity name in Airzone Cloud sensors (#95102) --- homeassistant/components/airzone_cloud/sensor.py | 11 +++-------- tests/components/airzone_cloud/test_sensor.py | 4 ++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index ee162ef5fec..2bc3f7fbda4 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -6,7 +6,6 @@ from typing import Any, Final from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_HUMIDITY, - AZD_NAME, AZD_TEMP, AZD_WEBSERVERS, AZD_WIFI_RSSI, @@ -42,7 +41,6 @@ AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), @@ -53,9 +51,7 @@ WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_entity_name=True, key=AZD_WIFI_RSSI, - name="RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, ), @@ -65,14 +61,12 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( device_class=SensorDeviceClass.HUMIDITY, key=AZD_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -161,7 +155,7 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, entry, aidoo_id, aidoo_data) - self._attr_name = f"{aidoo_data[AZD_NAME]} {description.name}" + self._attr_has_entity_name = True self._attr_unique_id = f"{aidoo_id}_{description.key}" self.entity_description = description @@ -182,6 +176,7 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, entry, ws_id, ws_data) + self._attr_has_entity_name = True self._attr_unique_id = f"{ws_id}_{description.key}" self.entity_description = description @@ -202,7 +197,7 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, entry, zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" + self._attr_has_entity_name = True self._attr_unique_id = f"{zone_id}_{description.key}" self.entity_description = description diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index 41807fac78b..d9b19f93f7d 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -17,10 +17,10 @@ async def test_airzone_create_sensors( assert state.state == "21.0" # WebServers - state = hass.states.get("sensor.webserver_11_22_33_44_55_66_rssi") + state = hass.states.get("sensor.webserver_11_22_33_44_55_66_signal_strength") assert state.state == "-56" - state = hass.states.get("sensor.webserver_11_22_33_44_55_67_rssi") + state = hass.states.get("sensor.webserver_11_22_33_44_55_67_signal_strength") assert state.state == "-77" # Zones From 239f5fe56b726a740584292a9b528249d8bbf59e Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 23 Jun 2023 14:36:43 +0200 Subject: [PATCH 487/857] Fix glances raid plugin data (#94597) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/glances/sensor.py | 54 +++++++++++----------- tests/components/glances/__init__.py | 52 +++++++++++++++++++++ tests/components/glances/test_sensor.py | 8 ++++ 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index f4a3f882749..e952164792f 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -223,13 +223,6 @@ SENSOR_TYPES = { icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - ("raid", "used"): GlancesSensorEntityDescription( - key="used", - type="raid", - name_suffix="Raid used", - icon="mdi:harddisk", - state_class=SensorStateClass.MEASUREMENT, - ), ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", @@ -237,6 +230,13 @@ SENSOR_TYPES = { icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), + ("raid", "used"): GlancesSensorEntityDescription( + key="used", + type="raid", + name_suffix="Raid used", + icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, + ), } @@ -269,36 +269,36 @@ async def async_setup_entry( if sensor_type in ["fs", "sensors", "raid"]: for sensor_label, params in sensors.items(): for param in params: - sensor_description = SENSOR_TYPES[(sensor_type, param)] + if sensor_description := SENSOR_TYPES.get((sensor_type, param)): + _migrate_old_unique_ids( + hass, + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", + ) + entities.append( + GlancesSensor( + coordinator, + name, + sensor_label, + sensor_description, + ) + ) + else: + for sensor in sensors: + if sensor_description := SENSOR_TYPES.get((sensor_type, sensor)): _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", - f"{sensor_label}-{sensor_description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor_label, + "", sensor_description, ) ) - else: - for sensor in sensors: - sensor_description = SENSOR_TYPES[(sensor_type, sensor)] - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {sensor_description.name_suffix}", - f"-{sensor_description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - sensor_description, - ) - ) async_add_entities(entities) diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 064c5ab0eb5..41f2675c41c 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -137,6 +137,40 @@ MOCK_DATA = { "os_version": "5.15.6-200.fc35.x86_64", "hr_name": "Fedora Linux 35 64bit", }, + "raid": { + "md3": { + "status": "active", + "type": "raid1", + "components": {"sdh1": "2", "sdi1": "0"}, + "available": "2", + "used": "2", + "config": "UU", + }, + "md1": { + "status": "active", + "type": "raid1", + "components": {"sdg": "0", "sde": "1"}, + "available": "2", + "used": "2", + "config": "UU", + }, + "md4": { + "status": "active", + "type": "raid1", + "components": {"sdf1": "1", "sdb1": "0"}, + "available": "2", + "used": "2", + "config": "UU", + }, + "md0": { + "status": "active", + "type": "raid1", + "components": {"sdc": "2", "sdd": "3"}, + "available": "2", + "used": "2", + "config": "UU", + }, + }, "uptime": "3 days, 10:25:20", } @@ -156,4 +190,22 @@ HA_SENSOR_DATA: dict[str, Any] = { "memory_free": 2745.0, }, "docker": {"docker_active": 2, "docker_cpu_use": 77.2, "docker_memory_use": 1149.6}, + "raid": { + "md3": { + "status": "active", + "type": "raid1", + "components": {"sdh1": "2", "sdi1": "0"}, + "available": "2", + "used": "2", + "config": "UU", + }, + "md1": { + "status": "active", + "type": "raid1", + "components": {"sdg": "0", "sde": "1"}, + "available": "2", + "used": "2", + "config": "UU", + }, + }, } diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 2366e10d11b..d7705854720 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -35,6 +35,14 @@ async def test_sensor_states(hass: HomeAssistant) -> None: assert state.state == HA_SENSOR_DATA["docker"]["docker_cpu_use"] if state := hass.states.get("sensor.0_0_0_0_docker_memory_use"): assert state.state == HA_SENSOR_DATA["docker"]["docker_memory_use"] + if state := hass.states.get("sensor.0_0_0_0_md3_available"): + assert state.state == HA_SENSOR_DATA["raid"]["md3"]["available"] + if state := hass.states.get("sensor.0_0_0_0_md3_used"): + assert state.state == HA_SENSOR_DATA["raid"]["md3"]["used"] + if state := hass.states.get("sensor.0_0_0_0_md1_available"): + assert state.state == HA_SENSOR_DATA["raid"]["md1"]["available"] + if state := hass.states.get("sensor.0_0_0_0_md1_used"): + assert state.state == HA_SENSOR_DATA["raid"]["md1"]["used"] @pytest.mark.parametrize( From 6033f39a0dbd0e1503ba37a67e569e8cd6bbfe30 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 23 Jun 2023 14:42:34 +0200 Subject: [PATCH 488/857] Partial revert "Add TypeVar defaults for DataUpdateCoordinator (#95026)" (#95101) * Revert "Add TypeVar defaults for DataUpdateCoordinator and EntityComponent (#95026)" This reverts commit 90f5b1c323102840b6dc553ae5d59cfc70715132. * Don't revert everything --- .../components/bluetooth/passive_update_coordinator.py | 5 +---- homeassistant/helpers/update_coordinator.py | 9 +++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index e2a2f7aef24..6f1749aeef2 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -1,9 +1,7 @@ """Passive update coordinator for the Bluetooth integration.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any - -from typing_extensions import TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import ( @@ -22,7 +20,6 @@ if TYPE_CHECKING: _PassiveBluetoothDataUpdateCoordinatorT = TypeVar( "_PassiveBluetoothDataUpdateCoordinatorT", bound="PassiveBluetoothDataUpdateCoordinator", - default="PassiveBluetoothDataUpdateCoordinator", ) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 911b714cae1..36dd7d27d4a 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -8,12 +8,11 @@ from datetime import datetime, timedelta import logging from random import randint from time import monotonic -from typing import Any, Generic, Protocol +from typing import Any, Generic, Protocol, TypeVar import urllib.error import aiohttp import requests -from typing_extensions import TypeVar from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -31,14 +30,12 @@ from .debounce import Debouncer REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True -_DataT = TypeVar("_DataT", default=dict[str, Any]) +_DataT = TypeVar("_DataT") _BaseDataUpdateCoordinatorT = TypeVar( "_BaseDataUpdateCoordinatorT", bound="BaseDataUpdateCoordinatorProtocol" ) _DataUpdateCoordinatorT = TypeVar( - "_DataUpdateCoordinatorT", - bound="DataUpdateCoordinator[Any]", - default="DataUpdateCoordinator[dict[str, Any]]", + "_DataUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" ) From 91611bbd3bb5501920205ff8c96228293aafd3a5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 23 Jun 2023 14:49:11 +0200 Subject: [PATCH 489/857] Add missing apparent temp in forecast (#95108) --- homeassistant/components/weather/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index f24fdc35c5d..c4a6a0ad777 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -140,6 +140,7 @@ class Forecast(TypedDict, total=False): temperature: None native_templow: float | None templow: None + native_apparent_temperature: float | None wind_bearing: float | str | None native_wind_gust_speed: float | None native_wind_speed: float | None From 983ff10541936586716a0e7db39c65c559a51d5f Mon Sep 17 00:00:00 2001 From: Daniel Kent <129895318+danielkent-net@users.noreply.github.com> Date: Fri, 23 Jun 2023 09:08:28 -0400 Subject: [PATCH 490/857] Fix ESPHome color temperature precision for light entities (#91424) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/light.py | 35 +++++++-- tests/components/esphome/test_light.py | 95 +++++++++++++++++++++-- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index b44bac0b933..a17c49caa73 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -13,7 +13,7 @@ from aioesphomeapi import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, @@ -98,6 +98,20 @@ _COLOR_MODE_MAPPING = { } +def _mired_to_kelvin(mired_temperature: float) -> int: + """Convert absolute mired shift to degrees kelvin. + + This function rounds the converted value instead of flooring the value as + is done in homeassistant.util.color.color_temperature_mired_to_kelvin(). + + If the value of mired_temperature is less than or equal to zero, return + the original value to avoid a divide by zero. + """ + if mired_temperature <= 0: + return round(mired_temperature) + return round(1000000 / mired_temperature) + + def _color_mode_to_ha(mode: int) -> str: """Convert an esphome color mode to a HA color mode constant. @@ -198,8 +212,9 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) if white != 0: - min_ct = self.min_mireds - max_ct = self.max_mireds + static_info = self._static_info + min_ct = static_info.min_mireds + max_ct = static_info.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) color_modes = _filter_color_modes( @@ -216,8 +231,9 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (transition := kwargs.get(ATTR_TRANSITION)) is not None: data["transition_length"] = transition - if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: - data["color_temperature"] = color_temp + if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + # Do not use kelvin_to_mired here to prevent precision loss + data["color_temperature"] = 1000000.0 / color_temp_k if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): color_modes = _filter_color_modes( color_modes, LightColorCapability.COLOR_TEMPERATURE @@ -349,6 +365,12 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """Return the CT color value in mireds.""" return round(self._state.color_temperature) + @property + @esphome_state_property + def color_temp_kelvin(self) -> int: + """Return the CT color value in Kelvin.""" + return _mired_to_kelvin(self._state.color_temperature) + @property @esphome_state_property def effect(self) -> str | None: @@ -385,3 +407,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): self._attr_effect_list = static_info.effects self._attr_min_mireds = round(static_info.min_mireds) self._attr_max_mireds = round(static_info.max_mireds) + if ColorMode.COLOR_TEMP in supported: + self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds) + self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds) diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index df307259e53..a8430be6b49 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -76,8 +76,8 @@ async def test_light_color_temp( key=1, name="my light", unique_id="my_light", - min_mireds=153, - max_mireds=400, + min_mireds=153.846161, + max_mireds=370.370361, supported_color_modes=[ LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF @@ -90,7 +90,7 @@ async def test_light_color_temp( key=1, state=True, brightness=100, - color_temperature=153, + color_temperature=153.846161, color_mode=LightColorCapability.COLOR_TEMPERATURE, ) ] @@ -106,10 +106,93 @@ async def test_light_color_temp( assert state.state == STATE_ON attributes = state.attributes - assert attributes[ATTR_MAX_MIREDS] == 400 assert attributes[ATTR_MIN_MIREDS] == 153 - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2500 - assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6535 + assert attributes[ATTR_MAX_MIREDS] == 370 + + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_my_light"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS, + ) + ] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_my_light"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.reset_mock() + + +async def test_light_color_temp_legacy( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a legacy light entity that does supports color temp.""" + mock_client.api_version = APIVersion(1, 7) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153.846161, + max_mireds=370.370361, + supported_color_modes=[ + LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ], + legacy_supports_brightness=True, + legacy_supports_color_temperature=True, + ) + ] + states = [ + LightState( + key=1, + state=True, + brightness=100, + red=1, + green=1, + blue=1, + white=1, + cold_white=1, + color_temperature=153.846161, + color_mode=19, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_my_light") + assert state is not None + assert state.state == STATE_ON + attributes = state.attributes + + assert attributes[ATTR_MIN_MIREDS] == 153 + assert attributes[ATTR_MAX_MIREDS] == 370 + + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, From 4255cd6bbcb6047edf4ed1109da8950576b0be86 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 23 Jun 2023 15:26:57 +0200 Subject: [PATCH 491/857] Remove invalid Signal Strength device class from Ondilo (#95109) --- homeassistant/components/ondilo_ico/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 129cdf50979..5e226dcead7 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -70,8 +70,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="rssi", name="RSSI", + icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( From 8fd930ba872e147be73b042ec985ac29100caf5a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 23 Jun 2023 15:34:37 +0200 Subject: [PATCH 492/857] Handle LastFM unavailable (#94456) --- homeassistant/components/lastfm/sensor.py | 36 +++++++++++++---------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index d8cf96be5ac..08179df5b7e 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import hashlib -from pylast import LastFMNetwork, Track, User, WSError +from pylast import LastFMNetwork, PyLastError, Track, User import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -104,26 +104,30 @@ class LastFmSensor(SensorEntity): def update(self) -> None: """Update device state.""" + self._attr_native_value = STATE_NOT_SCROBBLING try: - self._user.get_playcount() - except WSError as exc: + play_count = self._user.get_playcount() + self._attr_entity_picture = self._user.get_image() + now_playing = self._user.get_now_playing() + top_tracks = self._user.get_top_tracks(limit=1) + last_tracks = self._user.get_recent_tracks(limit=1) + except PyLastError as exc: self._attr_available = False LOGGER.error("Failed to load LastFM user `%s`: %r", self._user.name, exc) return - self._attr_entity_picture = self._user.get_image() - if now_playing := self._user.get_now_playing(): + self._attr_available = True + if now_playing: self._attr_native_value = format_track(now_playing) - else: - self._attr_native_value = STATE_NOT_SCROBBLING - top_played = None - if top_tracks := self._user.get_top_tracks(limit=1): - top_played = format_track(top_tracks[0].item) - last_played = None - if last_tracks := self._user.get_recent_tracks(limit=1): - last_played = format_track(last_tracks[0].track) - play_count = self._user.get_playcount() self._attr_extra_state_attributes = { - ATTR_LAST_PLAYED: last_played, ATTR_PLAY_COUNT: play_count, - ATTR_TOP_PLAYED: top_played, + ATTR_LAST_PLAYED: None, + ATTR_TOP_PLAYED: None, } + if len(last_tracks) > 0: + self._attr_extra_state_attributes[ATTR_LAST_PLAYED] = format_track( + last_tracks[0].track + ) + if len(top_tracks) > 0: + self._attr_extra_state_attributes[ATTR_TOP_PLAYED] = format_track( + top_tracks[0].item + ) From 85a9654e5284fad3ea2a208898a6a08c4ebda9b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Jun 2023 09:11:19 -0500 Subject: [PATCH 493/857] Remove signal strength device class from hunterdouglas_powerview (#95113) --- homeassistant/components/hunterdouglas_powerview/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 85192e0b7e4..b36457324e1 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -70,7 +70,7 @@ SENSORS: Final = [ PowerviewSensorDescription( key="signal", name="Signal", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, + icon="mdi:signal", native_unit_of_measurement=PERCENTAGE, native_value_fn=lambda shade: round( shade.raw_data[ATTR_SIGNAL_STRENGTH] / ATTR_SIGNAL_STRENGTH_MAX * 100 From 27021241304d128be6c94942ade0b4025598341d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 23 Jun 2023 16:26:34 +0200 Subject: [PATCH 494/857] Motion blinds improve async_request_position_till_stop (#93304) --- .../components/motion_blinds/const.py | 2 + .../components/motion_blinds/cover.py | 40 +++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 332a30a5e5f..d241f03a02e 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -28,4 +28,6 @@ SERVICE_SET_ABSOLUTE_POSITION = "set_absolute_position" UPDATE_INTERVAL = 600 UPDATE_INTERVAL_FAST = 60 +UPDATE_DELAY_STOP = 3 UPDATE_INTERVAL_MOVING = 5 +UPDATE_INTERVAL_MOVING_WIFI = 45 diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index aaf74a96de0..17918133614 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -36,7 +36,9 @@ from .const import ( KEY_VERSION, MANUFACTURER, SERVICE_SET_ABSOLUTE_POSITION, + UPDATE_DELAY_STOP, UPDATE_INTERVAL_MOVING, + UPDATE_INTERVAL_MOVING_WIFI, ) from .gateway import device_name @@ -191,13 +193,15 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): self._blind = blind self._api_lock = coordinator.api_lock - self._requesting_position = False + self._requesting_position: CALLBACK_TYPE | None = None self._previous_positions = [] if blind.device_type in DEVICE_TYPES_WIFI: + self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI via_device = () connections = {(dr.CONNECTION_NETWORK_MAC, blind.mac)} else: + self._update_interval_moving = UPDATE_INTERVAL_MOVING via_device = (DOMAIN, blind._gateway.mac) connections = {} sw_version = None @@ -271,23 +275,29 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): self.current_cover_position == prev_position for prev_position in self._previous_positions ): - # keep updating the position @UPDATE_INTERVAL_MOVING until the position does not change. - async_call_later( - self.hass, UPDATE_INTERVAL_MOVING, self.async_scheduled_update_request + # keep updating the position @self._update_interval_moving until the position does not change. + self._requesting_position = async_call_later( + self.hass, + self._update_interval_moving, + self.async_scheduled_update_request, ) else: self._previous_positions = [] - self._requesting_position = False + self._requesting_position = None + + async def async_request_position_till_stop(self, delay=None): + """Request the position of the blind every self._update_interval_moving seconds until it stops moving.""" + if delay is None: + delay = self._update_interval_moving - async def async_request_position_till_stop(self): - """Request the position of the blind every UPDATE_INTERVAL_MOVING seconds until it stops moving.""" self._previous_positions = [] - if self._requesting_position or self.current_cover_position is None: + if self.current_cover_position is None: return + if self._requesting_position is not None: + self._requesting_position() - self._requesting_position = True - async_call_later( - self.hass, UPDATE_INTERVAL_MOVING, self.async_scheduled_update_request + self._requesting_position = async_call_later( + self.hass, delay, self.async_scheduled_update_request ) async def async_open_cover(self, **kwargs: Any) -> None: @@ -334,6 +344,8 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) + await self.async_request_position_till_stop(delay=UPDATE_DELAY_STOP) + class MotionTiltDevice(MotionPositionDevice): """Representation of a Motion Blind Device.""" @@ -378,6 +390,8 @@ class MotionTiltDevice(MotionPositionDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) + await self.async_request_position_till_stop(delay=UPDATE_DELAY_STOP) + class MotionTiltOnlyDevice(MotionTiltDevice): """Representation of a Motion Blind Device.""" @@ -507,3 +521,5 @@ class MotionTDBUDevice(MotionPositionDevice): """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop, self._motor_key) + + await self.async_request_position_till_stop(delay=UPDATE_DELAY_STOP) From cd66da0ab19dfbae6db891c1679c9c37f58dc449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 23 Jun 2023 16:32:40 +0200 Subject: [PATCH 495/857] Add Airzone Cloud Binary Sensors support (#93583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone_cloud: add Binary Sensors support Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: binary_sensor: fix copy&paste Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: remote available attribute This is not working as expected and will require minor library changes. Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: binary_sensor: remove unique_id Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: binary_sensors: remove name Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: use entity_name Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: binary_sensor: add name=None Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: binary_sensor: fix device class name Signed-off-by: Álvaro Fernández Rojas * Update homeassistant/components/airzone_cloud/binary_sensor.py Co-authored-by: G Johansson --------- Signed-off-by: Álvaro Fernández Rojas Co-authored-by: G Johansson --- .../components/airzone_cloud/__init__.py | 5 +- .../components/airzone_cloud/binary_sensor.py | 108 ++++++++++++++++++ .../airzone_cloud/test_binary_sensor.py | 21 ++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/airzone_cloud/binary_sensor.py create mode 100644 tests/components/airzone_cloud/test_binary_sensor.py diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index cdc0f30a533..732f159c381 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -12,7 +12,10 @@ from homeassistant.helpers import aiohttp_client from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py new file mode 100644 index 00000000000..1bd42118835 --- /dev/null +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -0,0 +1,108 @@ +"""Support for the Airzone Cloud binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Final + +from aioairzone_cloud.const import AZD_PROBLEMS, AZD_WARNINGS, AZD_ZONES + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + + +@dataclass +class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes Airzone Cloud binary sensor entities.""" + + attributes: dict[str, str] | None = None + + +ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = ( + AirzoneBinarySensorEntityDescription( + attributes={ + "warnings": AZD_WARNINGS, + }, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + key=AZD_PROBLEMS, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud binary sensors from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + binary_sensors: list[AirzoneBinarySensor] = [] + + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): + for description in ZONE_BINARY_SENSOR_TYPES: + if description.key in zone_data: + binary_sensors.append( + AirzoneZoneBinarySensor( + coordinator, + description, + entry, + zone_id, + zone_data, + ) + ) + + async_add_entities(binary_sensors) + + +class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity): + """Define an Airzone Cloud binary sensor.""" + + entity_description: AirzoneBinarySensorEntityDescription + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update binary sensor attributes.""" + self._attr_is_on = self.get_airzone_value(self.entity_description.key) + if self.entity_description.attributes: + self._attr_extra_state_attributes = { + key: self.get_airzone_value(val) + for key, val in self.entity_description.attributes.items() + } + + +class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): + """Define an Airzone Cloud Zone binary sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneBinarySensorEntityDescription, + entry: ConfigEntry, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, zone_id, zone_data) + + self._attr_unique_id = f"{zone_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py new file mode 100644 index 00000000000..b2c9ee173b7 --- /dev/null +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -0,0 +1,21 @@ +"""The binary sensor tests for the Airzone Cloud platform.""" + +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: + """Test creation of binary sensors.""" + + await async_init_integration(hass) + + # Zones + state = hass.states.get("binary_sensor.dormitorio_problem") + assert state.state == STATE_OFF + assert state.attributes.get("warnings") is None + + state = hass.states.get("binary_sensor.salon_problem") + assert state.state == STATE_OFF + assert state.attributes.get("warnings") is None From 31a2b2e3a991fd8ab8f27e6f3f01f931e8ed366a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 23 Jun 2023 17:24:57 +0200 Subject: [PATCH 496/857] Clean up device class based entity translations in Rituals Perfume Genie (#95124) --- .../components/rituals_perfume_genie/binary_sensor.py | 1 - homeassistant/components/rituals_perfume_genie/sensor.py | 1 - .../components/rituals_perfume_genie/strings.json | 8 -------- 3 files changed, 10 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 19732169b86..73499fb5ccc 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -39,7 +39,6 @@ class RitualsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsBinarySensorEntityDescription( key="charging", - translation_key="charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda diffuser: diffuser.charging, diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 944efb21536..09189dabfad 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -40,7 +40,6 @@ class RitualsSensorEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsSensorEntityDescription( key="battery_percentage", - translation_key="battery_percentage", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, value_fn=lambda diffuser: diffuser.battery_percentage, diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json index f4570dd4cfc..48e9be670ec 100644 --- a/homeassistant/components/rituals_perfume_genie/strings.json +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -19,11 +19,6 @@ } }, "entity": { - "binary_sensor": { - "charging": { - "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" - } - }, "number": { "perfume_amount": { "name": "Perfume amount" @@ -35,9 +30,6 @@ } }, "sensor": { - "battery_percentage": { - "name": "[%key:component::sensor::entity_component::battery::name%]" - }, "fill": { "name": "Fill" }, From 00075520c2d354ced2191112a737db3fc9f71944 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Fri, 23 Jun 2023 13:48:29 -0300 Subject: [PATCH 497/857] Add `volatile_organic_compounds_parts` to device class selector strings for Scrape (#95128) Add to device class selector strings for Scrape --- homeassistant/components/scrape/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 857d53eb527..e5ed8613fc4 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -171,6 +171,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", From 3f10233833b88579716ae87310c064f8fa222cd4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 23 Jun 2023 13:32:03 -0400 Subject: [PATCH 498/857] Add return value to conversation.process service (#94740) * Add return value to conversation.process service * Adjust for new API --- .../components/conversation/__init__.py | 18 ++- .../conversation/snapshots/test_init.ambr | 120 ++++++++++++++++++ tests/components/conversation/test_init.py | 28 +++- 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f0cd6cb504c..f3d883b1565 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -16,6 +16,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -154,12 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if config_intents := config.get(DOMAIN, {}).get("intents"): hass.data[DATA_CONFIG] = config_intents - async def handle_process(service: core.ServiceCall) -> None: + async def handle_process(service: core.ServiceCall) -> core.ServiceResponse: """Parse text into commands.""" text = service.data[ATTR_TEXT] _LOGGER.debug("Processing: <%s>", text) try: - await async_converse( + result = await async_converse( hass=hass, text=text, conversation_id=None, @@ -168,7 +169,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: agent_id=service.data.get(ATTR_AGENT_ID), ) except intent.IntentHandleError as err: - _LOGGER.error("Error processing %s: %s", text, err) + raise HomeAssistantError(f"Error processing {text}: {err}") from err + + if service.return_response: + return result.as_dict() + + return None async def handle_reload(service: core.ServiceCall) -> None: """Reload intents.""" @@ -176,7 +182,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) hass.services.async_register( - DOMAIN, SERVICE_PROCESS, handle_process, schema=SERVICE_PROCESS_SCHEMA + DOMAIN, + SERVICE_PROCESS, + handle_process, + schema=SERVICE_PROCESS_SCHEMA, + supports_response=core.SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_RELOAD, handle_reload, schema=SERVICE_RELOAD_SCHEMA diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 38a7ed92b52..f4325e2f291 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -222,6 +222,126 @@ ]), }) # --- +# name: test_turn_on_intent[turn kitchen on-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[turn kitchen on-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[turn on kitchen-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[turn on kitchen-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- # name: test_ws_get_agent_info dict({ 'attribution': dict({ diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 7c0cc54b91d..b55bd651b9e 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -12,6 +12,7 @@ from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -873,7 +874,7 @@ async def test_http_processing_intent_conversion_not_expose_new( @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on")) async def test_turn_on_intent( - hass: HomeAssistant, init_components, sentence, agent_id + hass: HomeAssistant, init_components, sentence, agent_id, snapshot ) -> None: """Test calling the turn on intent.""" hass.states.async_set("light.kitchen", "off") @@ -882,8 +883,13 @@ async def test_turn_on_intent( data = {conversation.ATTR_TEXT: sentence} if agent_id is not None: data[conversation.ATTR_AGENT_ID] = agent_id - await hass.services.async_call("conversation", "process", data) - await hass.async_block_till_done() + result = await hass.services.async_call( + "conversation", + "process", + data, + blocking=True, + return_response=True, + ) assert len(calls) == 1 call = calls[0] @@ -891,6 +897,22 @@ async def test_turn_on_intent( assert call.service == "turn_on" assert call.data == {"entity_id": ["light.kitchen"]} + assert result == snapshot + + +async def test_service_fails(hass: HomeAssistant, init_components) -> None: + """Test calling the turn on intent.""" + with pytest.raises(HomeAssistantError), patch( + "homeassistant.components.conversation.async_converse", + side_effect=intent.IntentHandleError, + ): + await hass.services.async_call( + "conversation", + "process", + {"text": "bla"}, + blocking=True, + ) + @pytest.mark.parametrize("sentence", ("turn off kitchen", "turn kitchen off")) async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) -> None: From c42d0feec1d8d6bf109b7f8cbf7023451f48744e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 23 Jun 2023 22:29:56 -0400 Subject: [PATCH 499/857] Allow passing in device_id to pipeline run WS API (#95139) --- homeassistant/components/assist_pipeline/pipeline.py | 2 ++ .../components/assist_pipeline/websocket_api.py | 2 ++ .../assist_pipeline/snapshots/test_init.ambr | 6 ++++++ .../assist_pipeline/snapshots/test_websocket.ambr | 10 ++++++++++ tests/components/assist_pipeline/test_websocket.py | 3 ++- 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index d08e1fc3e50..4a811b25f1f 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -512,6 +512,8 @@ class PipelineRun: "engine": self.intent_agent, "language": self.pipeline.conversation_language, "intent_input": intent_input, + "conversation_id": conversation_id, + "device_id": device_id, }, ) ) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index bd2ec53db40..ea3aacf43a4 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -56,6 +56,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: vol.Optional("input"): dict, vol.Optional("pipeline"): str, vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("device_id"): vol.Any(str, None), vol.Optional("timeout"): vol.Any(float, int), }, ), @@ -105,6 +106,7 @@ async def websocket_run( # Arguments to PipelineInput input_args: dict[str, Any] = { "conversation_id": msg.get("conversation_id"), + "device_id": msg.get("device_id"), } if start_stage == PipelineStage.STT: diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 619c59606ed..d8858cec4b6 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -32,6 +32,8 @@ }), dict({ 'data': dict({ + 'conversation_id': None, + 'device_id': None, 'engine': 'homeassistant', 'intent_input': 'test transcript', 'language': 'en', @@ -119,6 +121,8 @@ }), dict({ 'data': dict({ + 'conversation_id': None, + 'device_id': None, 'engine': 'homeassistant', 'intent_input': 'test transcript', 'language': 'en-US', @@ -206,6 +210,8 @@ }), dict({ 'data': dict({ + 'conversation_id': None, + 'device_id': None, 'engine': 'homeassistant', 'intent_input': 'test transcript', 'language': 'en-US', diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index a2e5ac72b07..12a4d766f06 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -31,6 +31,8 @@ # --- # name: test_audio_pipeline.3 dict({ + 'conversation_id': None, + 'device_id': None, 'engine': 'homeassistant', 'intent_input': 'test transcript', 'language': 'en', @@ -107,6 +109,8 @@ # --- # name: test_audio_pipeline_debug.3 dict({ + 'conversation_id': None, + 'device_id': None, 'engine': 'homeassistant', 'intent_input': 'test transcript', 'language': 'en', @@ -163,6 +167,8 @@ # --- # name: test_intent_failed.1 dict({ + 'conversation_id': None, + 'device_id': None, 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', 'language': 'en', @@ -180,6 +186,8 @@ # --- # name: test_intent_timeout.1 dict({ + 'conversation_id': None, + 'device_id': None, 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', 'language': 'en', @@ -249,6 +257,8 @@ # --- # name: test_text_only_pipeline.1 dict({ + 'conversation_id': 'mock-conversation-id', + 'device_id': 'mock-device-id', 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', 'language': 'en', diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 23044073368..4ebf0a1fb98 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -28,6 +28,8 @@ async def test_text_only_pipeline( "start_stage": "intent", "end_stage": "intent", "input": {"text": "Are the lights on?"}, + "conversation_id": "mock-conversation-id", + "device_id": "mock-device-id", } ) @@ -954,7 +956,6 @@ async def test_list_pipelines( ) -> None: """Test we can list pipelines.""" client = await hass_ws_client(hass) - hass.data[DOMAIN] await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"}) msg = await client.receive_json() From 65454c945dc43bf64bcaf976774def8399a4bcc9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 23 Jun 2023 22:28:13 -0500 Subject: [PATCH 500/857] Add VAD sensitivity option to VoIP devices (#94688) * Add VAD sensitivity option to VoIP devices * Use select entitiy for VAD sensitivity * Add sensitivity to tests * Add to assist pipeline tests * Update homeassistant/components/assist_pipeline/select.py Co-authored-by: Paulus Schoutsen * Update tests/components/voip/test_voip.py --------- Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/select.py | 51 +++++++++++++++++ .../components/assist_pipeline/strings.json | 8 +++ .../components/assist_pipeline/vad.py | 24 ++++++++ homeassistant/components/voip/select.py | 27 +++++++-- homeassistant/components/voip/strings.json | 8 +++ homeassistant/components/voip/voip.py | 24 +++++++- .../components/assist_pipeline/test_select.py | 55 +++++++++++++++++-- tests/components/voip/test_select.py | 15 +++++ tests/components/voip/test_voip.py | 8 ++- 9 files changed, 205 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 8e9f11252be..2ae46fcb9ac 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -11,6 +11,7 @@ from homeassistant.helpers import collection, entity_registry as er, restore_sta from .const import DOMAIN from .pipeline import PipelineData, PipelineStorageCollection +from .vad import VadSensitivity OPTION_PREFERRED = "preferred" @@ -38,6 +39,25 @@ def get_chosen_pipeline( ) +@callback +def get_vad_sensitivity( + hass: HomeAssistant, domain: str, unique_id_prefix: str +) -> VadSensitivity: + """Get the chosen vad sensitivity for a domain.""" + ent_reg = er.async_get(hass) + sensitivity_entity_id = ent_reg.async_get_entity_id( + Platform.SELECT, domain, f"{unique_id_prefix}-vad_sensitivity" + ) + if sensitivity_entity_id is None: + return VadSensitivity.DEFAULT + + state = hass.states.get(sensitivity_entity_id) + if state is None: + return VadSensitivity.DEFAULT + + return VadSensitivity(state.state) + + class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): """Entity to represent a pipeline selector.""" @@ -102,3 +122,34 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): if self._attr_current_option not in options: self._attr_current_option = OPTION_PREFERRED + + +class VadSensitivitySelect(SelectEntity, restore_state.RestoreEntity): + """Entity to represent VAD sensitivity.""" + + entity_description = SelectEntityDescription( + key="vad_sensitivity", + translation_key="vad_sensitivity", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = VadSensitivity.DEFAULT.value + _attr_options = [vs.value for vs in VadSensitivity] + + def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None: + """Initialize a pipeline selector.""" + self._attr_unique_id = f"{unique_id_prefix}-vad_sensitivity" + self.hass = hass + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index d85eb1aaed9..edcdff752f6 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -11,6 +11,14 @@ "state": { "preferred": "Preferred" } + }, + "vad_sensitivity": { + "name": "Silence sensitivity", + "state": { + "default": "Default", + "aggressive": "Aggressive", + "relaxed": "Relaxed" + } } } } diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index c5f87f1336a..f76de39ccce 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,11 +1,35 @@ """Voice activity detection.""" +from __future__ import annotations + from dataclasses import dataclass, field import webrtcvad +from homeassistant.backports.enum import StrEnum + _SAMPLE_RATE = 16000 +class VadSensitivity(StrEnum): + """How quickly the end of a voice command is detected.""" + + DEFAULT = "default" + RELAXED = "relaxed" + AGGRESSIVE = "aggressive" + + @staticmethod + def to_seconds(sensitivity: VadSensitivity | str) -> float: + """Return seconds of silence for sensitivity level.""" + sensitivity = VadSensitivity(sensitivity) + if sensitivity == VadSensitivity.RELAXED: + return 2.0 + + if sensitivity == VadSensitivity.AGGRESSIVE: + return 0.5 + + return 1.0 + + @dataclass class VoiceCommandSegmenter: """Segments an audio stream into voice commands using webrtcvad.""" diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py index 7383e1b886a..94a3aacc0fd 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -4,7 +4,10 @@ from __future__ import annotations from typing import TYPE_CHECKING -from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.assist_pipeline.select import ( + AssistPipelineSelect, + VadSensitivitySelect, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,13 +31,18 @@ async def async_setup_entry( @callback def async_add_device(device: VoIPDevice) -> None: """Add device.""" - async_add_entities([VoipPipelineSelect(hass, device)]) + async_add_entities( + [VoipPipelineSelect(hass, device), VoipVadSensitivitySelect(hass, device)] + ) domain_data.devices.async_add_new_device_listener(async_add_device) - async_add_entities( - [VoipPipelineSelect(hass, device) for device in domain_data.devices] - ) + entities: list[VoIPEntity] = [] + for device in domain_data.devices: + entities.append(VoipPipelineSelect(hass, device)) + entities.append(VoipVadSensitivitySelect(hass, device)) + + async_add_entities(entities) class VoipPipelineSelect(VoIPEntity, AssistPipelineSelect): @@ -44,3 +52,12 @@ class VoipPipelineSelect(VoIPEntity, AssistPipelineSelect): """Initialize a pipeline selector.""" VoIPEntity.__init__(self, device) AssistPipelineSelect.__init__(self, hass, device.voip_id) + + +class VoipVadSensitivitySelect(VoIPEntity, VadSensitivitySelect): + """VAD sensitivity selector for VoIP devices.""" + + def __init__(self, hass: HomeAssistant, device: VoIPDevice) -> None: + """Initialize a VAD sensitivity selector.""" + VoIPEntity.__init__(self, device) + VadSensitivitySelect.__init__(self, hass, device.voip_id) diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 2bef9a18008..8bcbb06d4e2 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -26,6 +26,14 @@ "state": { "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } + }, + "vad_sensitivity": { + "name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]", + "state": { + "default": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::default%]", + "aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]", + "relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]" + } } } }, diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index d7e261508fd..32cfbd70337 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -23,12 +23,21 @@ from homeassistant.components.assist_pipeline import ( async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.components.assist_pipeline.vad import ( + VadSensitivity, + VoiceCommandSegmenter, +) from homeassistant.const import __version__ from homeassistant.core import Context, HomeAssistant from homeassistant.util.ulid import ulid -from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH +from .const import ( + CHANNELS, + DOMAIN, + RATE, + RTP_AUDIO_SETTINGS, + WIDTH, +) if TYPE_CHECKING: from .devices import VoIPDevice, VoIPDevices @@ -63,6 +72,12 @@ def make_protocol( opus_payload_type=call_info.opus_payload_type, ) + vad_sensitivity = pipeline_select.get_vad_sensitivity( + hass, + DOMAIN, + voip_device.voip_id, + ) + # Pipeline is properly configured return PipelineRtpDatagramProtocol( hass, @@ -70,6 +85,7 @@ def make_protocol( voip_device, Context(user_id=devices.config_entry.data["user"]), opus_payload_type=call_info.opus_payload_type, + silence_seconds=VadSensitivity.to_seconds(vad_sensitivity), ) @@ -130,6 +146,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): error_tone_enabled: bool = True, tone_delay: float = 0.2, tts_extra_timeout: float = 1.0, + silence_seconds: float = 1.0, ) -> None: """Set up pipeline RTP server.""" super().__init__( @@ -151,6 +168,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self.error_tone_enabled = error_tone_enabled self.tone_delay = tone_delay self.tts_extra_timeout = tts_extra_timeout + self.silence_seconds = silence_seconds self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() self._context = context @@ -199,7 +217,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): try: # Wait for speech before starting pipeline - segmenter = VoiceCommandSegmenter() + segmenter = VoiceCommandSegmenter(silence_seconds=self.silence_seconds) chunk_buffer: deque[bytes] = deque( maxlen=self.buffered_chunks_before_speech, ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 2bc580864d7..bb9c4d45a32 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -9,7 +9,11 @@ from homeassistant.components.assist_pipeline.pipeline import ( PipelineData, PipelineStorageCollection, ) -from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.assist_pipeline.select import ( + AssistPipelineSelect, + VadSensitivitySelect, +) +from homeassistant.components.assist_pipeline.vad import VadSensitivity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -30,11 +34,15 @@ class SelectPlatform(MockPlatform): async_add_entities: AddEntitiesCallback, ) -> None: """Set up fake select platform.""" - entity = AssistPipelineSelect(hass, "test") - entity._attr_device_info = DeviceInfo( + pipeline_entity = AssistPipelineSelect(hass, "test") + pipeline_entity._attr_device_info = DeviceInfo( identifiers={("test", "test")}, ) - async_add_entities([entity]) + sensitivity_entity = VadSensitivitySelect(hass, "test") + sensitivity_entity._attr_device_info = DeviceInfo( + identifiers={("test", "test")}, + ) + async_add_entities([pipeline_entity, sensitivity_entity]) @pytest.fixture @@ -95,6 +103,7 @@ async def test_select_entity_registering_device( """Test entity registering as an assist device.""" dev_reg = dr.async_get(hass) device = dev_reg.async_get_device({("test", "test")}) + assert device is not None # Test device is registered assert pipeline_data.pipeline_devices == {device.id} @@ -138,6 +147,7 @@ async def test_select_entity_changing_pipelines( ) state = hass.states.get("select.assist_pipeline_test_pipeline") + assert state is not None assert state.state == pipeline_2.name # Reload config entry to test selected option persists @@ -145,15 +155,52 @@ async def test_select_entity_changing_pipelines( assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") state = hass.states.get("select.assist_pipeline_test_pipeline") + assert state is not None assert state.state == pipeline_2.name # Remove selected pipeline await pipeline_storage.async_delete_item(pipeline_2.id) state = hass.states.get("select.assist_pipeline_test_pipeline") + assert state is not None assert state.state == "preferred" assert state.attributes["options"] == [ "preferred", "Home Assistant", pipeline_1.name, ] + + +async def test_select_entity_changing_vad_sensitivity( + hass: HomeAssistant, + init_select: ConfigEntry, +) -> None: + """Test entity tracking pipeline changes.""" + config_entry = init_select # nicer naming + + state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") + assert state is not None + assert state.state == VadSensitivity.DEFAULT.value + + # Change select to new pipeline + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.assist_pipeline_test_vad_sensitivity", + "option": VadSensitivity.AGGRESSIVE.value, + }, + blocking=True, + ) + + state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") + assert state is not None + assert state.state == VadSensitivity.AGGRESSIVE.value + + # Reload config entry to test selected option persists + assert await hass.config_entries.async_forward_entry_unload(config_entry, "select") + assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") + + state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") + assert state is not None + assert state.state == VadSensitivity.AGGRESSIVE.value diff --git a/tests/components/voip/test_select.py b/tests/components/voip/test_select.py index 19c3202576a..9d45477a429 100644 --- a/tests/components/voip/test_select.py +++ b/tests/components/voip/test_select.py @@ -17,3 +17,18 @@ async def test_pipeline_select( state = hass.states.get("select.192_168_1_210_assist_pipeline") assert state is not None assert state.state == "preferred" + + +async def test_vad_sensitivity_select( + hass: HomeAssistant, + config_entry: ConfigEntry, + voip_device: VoIPDevice, +) -> None: + """Test VAD sensitivity select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + state = hass.states.get("select.192_168_1_210_silence_sensitivity") + assert state is not None + assert state.state == "default" diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 8fc98f31167..9b3f5d963dc 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -95,6 +95,7 @@ async def test_pipeline( listening_tone_enabled=False, processing_tone_enabled=False, error_tone_enabled=False, + silence_seconds=assist_pipeline.vad.VadSensitivity.to_seconds("aggressive"), ) rtp_protocol.transport = Mock() @@ -113,7 +114,7 @@ async def test_pipeline( # "speech" rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) - # silence + # silence (assumes aggressive VAD sensitivity) rtp_protocol.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream @@ -288,6 +289,7 @@ async def test_tts_timeout( listening_tone_enabled=True, processing_tone_enabled=True, error_tone_enabled=True, + silence_seconds=assist_pipeline.vad.VadSensitivity.to_seconds("relaxed"), ) rtp_protocol._tone_bytes = tone_bytes rtp_protocol._processing_bytes = tone_bytes @@ -313,8 +315,8 @@ async def test_tts_timeout( # "speech" rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) - # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) # Wait for mock pipeline to exhaust the audio stream async with async_timeout.timeout(1): From b9b5fe6be875b2e9108031fd49b0ed443686e8b0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 23 Jun 2023 20:34:34 -0700 Subject: [PATCH 501/857] Add service response data for listing calendar events (#94759) * Add service response data for listing calendar events Add the capability of response data for for the entity component. * Rename input arguments and add service description * Improve list events to be more user friendly Allow the end date to be determined based on a relative time duration. Make the start time optional and set to "now". Add additional test coverage. Update demo calendar to actually perform date range checks. * Wrap docstrings properly. * Increase test coverage * Update to use new API calls * Readability improvements * Wrap docstrings * Require at least one of end or duration * Check for multiple entity matches earlier in the request * Update documentation strings --- homeassistant/components/calendar/__init__.py | 51 +++++++- homeassistant/components/calendar/const.py | 1 + .../components/calendar/services.yaml | 24 ++++ homeassistant/components/demo/calendar.py | 11 +- homeassistant/helpers/entity.py | 9 +- homeassistant/helpers/entity_component.py | 18 ++- homeassistant/helpers/service.py | 51 ++++++-- tests/components/calendar/test_init.py | 121 +++++++++++++++++- tests/helpers/test_entity_component.py | 76 ++++++++++- 9 files changed, 331 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 2cb807169ea..5d0d2526bf2 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -8,7 +8,7 @@ from http import HTTPStatus from itertools import groupby import logging import re -from typing import Any, cast, final +from typing import Any, Final, cast, final from aiohttp import web from dateutil.rrule import rrulestr @@ -19,7 +19,12 @@ from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPOR from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -32,10 +37,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType from .const import ( CONF_EVENT, EVENT_DESCRIPTION, + EVENT_DURATION, EVENT_END, EVENT_END_DATE, EVENT_END_DATETIME, @@ -250,6 +257,21 @@ CALENDAR_EVENT_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +SERVICE_LIST_EVENTS: Final = "list_events" +SERVICE_LIST_EVENTS_SCHEMA: Final = vol.All( + cv.has_at_least_one_key(EVENT_END_DATETIME, EVENT_DURATION), + cv.has_at_most_one_key(EVENT_END_DATETIME, EVENT_DURATION), + cv.make_entity_service_schema( + { + vol.Optional(EVENT_START_DATETIME): datetime.datetime, + vol.Optional(EVENT_END_DATETIME): datetime.datetime, + vol.Optional(EVENT_DURATION): vol.All( + cv.time_period, cv.positive_timedelta + ), + } + ), +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for calendars.""" @@ -274,7 +296,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_create_event, required_features=[CalendarEntityFeature.CREATE_EVENT], ) - + component.async_register_entity_service( + SERVICE_LIST_EVENTS, + SERVICE_LIST_EVENTS_SCHEMA, + async_list_events_service, + supports_response=SupportsResponse.ONLY, + ) await component.async_setup(config) return True @@ -743,3 +770,21 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: EVENT_END: end, } await entity.async_create_event(**params) + + +async def async_list_events_service( + calendar: CalendarEntity, service_call: ServiceCall +) -> ServiceResponse: + """List events on a calendar during a time drange.""" + start = service_call.data.get(EVENT_START_DATETIME, dt_util.now()) + if EVENT_DURATION in service_call.data: + end = start + service_call.data[EVENT_DURATION] + else: + end = service_call.data[EVENT_END_DATETIME] + calendar_event_list = await calendar.async_get_events(calendar.hass, start, end) + events: list[JsonValueType] = [ + dataclasses.asdict(event) for event in calendar_event_list + ] + return { + "events": events, + } diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index 3fbab6742a9..2d4f0dfe0ba 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -40,3 +40,4 @@ EVENT_TIME_FIELDS = { EVENT_IN, } EVENT_TYPES = "event_types" +EVENT_DURATION = "duration" diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 5d1a3ccf0f4..af69882bba5 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -52,3 +52,27 @@ create_event: example: "Conference Room - F123, Bldg. 002" selector: text: +list_events: + name: List event + description: List events on a calendar within a time range. + target: + entity: + domain: calendar + fields: + start_date_time: + name: Start time + description: Return active events after this time (exclusive). When not set, defaults to now. + example: "2022-03-22 20:00:00" + selector: + datetime: + end_date_time: + name: End time + description: Return active events before this time (exclusive). Cannot be used with 'duration'. + example: "2022-03-22 22:00:00" + selector: + datetime: + duration: + name: Duration + description: Return active events from start_date_time until the specified duration. + selector: + duration: diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index ae546361d8f..73b45a55640 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -27,10 +27,10 @@ def setup_platform( def calendar_data_future() -> CalendarEvent: """Representation of a Demo Calendar for a future event.""" - one_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) + half_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) return CalendarEvent( - start=one_hour_from_now, - end=one_hour_from_now + datetime.timedelta(minutes=60), + start=half_hour_from_now, + end=half_hour_from_now + datetime.timedelta(minutes=60), summary="Future Event", description="Future Description", location="Future Location", @@ -67,4 +67,9 @@ class DemoCalendar(CalendarEntity): end_date: datetime.datetime, ) -> list[CalendarEvent]: """Return calendar events within a datetime range.""" + assert start_date < end_date + if self._event.start_datetime_local >= end_date: + return [] + if self._event.end_datetime_local < start_date: + return [] return [self._event] diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 97b3485c893..673d2c0b4d5 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -12,7 +12,7 @@ import logging import math import sys from timeit import default_timer as timer -from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, final +from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, final import voluptuous as vol @@ -49,6 +49,9 @@ from .typing import UNDEFINED, StateType, UndefinedType if TYPE_CHECKING: from .entity_platform import EntityPlatform + +_T = TypeVar("_T") + _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 DATA_ENTITY_SOURCE = "entity_info" @@ -1130,13 +1133,13 @@ class Entity(ABC): """Return the representation.""" return f"" - async def async_request_call(self, coro: Coroutine[Any, Any, Any]) -> None: + async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: """Process request batched.""" if self.parallel_updates: await self.parallel_updates.acquire() try: - await coro + return await coro finally: if self.parallel_updates: self.parallel_updates.release() diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2e8e23fcee9..af1b87ec0fa 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -19,7 +19,14 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + Event, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform @@ -217,18 +224,21 @@ class EntityComponent(Generic[_EntityT]): schema: dict[str | vol.Marker, Any] | vol.Schema, func: str | Callable[..., Any], required_features: list[int] | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register an entity service.""" if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: ServiceCall) -> None: + async def handle_service(call: ServiceCall) -> ServiceResponse: """Handle the service.""" - await service.entity_service_call( + return await service.entity_service_call( self.hass, self._platforms.values(), func, call, required_features ) - self.hass.services.async_register(self.domain, name, handle_service, schema) + self.hass.services.async_register( + self.domain, name, handle_service, schema, supports_response + ) async def async_setup_platform( self, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index a1ecdc75c71..3eacc8d6629 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial, wraps @@ -26,7 +26,13 @@ from homeassistant.const import ( ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + callback, +) from homeassistant.exceptions import ( HomeAssistantError, TemplateError, @@ -672,10 +678,10 @@ def async_set_service_schema( async def entity_service_call( # noqa: C901 hass: HomeAssistant, platforms: Iterable[EntityPlatform], - func: str | Callable[..., Any], + func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], call: ServiceCall, required_features: Iterable[int] | None = None, -) -> None: +) -> ServiceResponse | None: """Handle an entity service call. Calls all platforms simultaneously. @@ -791,7 +797,16 @@ async def entity_service_call( # noqa: C901 entities.append(entity) if not entities: - return + if call.return_response: + raise HomeAssistantError( + "Service call requested response data but did not match any entities" + ) + return None + + if call.return_response and len(entities) != 1: + raise HomeAssistantError( + "Service call requested response data but matched more than one entity" + ) done, pending = await asyncio.wait( [ @@ -804,8 +819,10 @@ async def entity_service_call( # noqa: C901 ] ) assert not pending - for future in done: - future.result() # pop exception if have + + response_data: ServiceResponse | None + for task in done: + response_data = task.result() # pop exception if have tasks: list[asyncio.Task[None]] = [] @@ -824,28 +841,32 @@ async def entity_service_call( # noqa: C901 for future in done: future.result() # pop exception if have + return response_data if call.return_response else None + async def _handle_entity_call( hass: HomeAssistant, entity: Entity, - func: str | Callable[..., Any], + func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], data: dict | ServiceCall, context: Context, -) -> None: +) -> ServiceResponse: """Handle calling service method.""" entity.async_set_context(context) + task: asyncio.Future[ServiceResponse] | None if isinstance(func, str): - result = hass.async_run_job( + task = hass.async_run_job( partial(getattr(entity, func), **data) # type: ignore[arg-type] ) else: - result = hass.async_run_job(func, entity, data) + task = hass.async_run_job(func, entity, data) # Guard because callback functions do not return a task when passed to # async_run_job. - if result is not None: - await result + result: ServiceResponse | None = None + if task is not None: + result = await task if asyncio.iscoroutine(result): _LOGGER.error( @@ -856,7 +877,9 @@ async def _handle_entity_call( func, entity.entity_id, ) - await result + result = await result + + return result @bind_hass diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index d58932ce898..97292221819 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -10,7 +10,7 @@ import pytest import voluptuous as vol from homeassistant.bootstrap import async_setup_component -from homeassistant.components.calendar import DOMAIN +from homeassistant.components.calendar import DOMAIN, SERVICE_LIST_EVENTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util @@ -384,3 +384,122 @@ async def test_create_event_service_invalid_params( target={"entity_id": "calendar.calendar_1"}, blocking=True, ) + + +async def test_list_events_service(hass: HomeAssistant) -> None: + """Test listing events from the service call using exlplicit start and end time.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + start = dt_util.now() + end = start + timedelta(days=1) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + "start_date_time": start, + "end_date_time": end, + }, + blocking=True, + return_response=True, + ) + assert response + assert "events" in response + events = response["events"] + assert len(events) == 1 + assert events[0]["summary"] == "Future Event" + + +@pytest.mark.parametrize( + ("entity", "duration", "expected_events"), + [ + # Calendar 1 has an hour long event starting in 30 minutes. No events in the + # next 15 minutes, but it shows up an hour from now. + ("calendar.calendar_1", "00:15:00", []), + ("calendar.calendar_1", "01:00:00", ["Future Event"]), + # Calendar 2 has a active event right now + ("calendar.calendar_2", "00:15:00", ["Current Event"]), + ], +) +async def test_list_events_service_duration( + hass: HomeAssistant, + entity: str, + duration: str, + expected_events: list[str], +) -> None: + """Test listing events using a time duration.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": entity, + "duration": duration, + }, + blocking=True, + return_response=True, + ) + assert response + assert "events" in response + events = response["events"] + assert [event["summary"] for event in events] == expected_events + + +async def test_list_events_positive_duration(hass: HomeAssistant) -> None: + """Test listing events requires a positive duration.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + with pytest.raises(vol.Invalid, match="should be positive"): + await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + "duration": "-01:00:00", + }, + blocking=True, + return_response=True, + ) + + +async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: + """Test listing events specifying fields that are exclusive.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + end = dt_util.now() + timedelta(days=1) + + with pytest.raises(vol.Invalid, match="at most one of"): + await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + "end_date_time": end, + "duration": "01:00:00", + }, + blocking=True, + return_response=True, + ) + + +async def test_list_events_missing_fields(hass: HomeAssistant) -> None: + """Test listing events missing some required fields.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + with pytest.raises(vol.Invalid, match="at least one of"): + await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + }, + blocking=True, + return_response=True, + ) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 4c9847bb3d2..4119ccc6e85 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -14,8 +14,14 @@ from homeassistant.const import ( ENTITY_MATCH_NONE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import PlatformNotReady +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent, async_update_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -474,7 +480,7 @@ async def test_extract_all_use_match_all( async def test_register_entity_service(hass: HomeAssistant) -> None: - """Test not expanding a group.""" + """Test registering an enttiy service and calling it.""" entity = MockEntity(entity_id=f"{DOMAIN}.entity") calls = [] @@ -524,6 +530,70 @@ async def test_register_entity_service(hass: HomeAssistant) -> None: assert len(calls) == 2 +async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: + """Test an enttiy service that does not support response data.""" + entity = MockEntity(entity_id=f"{DOMAIN}.entity") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": "response-value"} + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity]) + + component.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + DOMAIN, + "hello", + service_data={"entity_id": entity.entity_id, "some": "data"}, + blocking=True, + return_response=True, + ) + assert response_data == {"response-key": "response-value"} + + +async def test_register_entity_service_response_data_multiple_matches( + hass: HomeAssistant, +) -> None: + """Test asking for service response data but matching many entities.""" + entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") + entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + raise ValueError("Should not be invoked") + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity1, entity2]) + + component.async_register_entity_service( + "hello", + {}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + with pytest.raises(HomeAssistantError, match="matched more than one entity"): + await hass.services.async_call( + DOMAIN, + "hello", + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + + async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: """Test that we shutdown platforms on stop.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) From fe9366eee6012ddebd9f8406d0004563dcf12635 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 24 Jun 2023 10:38:20 +0000 Subject: [PATCH 502/857] Add new properties to the weather entity in Accuweather integration (#95110) * Add new properties to the current conditions * Add new properties to forecast * Use existing constants * Update tests --- .../components/accuweather/weather.py | 69 ++++++++++++++++--- tests/components/accuweather/test_weather.py | 18 +++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index f801f2a5e46..20cb12179ee 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -4,10 +4,13 @@ from __future__ import annotations from typing import cast from homeassistant.components.weather import ( + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, @@ -29,7 +32,16 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp from . import AccuWeatherDataUpdateCoordinator -from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN +from .const import ( + API_METRIC, + ATTR_DIRECTION, + ATTR_FORECAST, + ATTR_SPEED, + ATTR_VALUE, + ATTRIBUTION, + CONDITION_CLASSES, + DOMAIN, +) PARALLEL_UPDATES = 1 @@ -79,35 +91,61 @@ class AccuWeatherEntity( except IndexError: return None + @property + def cloud_coverage(self) -> float: + """Return the Cloud coverage in %.""" + return cast(float, self.coordinator.data["CloudCover"]) + + @property + def native_apparent_temperature(self) -> float: + """Return the apparent temperature.""" + return cast( + float, self.coordinator.data["ApparentTemperature"][API_METRIC][ATTR_VALUE] + ) + @property def native_temperature(self) -> float: """Return the temperature.""" - return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) + return cast(float, self.coordinator.data["Temperature"][API_METRIC][ATTR_VALUE]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) + return cast(float, self.coordinator.data["Pressure"][API_METRIC][ATTR_VALUE]) + + @property + def native_dew_point(self) -> float: + """Return the dew point.""" + return cast(float, self.coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE]) @property def humidity(self) -> int: """Return the humidity.""" return cast(int, self.coordinator.data["RelativeHumidity"]) + @property + def native_wind_gust_speed(self) -> float: + """Return the wind gust speed.""" + return cast( + float, self.coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + ) + @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) + return cast( + float, self.coordinator.data["Wind"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + ) @property def wind_bearing(self) -> int: """Return the wind bearing.""" - return cast(int, self.coordinator.data["Wind"]["Direction"]["Degrees"]) + return cast(int, self.coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"]) @property def native_visibility(self) -> float: """Return the visibility.""" - return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) + return cast(float, self.coordinator.data["Visibility"][API_METRIC][ATTR_VALUE]) @property def forecast(self) -> list[Forecast] | None: @@ -118,14 +156,23 @@ class AccuWeatherEntity( return [ { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), - ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"]["Value"], - ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"]["Value"], - ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquidDay"]["Value"], + ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCoverDay"], + ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"][ATTR_VALUE], + ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"][ATTR_VALUE], + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperatureMax"][ + ATTR_VALUE + ], + ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquidDay"][ATTR_VALUE], ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[ "PrecipitationProbabilityDay" ], - ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"]["Speed"]["Value"], - ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], + ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"][ATTR_SPEED][ + ATTR_VALUE + ], + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item["WindGustDay"][ATTR_SPEED][ + ATTR_VALUE + ], + ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"], ATTR_FORECAST_CONDITION: [ k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v ][0], diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index de1703128dd..dd5dca8c069 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -5,6 +5,8 @@ from unittest.mock import PropertyMock, patch from homeassistant.components.accuweather.const import ATTRIBUTION from homeassistant.components.weather import ( ATTR_FORECAST, + ATTR_FORECAST_APPARENT_TEMP, + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, @@ -12,12 +14,17 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, ) from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_UNAVAILABLE @@ -50,6 +57,10 @@ async def test_weather_without_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5 # 4.03 m/s -> km/h + assert state.attributes.get(ATTR_WEATHER_APPARENT_TEMPERATURE) == 22.8 + assert state.attributes.get(ATTR_WEATHER_DEW_POINT) == 16.2 + assert state.attributes.get(ATTR_WEATHER_CLOUD_COVERAGE) == 10 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION entry = registry.async_get("weather.home") @@ -71,6 +82,10 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5 # 4.03 m/s -> km/h + assert state.attributes.get(ATTR_WEATHER_APPARENT_TEMPERATURE) == 22.8 + assert state.attributes.get(ATTR_WEATHER_DEW_POINT) == 16.2 + assert state.attributes.get(ATTR_WEATHER_CLOUD_COVERAGE) == 10 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" @@ -81,6 +96,9 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert forecast.get(ATTR_FORECAST_TIME) == "2020-07-26T05:00:00+00:00" assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 166 assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 13.0 # 3.61 m/s -> km/h + assert forecast.get(ATTR_FORECAST_CLOUD_COVERAGE) == 58 + assert forecast.get(ATTR_FORECAST_APPARENT_TEMP) == 29.8 + assert forecast.get(ATTR_FORECAST_WIND_GUST_SPEED) == 29.6 entry = registry.async_get("weather.home") assert entry From a2f9caa482268e2151fce50aa231f95b263326fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 24 Jun 2023 12:45:47 +0200 Subject: [PATCH 503/857] Clean up device class based entity translations in Elgato (#95122) * Clean up device class based entity translations in Elgato * Update snapshots --- homeassistant/components/elgato/button.py | 1 - homeassistant/components/elgato/sensor.py | 1 - homeassistant/components/elgato/strings.json | 6 ------ tests/components/elgato/snapshots/test_button.ambr | 2 +- tests/components/elgato/snapshots/test_sensor.ambr | 2 +- 5 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 97673a79b9a..48a3d7d8b4a 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -47,7 +47,6 @@ BUTTONS = [ ), ElgatoButtonEntityDescription( key="restart", - translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.restart(), diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 371840de013..8ed8265705c 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -45,7 +45,6 @@ class ElgatoSensorEntityDescription( SENSORS = [ ElgatoSensorEntityDescription( key="battery", - translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index c5fc016aeb9..bef7b4ddcbf 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -26,15 +26,9 @@ "button": { "identify": { "name": "Identify" - }, - "restart": { - "name": "[%key:component::button::entity_component::restart::name%]" } }, "sensor": { - "battery": { - "name": "[%key:component::sensor::entity_component::battery::name%]" - }, "charge_power": { "name": "Charging power" }, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index cb420c486b4..7aaa8f2383a 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -111,7 +111,7 @@ 'original_name': 'Restart', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': 'restart', + 'translation_key': None, 'unique_id': 'GW24L1A02987_restart', 'unit_of_measurement': None, }) diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 35429b8a320..5fa7a6e827a 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -44,7 +44,7 @@ 'original_name': 'Battery', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': 'battery', + 'translation_key': None, 'unique_id': 'GW24L1A02987_battery', 'unit_of_measurement': '%', }) From e4a7c57b81f0e8ae0800cc674b55491def70562a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 24 Jun 2023 13:13:36 +0200 Subject: [PATCH 504/857] Use device class translations for Airly (#95115) * Use device class translations for Airly * Use device class translations for Airly * Use device class translations for Airly --- homeassistant/components/airly/sensor.py | 9 ------- homeassistant/components/airly/strings.json | 27 --------------------- 2 files changed, 36 deletions(-) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 53e15c651a7..cfbe7b98883 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -80,7 +80,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +87,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +98,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +109,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +116,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,7 +123,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -147,7 +141,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +152,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +163,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - translation_key="o3", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 50ebdd6d4dd..7ec58ccd8e5 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -32,35 +32,8 @@ "caqi": { "name": "Common air quality index" }, - "pm1": { - "name": "[%key:component::sensor::entity_component::pm1::name%]" - }, - "pm25": { - "name": "[%key:component::sensor::entity_component::pm25::name%]" - }, - "pm10": { - "name": "[%key:component::sensor::entity_component::pm10::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, - "pressure": { - "name": "[%key:component::sensor::entity_component::pressure::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, "co": { "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" - }, - "no2": { - "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" - }, - "so2": { - "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" - }, - "o3": { - "name": "[%key:component::sensor::entity_component::ozone::name%]" } } } From 7c49324f184e98cd47afccb4fdeb22b3388cfc80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 14:15:51 -0500 Subject: [PATCH 505/857] Bump aioesphomeapi to 14.1.1 (#95166) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a5e370aec44..928bd851ca1 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==14.1.0", + "aioesphomeapi==14.1.1", "bluetooth-data-tools==1.2.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index a2ff678d275..0509bdbaccf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==14.1.0 +aioesphomeapi==14.1.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9d87d072de..d4b7b8431f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==14.1.0 +aioesphomeapi==14.1.1 # homeassistant.components.flo aioflo==2021.11.0 From 9354df975c131e0c6d233ab25c86d73c9404bef6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 14:16:28 -0500 Subject: [PATCH 506/857] Reduce overhead to set up and write entity state (#95162) --- homeassistant/helpers/entity.py | 65 ++++++++++++++++----------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 673d2c0b4d5..33899b76c87 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -325,29 +325,28 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id + def _report_implicit_device_name(self) -> None: + """Report entities which use implicit device name.""" + if self._implicit_device_name_reported: + return + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is implicitly using device name by not setting its " + "name. Instead, the name should be set to None, please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + self._implicit_device_name_reported = True + @property def use_device_name(self) -> bool: """Return if this entity does not have its own name. Should be True if the entity represents the single main feature of a device. """ - - def report_implicit_device_name() -> None: - """Report entities which use implicit device name.""" - if self._implicit_device_name_reported: - return - report_issue = self._suggest_report_issue() - _LOGGER.warning( - ( - "Entity %s (%s) is implicitly using device name by not setting its " - "name. Instead, the name should be set to None, please %s" - ), - self.entity_id, - type(self), - report_issue, - ) - self._implicit_device_name_reported = True - if hasattr(self, "_attr_name"): return not self._attr_name @@ -362,13 +361,13 @@ class Entity(ABC): # Backwards compatibility with leaving EntityDescription.name unassigned # for device name. # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 - report_implicit_device_name() + self._report_implicit_device_name() return True return False if self.name is UNDEFINED and not self._default_to_device_class_name(): # Backwards compatibility with not overriding name property for device name. # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 - report_implicit_device_name() + self._report_implicit_device_name() return True return not self.name @@ -1092,6 +1091,19 @@ class Entity(ABC): self._unsub_device_updates() self._unsub_device_updates = None + @callback + def _async_device_registry_updated(self, event: Event) -> None: + """Handle device registry update.""" + data = event.data + + if data["action"] != "update": + return + + if "name" not in data["changes"] and "name_by_user" not in data["changes"]: + return + + self.async_write_ha_state() + @callback def _async_subscribe_device_updates(self) -> None: """Subscribe to device registry updates.""" @@ -1105,23 +1117,10 @@ class Entity(ABC): if not self.has_entity_name: return - @callback - def async_device_registry_updated(event: Event) -> None: - """Handle device registry update.""" - data = event.data - - if data["action"] != "update": - return - - if "name" not in data["changes"] and "name_by_user" not in data["changes"]: - return - - self.async_write_ha_state() - self._unsub_device_updates = async_track_device_registry_updated_event( self.hass, device_id, - async_device_registry_updated, + self._async_device_registry_updated, ) if ( not self._on_remove From 5059cee53fc0ce2ad7acf1b91c196e0599cf52be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 14:39:13 -0500 Subject: [PATCH 507/857] Reduce overhead to fire events (#95163) --- homeassistant/core.py | 38 +++++++------ tests/components/honeywell/test_climate.py | 53 ++++++++++++------ .../components/mqtt_eventstream/test_init.py | 2 +- tests/components/pilight/test_init.py | 56 ++++++++----------- 4 files changed, 81 insertions(+), 68 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index ad5fb44a514..1993e657368 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -82,7 +82,7 @@ from .exceptions import ( ) from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior from .helpers.json import json_dumps -from .util import dt as dt_util, location, ulid as ulid_util +from .util import dt as dt_util, location from .util.async_ import ( cancelling, run_callback_threadsafe, @@ -91,6 +91,7 @@ from .util.async_ import ( from .util.json import JsonObjectType from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager +from .util.ulid import ulid, ulid_at_time from .util.unit_system import ( _CONF_UNIT_SYSTEM_IMPERIAL, _CONF_UNIT_SYSTEM_US_CUSTOMARY, @@ -874,7 +875,7 @@ class Context: id: str | None = None, # pylint: disable=redefined-builtin ) -> None: """Init the context.""" - self.id = id or ulid_util.ulid() + self.id = id or ulid() self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None @@ -926,10 +927,14 @@ class Event: self.data = data or {} self.origin = origin self.time_fired = time_fired or dt_util.utcnow() - self.context: Context = context or Context( - id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) - ) + if not context: + context = Context( + id=ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) + ) + self.context = context self._as_dict: ReadOnlyDict[str, Any] | None = None + if not context.origin_event: + context.origin_event = self def as_dict(self) -> ReadOnlyDict[str, Any]: """Create a dict representation of this Event. @@ -973,6 +978,8 @@ class EventBus: def __init__(self, hass: HomeAssistant) -> None: """Initialize a new event bus.""" self._listeners: dict[str, list[_FilterableJob]] = {} + self._match_all_listeners: list[_FilterableJob] = [] + self._listeners[MATCH_ALL] = self._match_all_listeners self._hass = hass @callback @@ -1019,20 +1026,19 @@ class EventBus: ) listeners = self._listeners.get(event_type, []) + match_all_listeners = self._match_all_listeners - # EVENT_HOMEASSISTANT_CLOSE should go only to this listeners - match_all_listeners = self._listeners.get(MATCH_ALL) - if match_all_listeners is not None and event_type != EVENT_HOMEASSISTANT_CLOSE: + if not listeners and not match_all_listeners: + return + + # EVENT_HOMEASSISTANT_CLOSE should not be sent to MATCH_ALL listeners + if event_type != EVENT_HOMEASSISTANT_CLOSE: listeners = match_all_listeners + listeners event = Event(event_type, event_data, origin, time_fired, context) - if not event.context.origin_event: - event.context.origin_event = event - _LOGGER.debug("Bus:Handling %s", event) - - if not listeners: - return + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Bus:Handling %s", event) for job, event_filter, run_immediately in listeners: if event_filter is not None: @@ -1195,7 +1201,7 @@ class EventBus: self._listeners[event_type].remove(filterable_job) # delete event_type list if empty - if not self._listeners[event_type]: + if not self._listeners[event_type] and event_type != MATCH_ALL: self._listeners.pop(event_type) except (KeyError, ValueError): # KeyError is key event_type listener did not exist @@ -1630,7 +1636,7 @@ class StateMachine: # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6323 timestamp = time.time() now = dt_util.utc_from_timestamp(timestamp) - context = Context(id=ulid_util.ulid_at_time(timestamp)) + context = Context(id=ulid_at_time(timestamp)) else: now = dt_util.utcnow() diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 01472144c59..afb49cbffca 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -296,6 +296,7 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -308,8 +309,9 @@ async def test_service_calls_off_mode( ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) - assert "Invalid temperature" in caplog.messages[-1] + assert "Invalid temperature" in caplog.text + caplog.clear() reset_mock(device) await hass.services.async_call( CLIMATE_DOMAIN, @@ -436,6 +438,7 @@ async def test_service_calls_cool_mode( device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) + caplog.clear() device.set_setpoint_cool.reset_mock() device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError await hass.services.async_call( @@ -450,7 +453,7 @@ async def test_service_calls_cool_mode( ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) - assert "Invalid temperature" in caplog.messages[-1] + assert "Invalid temperature" in caplog.text reset_mock(device) await hass.services.async_call( @@ -467,6 +470,7 @@ async def test_service_calls_cool_mode( reset_mock(device) device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, @@ -478,7 +482,7 @@ async def test_service_calls_cool_mode( device.set_hold_cool.assert_called_once_with(True, 12) device.set_hold_heat.assert_not_called() device.set_setpoint_heat.assert_not_called() - assert "Temperature out of range" in caplog.messages[-1] + assert "Temperature out of range" in caplog.text reset_mock(device) @@ -512,6 +516,7 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, @@ -521,7 +526,7 @@ async def test_service_calls_cool_mode( ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() - assert "Couldn't set permanent hold" in caplog.messages[-1] + assert "Couldn't set permanent hold" in caplog.text reset_mock(device) @@ -536,6 +541,7 @@ async def test_service_calls_cool_mode( device.set_hold_cool.assert_called_once_with(False) reset_mock(device) + caplog.clear() device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError @@ -548,7 +554,7 @@ async def test_service_calls_cool_mode( device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) - assert "Can not stop hold mode" in caplog.messages[-1] + assert "Can not stop hold mode" in caplog.text reset_mock(device) @@ -566,6 +572,8 @@ async def test_service_calls_cool_mode( device.set_hold_heat.assert_not_called() reset_mock(device) + caplog.clear() + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError device.raw_ui_data["StatusHeat"] = 2 @@ -580,9 +588,10 @@ async def test_service_calls_cool_mode( device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() - assert "Couldn't set permanent hold" in caplog.messages[-1] + assert "Couldn't set permanent hold" in caplog.text reset_mock(device) + caplog.clear() device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 @@ -597,7 +606,7 @@ async def test_service_calls_cool_mode( device.set_hold_cool.assert_not_called() device.set_hold_heat.assert_not_called() - assert "Invalid system mode returned" in caplog.messages[-2] + assert "Invalid system mode returned" in caplog.text async def test_service_calls_heat_mode( @@ -638,8 +647,9 @@ async def test_service_calls_heat_mode( ) device.set_hold_heat.assert_called_once_with(datetime.time(2, 30), 59) device.set_hold_heat.reset_mock() - assert "Invalid temperature" in caplog.messages[-1] + assert "Invalid temperature" in caplog.text + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -667,7 +677,7 @@ async def test_service_calls_heat_mode( ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) - assert "Invalid temperature" in caplog.messages[-1] + assert "Invalid temperature" in caplog.text reset_mock(device) device.raw_ui_data["StatusHeat"] = 2 @@ -696,6 +706,7 @@ async def test_service_calls_heat_mode( device.set_setpoint_heat.assert_called_once() reset_mock(device) + caplog.clear() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError @@ -710,7 +721,7 @@ async def test_service_calls_heat_mode( ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() - assert "Couldn't set permanent hold" in caplog.messages[-1] + assert "Couldn't set permanent hold" in caplog.text reset_mock(device) @@ -726,6 +737,7 @@ async def test_service_calls_heat_mode( device.set_setpoint_cool.assert_not_called() reset_mock(device) + caplog.clear() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError @@ -739,9 +751,10 @@ async def test_service_calls_heat_mode( device.set_hold_heat.assert_called_once_with(True, 22) device.set_hold_cool.assert_not_called() device.set_setpoint_cool.assert_not_called() - assert "Temperature out of range" in caplog.messages[-1] + assert "Temperature out of range" in caplog.text reset_mock(device) + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, @@ -765,7 +778,7 @@ async def test_service_calls_heat_mode( ) device.set_hold_heat.assert_called_once_with(False) - assert "Can not stop hold mode" in caplog.messages[-1] + assert "Can not stop hold mode" in caplog.text reset_mock(device) device.raw_ui_data["StatusHeat"] = 2 @@ -844,6 +857,7 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.assert_called_once_with(77) reset_mock(device) + caplog.clear() device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError @@ -855,9 +869,10 @@ async def test_service_calls_auto_mode( blocking=True, ) device.set_setpoint_heat.assert_not_called() - assert "Invalid temperature" in caplog.messages[-1] + assert "Invalid temperature" in caplog.text reset_mock(device) + caplog.clear() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError @@ -872,9 +887,10 @@ async def test_service_calls_auto_mode( blocking=True, ) device.set_setpoint_heat.assert_not_called() - assert "Invalid temperature" in caplog.messages[-1] + assert "Invalid temperature" in caplog.text reset_mock(device) + caplog.clear() device.set_hold_heat.side_effect = None device.set_hold_cool.side_effect = None @@ -893,6 +909,7 @@ async def test_service_calls_auto_mode( device.set_hold_heat.assert_called_once_with(True) reset_mock(device) + caplog.clear() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError device.raw_ui_data["StatusHeat"] = 2 @@ -906,7 +923,7 @@ async def test_service_calls_auto_mode( ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_called_once_with(True) - assert "Couldn't set permanent hold" in caplog.messages[-1] + assert "Couldn't set permanent hold" in caplog.text reset_mock(device) device.set_setpoint_heat.side_effect = None @@ -923,6 +940,7 @@ async def test_service_calls_auto_mode( device.set_hold_heat.assert_called_once_with(True, 22) reset_mock(device) + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, @@ -946,9 +964,10 @@ async def test_service_calls_auto_mode( device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) - assert "Can not stop hold mode" in caplog.messages[-1] + assert "Can not stop hold mode" in caplog.text reset_mock(device) + caplog.clear() device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 @@ -978,7 +997,7 @@ async def test_service_calls_auto_mode( device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() - assert "Couldn't set permanent hold" in caplog.messages[-1] + assert "Couldn't set permanent hold" in caplog.text async def test_async_update_errors( diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index a61ea692bf2..5eabb2202aa 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -49,7 +49,7 @@ async def test_setup_no_mqtt( async def test_setup_with_pub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test the setup with subscription.""" # Should start off with no listeners for all events - assert hass.bus.async_listeners().get("*") is None + assert not hass.bus.async_listeners().get("*") assert await add_eventstream(hass, pub_topic="bar") await hass.async_block_till_done() diff --git a/tests/components/pilight/test_init.py b/tests/components/pilight/test_init.py index 365cd942ab9..96f384f98b9 100644 --- a/tests/components/pilight/test_init.py +++ b/tests/components/pilight/test_init.py @@ -11,7 +11,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import ( + assert_setup_component, + async_capture_events, + async_fire_time_changed, +) _LOGGER = logging.getLogger(__name__) @@ -222,9 +226,9 @@ async def test_start_stop(mock_pilight_error, hass: HomeAssistant) -> None: @patch("pilight.pilight.Client", PilightDaemonSim) -@patch("homeassistant.core._LOGGER.debug") -async def test_receive_code(mock_debug, hass: HomeAssistant) -> None: +async def test_receive_code(hass: HomeAssistant) -> None: """Check if code receiving via pilight daemon works.""" + events = async_capture_events(hass, pilight.EVENT) with assert_setup_component(4): assert await async_setup_component(hass, pilight.DOMAIN, {pilight.DOMAIN: {}}) @@ -239,18 +243,13 @@ async def test_receive_code(mock_debug, hass: HomeAssistant) -> None: }, **PilightDaemonSim.test_message["message"], ) - debug_log_call = mock_debug.call_args_list[-1] - - # Check if all message parts are put on event bus - for key, value in expected_message.items(): - assert str(key) in str(debug_log_call) - assert str(value) in str(debug_log_call) + assert events[0].data == expected_message @patch("pilight.pilight.Client", PilightDaemonSim) -@patch("homeassistant.core._LOGGER.debug") -async def test_whitelist_exact_match(mock_debug, hass: HomeAssistant) -> None: +async def test_whitelist_exact_match(hass: HomeAssistant) -> None: """Check whitelist filter with matched data.""" + events = async_capture_events(hass, pilight.EVENT) with assert_setup_component(4): whitelist = { "protocol": [PilightDaemonSim.test_message["protocol"]], @@ -272,18 +271,14 @@ async def test_whitelist_exact_match(mock_debug, hass: HomeAssistant) -> None: }, **PilightDaemonSim.test_message["message"], ) - debug_log_call = mock_debug.call_args_list[-1] - # Check if all message parts are put on event bus - for key, value in expected_message.items(): - assert str(key) in str(debug_log_call) - assert str(value) in str(debug_log_call) + assert events[0].data == expected_message @patch("pilight.pilight.Client", PilightDaemonSim) -@patch("homeassistant.core._LOGGER.debug") -async def test_whitelist_partial_match(mock_debug, hass: HomeAssistant) -> None: +async def test_whitelist_partial_match(hass: HomeAssistant) -> None: """Check whitelist filter with partially matched data, should work.""" + events = async_capture_events(hass, pilight.EVENT) with assert_setup_component(4): whitelist = { "protocol": [PilightDaemonSim.test_message["protocol"]], @@ -303,18 +298,15 @@ async def test_whitelist_partial_match(mock_debug, hass: HomeAssistant) -> None: }, **PilightDaemonSim.test_message["message"], ) - debug_log_call = mock_debug.call_args_list[-1] - # Check if all message parts are put on event bus - for key, value in expected_message.items(): - assert str(key) in str(debug_log_call) - assert str(value) in str(debug_log_call) + assert events[0].data == expected_message @patch("pilight.pilight.Client", PilightDaemonSim) -@patch("homeassistant.core._LOGGER.debug") -async def test_whitelist_or_match(mock_debug, hass: HomeAssistant) -> None: +async def test_whitelist_or_match(hass: HomeAssistant) -> None: """Check whitelist filter with several subsection, should work.""" + events = async_capture_events(hass, pilight.EVENT) + with assert_setup_component(4): whitelist = { "protocol": [ @@ -337,18 +329,15 @@ async def test_whitelist_or_match(mock_debug, hass: HomeAssistant) -> None: }, **PilightDaemonSim.test_message["message"], ) - debug_log_call = mock_debug.call_args_list[-1] - # Check if all message parts are put on event bus - for key, value in expected_message.items(): - assert str(key) in str(debug_log_call) - assert str(value) in str(debug_log_call) + assert events[0].data == expected_message @patch("pilight.pilight.Client", PilightDaemonSim) -@patch("homeassistant.core._LOGGER.debug") -async def test_whitelist_no_match(mock_debug, hass: HomeAssistant) -> None: +async def test_whitelist_no_match(hass: HomeAssistant) -> None: """Check whitelist filter with unmatched data, should not work.""" + events = async_capture_events(hass, pilight.EVENT) + with assert_setup_component(4): whitelist = { "protocol": ["wrong_protocol"], @@ -360,9 +349,8 @@ async def test_whitelist_no_match(mock_debug, hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - debug_log_call = mock_debug.call_args_list[-1] - assert "Event pilight_received" not in debug_log_call + assert len(events) == 0 async def test_call_rate_delay_throttle_enabled(hass: HomeAssistant) -> None: From 3ea26efac914089dbf5ecd907599eefb321012d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 15:10:54 -0500 Subject: [PATCH 508/857] Retry solaredge on socket.gaierror (#95153) --- homeassistant/components/solaredge/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py index dca129c7a70..0b685661ac3 100644 --- a/homeassistant/components/solaredge/__init__.py +++ b/homeassistant/components/solaredge/__init__.py @@ -1,6 +1,8 @@ """The SolarEdge integration.""" from __future__ import annotations +import socket + from requests.exceptions import ConnectTimeout, HTTPError from solaredge import Solaredge @@ -25,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: response = await hass.async_add_executor_job( api.get_details, entry.data[CONF_SITE_ID] ) - except (ConnectTimeout, HTTPError) as ex: + except (ConnectTimeout, HTTPError, socket.gaierror) as ex: LOGGER.error("Could not retrieve details from SolarEdge API") raise ConfigEntryNotReady from ex From 74d342a00003659cf1b007ea20e1850b1ef41575 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 15:11:13 -0500 Subject: [PATCH 509/857] Bump sense-energy to 0.12.0 (#95151) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index eea3f18adc0..324279db7d9 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.11.2"] + "requirements": ["sense_energy==0.12.0"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 257baae12f5..8c20db2e422 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.11.2"] + "requirements": ["sense-energy==0.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0509bdbaccf..f33872e1f9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2343,10 +2343,10 @@ securetar==2023.3.0 sendgrid==6.8.2 # homeassistant.components.sense -sense-energy==0.11.2 +sense-energy==0.12.0 # homeassistant.components.emulated_kasa -sense_energy==0.11.2 +sense_energy==0.12.0 # homeassistant.components.sensirion_ble sensirion-ble==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4b7b8431f4..8196e2ee6ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1703,10 +1703,10 @@ screenlogicpy==0.8.2 securetar==2023.3.0 # homeassistant.components.sense -sense-energy==0.11.2 +sense-energy==0.12.0 # homeassistant.components.emulated_kasa -sense_energy==0.11.2 +sense_energy==0.12.0 # homeassistant.components.sensirion_ble sensirion-ble==0.1.0 From c8430e45574ca1dd2f06115b16327aeadc066fa2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 15:12:02 -0500 Subject: [PATCH 510/857] Bump aiooncue to 0.3.5 (#95148) --- homeassistant/components/oncue/const.py | 7 ++++++- homeassistant/components/oncue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py index bf248369987..7118944a4ec 100644 --- a/homeassistant/components/oncue/const.py +++ b/homeassistant/components/oncue/const.py @@ -3,10 +3,15 @@ import asyncio import aiohttp +from aiooncue import ServiceFailedException DOMAIN = "oncue" -CONNECTION_EXCEPTIONS = (asyncio.TimeoutError, aiohttp.ClientError) +CONNECTION_EXCEPTIONS = ( + asyncio.TimeoutError, + aiohttp.ClientError, + ServiceFailedException, +) CONNECTION_ESTABLISHED_KEY: str = "NetworkConnectionEstablished" diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index 02c953736bb..24414e4efb8 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/oncue", "iot_class": "cloud_polling", "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.4"] + "requirements": ["aiooncue==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index f33872e1f9a..76cbe362ded 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -301,7 +301,7 @@ aionanoleaf==0.2.1 aionotion==2023.05.5 # homeassistant.components.oncue -aiooncue==0.3.4 +aiooncue==0.3.5 # homeassistant.components.openexchangerates aioopenexchangerates==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8196e2ee6ab..6fcf65c18a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -273,7 +273,7 @@ aionanoleaf==0.2.1 aionotion==2023.05.5 # homeassistant.components.oncue -aiooncue==0.3.4 +aiooncue==0.3.5 # homeassistant.components.openexchangerates aioopenexchangerates==0.4.0 From fa334cf2bd7d3de071b3fb94110456ba3c67a73f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 25 Jun 2023 02:00:20 +0200 Subject: [PATCH 511/857] Add entity translations to Big Ass Fans (#95136) --- homeassistant/components/baf/binary_sensor.py | 3 +- homeassistant/components/baf/climate.py | 5 +- homeassistant/components/baf/entity.py | 4 +- homeassistant/components/baf/fan.py | 3 +- homeassistant/components/baf/light.py | 6 +- homeassistant/components/baf/number.py | 16 ++-- homeassistant/components/baf/sensor.py | 12 ++- homeassistant/components/baf/strings.json | 76 +++++++++++++++++++ homeassistant/components/baf/switch.py | 22 +++--- 9 files changed, 111 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index fcfd0f3241d..a68e80c3ac2 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -39,7 +39,6 @@ class BAFBinarySensorDescription( OCCUPANCY_SENSORS = ( BAFBinarySensorDescription( key="occupancy", - name="Occupancy", device_class=BinarySensorDeviceClass.OCCUPANCY, value_fn=lambda device: cast(bool | None, device.fan_occupancy_detected), ), @@ -70,7 +69,7 @@ class BAFBinarySensor(BAFEntity, BinarySensorEntity): def __init__(self, device: Device, description: BAFBinarySensorDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index 6798639e7a8..531659e901f 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -27,9 +27,7 @@ async def async_setup_entry( """Set up BAF fan auto comfort.""" data: BAFData = hass.data[DOMAIN][entry.entry_id] if data.device.has_fan and data.device.has_auto_comfort: - async_add_entities( - [BAFAutoComfort(data.device, f"{data.device.name} Auto Comfort")] - ) + async_add_entities([BAFAutoComfort(data.device)]) class BAFAutoComfort(BAFEntity, ClimateEntity): @@ -38,6 +36,7 @@ class BAFAutoComfort(BAFEntity, ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] + _attr_translation_key = "auto_comfort" @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/baf/entity.py b/homeassistant/components/baf/entity.py index 22054d0b16d..4aeb287b861 100644 --- a/homeassistant/components/baf/entity.py +++ b/homeassistant/components/baf/entity.py @@ -13,12 +13,12 @@ class BAFEntity(Entity): """Base class for baf entities.""" _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, device: Device, name: str) -> None: + def __init__(self, device: Device) -> None: """Initialize the entity.""" self._device = device self._attr_unique_id = format_mac(self._device.mac_address) - self._attr_name = name self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac_address)}, name=self._device.name, diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index a166c346f12..059603fc589 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Set up SenseME fans.""" data: BAFData = hass.data[DOMAIN][entry.entry_id] if data.device.has_fan: - async_add_entities([BAFFan(data.device, data.device.name)]) + async_add_entities([BAFFan(data.device)]) class BAFFan(BAFEntity, FanEntity): @@ -46,6 +46,7 @@ class BAFFan(BAFEntity, FanEntity): ) _attr_preset_modes = [PRESET_MODE_AUTO] _attr_speed_count = SPEED_COUNT + _attr_name = None @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index b177d383cd5..9557005e5eb 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -63,9 +63,11 @@ class BAFLight(BAFEntity, LightEntity): class BAFFanLight(BAFLight): """Representation of a Big Ass Fans light on a fan.""" + _attr_name = None + def __init__(self, device: Device) -> None: """Init a fan light.""" - super().__init__(device, device.name) + super().__init__(device) self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_color_mode = ColorMode.BRIGHTNESS @@ -75,7 +77,7 @@ class BAFStandaloneLight(BAFLight): def __init__(self, device: Device) -> None: """Init a standalone light.""" - super().__init__(device, f"{device.name} Light") + super().__init__(device) self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_min_mireds = color_temperature_kelvin_to_mired( diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 020f34fefaf..7fd1c9ed290 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -37,7 +37,7 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", - name="Auto Comfort Minimum Speed", + translation_key="comfort_min_speed", native_step=1, native_min_value=0, native_max_value=SPEED_RANGE[1] - 1, @@ -47,7 +47,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="comfort_max_speed", - name="Auto Comfort Maximum Speed", + translation_key="comfort_max_speed", native_step=1, native_min_value=1, native_max_value=SPEED_RANGE[1], @@ -57,7 +57,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="comfort_heat_assist_speed", - name="Auto Comfort Heat Assist Speed", + translation_key="comfort_heat_assist_speed", native_step=1, native_min_value=SPEED_RANGE[0], native_max_value=SPEED_RANGE[1], @@ -70,7 +70,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="return_to_auto_timeout", - name="Return to Auto Timeout", + translation_key="return_to_auto_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, @@ -81,7 +81,7 @@ FAN_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="motion_sense_timeout", - name="Motion Sense Timeout", + translation_key="motion_sense_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, @@ -95,7 +95,7 @@ FAN_NUMBER_DESCRIPTIONS = ( LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", - name="Light Return to Auto Timeout", + translation_key="light_return_to_auto_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, @@ -106,7 +106,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="light_auto_motion_timeout", - name="Light Motion Sense Timeout", + translation_key="light_auto_motion_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, @@ -144,7 +144,7 @@ class BAFNumber(BAFEntity, NumberEntity): def __init__(self, device: Device, description: BAFNumberDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index d8700886e0a..d8111804142 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -46,7 +46,6 @@ class BAFSensorDescription( AUTO_COMFORT_SENSORS = ( BAFSensorDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -57,7 +56,6 @@ AUTO_COMFORT_SENSORS = ( DEFINED_ONLY_SENSORS = ( BAFSensorDescription( key="humidity", - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -68,7 +66,7 @@ DEFINED_ONLY_SENSORS = ( FAN_SENSORS = ( BAFSensorDescription( key="current_rpm", - name="Current RPM", + translation_key="current_rpm", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -76,7 +74,7 @@ FAN_SENSORS = ( ), BAFSensorDescription( key="target_rpm", - name="Target RPM", + translation_key="target_rpm", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -84,14 +82,14 @@ FAN_SENSORS = ( ), BAFSensorDescription( key="wifi_ssid", - name="WiFi SSID", + translation_key="wifi_ssid", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: cast(int | None, device.wifi_ssid), ), BAFSensorDescription( key="ip_address", - name="IP Address", + translation_key="ip_address", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: cast(str | None, device.ip_address), @@ -128,7 +126,7 @@ class BAFSensor(BAFEntity, SensorEntity): def __init__(self, device: Device, description: BAFSensorDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json index 59a20ea400c..cb322320675 100644 --- a/homeassistant/components/baf/strings.json +++ b/homeassistant/components/baf/strings.json @@ -19,5 +19,81 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "climate": { + "auto_comfort": { + "name": "Auto comfort" + } + }, + "number": { + "comfort_min_speed": { + "name": "Auto Comfort Minimum Speed" + }, + "comfort_max_speed": { + "name": "Auto Comfort Maximum Speed" + }, + "comfort_heat_assist_speed": { + "name": "Auto Comfort Heat Assist Speed" + }, + "return_to_auto_timeout": { + "name": "Return to Auto Timeout" + }, + "motion_sense_timeout": { + "name": "Motion Sense Timeout" + }, + "light_return_to_auto_timeout": { + "name": "Light Return to Auto Timeout" + }, + "light_auto_motion_timeout": { + "name": "Light Motion Sense Timeout" + } + }, + "sensor": { + "current_rpm": { + "name": "Current RPM" + }, + "target_rpm": { + "name": "Target RPM" + }, + "wifi_ssid": { + "name": "Wi-Fi SSID" + }, + "ip_address": { + "name": "IP Address" + } + }, + "switch": { + "legacy_ir_remote_enable": { + "name": "Legacy IR Remote" + }, + "led_indicators_enable": { + "name": "Led Indicators" + }, + "comfort_heat_assist_enable": { + "name": "Auto Comfort Heat Assist" + }, + "fan_beep_enable": { + "name": "Beep" + }, + "eco_enable": { + "name": "Eco Mode" + }, + "motion_sense_enable": { + "name": "Motion Sense" + }, + "return_to_auto_enable": { + "name": "Return to Auto" + }, + "whoosh_enable": { + "name": "Whoosh" + }, + "light_dim_to_warm_enable": { + "name": "Dim to Warm" + }, + "light_return_to_auto_enable": { + "name": "Light Return to Auto" + } + } } } diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index d5236f9b861..ed4e635ece3 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -36,13 +36,13 @@ class BAFSwitchDescription( BASE_SWITCHES = [ BAFSwitchDescription( key="legacy_ir_remote_enable", - name="Legacy IR Remote", + translation_key="legacy_ir_remote_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.legacy_ir_remote_enable), ), BAFSwitchDescription( key="led_indicators_enable", - name="Led Indicators", + translation_key="led_indicators_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.led_indicators_enable), ), @@ -51,7 +51,7 @@ BASE_SWITCHES = [ AUTO_COMFORT_SWITCHES = [ BAFSwitchDescription( key="comfort_heat_assist_enable", - name="Auto Comfort Heat Assist", + translation_key="comfort_heat_assist_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.comfort_heat_assist_enable), ), @@ -60,31 +60,31 @@ AUTO_COMFORT_SWITCHES = [ FAN_SWITCHES = [ BAFSwitchDescription( key="fan_beep_enable", - name="Beep", + translation_key="fan_beep_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.fan_beep_enable), ), BAFSwitchDescription( key="eco_enable", - name="Eco Mode", + translation_key="eco_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.eco_enable), ), BAFSwitchDescription( key="motion_sense_enable", - name="Motion Sense", + translation_key="motion_sense_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.motion_sense_enable), ), BAFSwitchDescription( key="return_to_auto_enable", - name="Return to Auto", + translation_key="return_to_auto_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.return_to_auto_enable), ), BAFSwitchDescription( key="whoosh_enable", - name="Whoosh", + translation_key="whoosh_enable", # Not a configuration switch value_fn=lambda device: cast(bool | None, device.whoosh_enable), ), @@ -94,13 +94,13 @@ FAN_SWITCHES = [ LIGHT_SWITCHES = [ BAFSwitchDescription( key="light_dim_to_warm_enable", - name="Dim to Warm", + translation_key="light_dim_to_warm_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.light_dim_to_warm_enable), ), BAFSwitchDescription( key="light_return_to_auto_enable", - name="Light Return to Auto", + translation_key="light_return_to_auto_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.light_return_to_auto_enable), ), @@ -134,7 +134,7 @@ class BAFSwitch(BAFEntity, SwitchEntity): def __init__(self, device: Device, description: BAFSwitchDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback From c6b3d538dea5a0753bdae6461bb6951f2018709b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 22:01:44 -0500 Subject: [PATCH 512/857] Remove deprecated non-native number support (#95178) * Remove deprecated non-native number support These were scheduled to be removed in 2022.10 but were left in to give custom component authors a bit more time. Its been a year since they were deprecated so its time to remove the old code https://developers.home-assistant.io/blog/2022/06/14/number_entity_refactoring/ * strip unneeded change from testing --- homeassistant/components/number/__init__.py | 92 ------------ tests/components/number/test_init.py | 157 -------------------- 2 files changed, 249 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 9bf1a656efd..10c296c7a7a 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -135,39 +135,6 @@ class NumberEntityDescription(EntityDescription): step: None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement - def __post_init__(self) -> None: - """Post initialisation processing.""" - if ( - self.max_value is not None - or self.min_value is not None - or self.step is not None - or self.unit_of_measurement is not None - ): - if ( # type: ignore[unreachable] - self.__class__.__name__ == "NumberEntityDescription" - ): - caller = inspect.stack()[2] - module = inspect.getmodule(caller[0]) - else: - module = inspect.getmodule(self) - if module and module.__file__ and "custom_components" in module.__file__: - report_issue = "report it to the custom integration author." - else: - report_issue = ( - "create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) - _LOGGER.warning( - ( - "%s is setting deprecated attributes on an instance of" - " NumberEntityDescription, this is not valid and will be" - " unsupported from Home Assistant 2022.10. Please %s" - ), - module.__name__ if module else self.__class__.__name__, - report_issue, - ) - self.native_unit_of_measurement = self.unit_of_measurement - def ceil_decimal(value: float, precision: float = 0) -> float: """Return the ceiling of f with d decimals. @@ -290,15 +257,6 @@ class NumberEntity(Entity): @final def min_value(self) -> float: """Return the minimum value.""" - if hasattr(self, "_attr_min_value"): - self._report_deprecated_number_entity() - return self._attr_min_value # type: ignore[return-value] - if ( - hasattr(self, "entity_description") - and self.entity_description.min_value is not None - ): - self._report_deprecated_number_entity() # type: ignore[unreachable] - return self.entity_description.min_value return self._convert_to_state_value(self.native_min_value, floor_decimal) @property @@ -317,15 +275,6 @@ class NumberEntity(Entity): @final def max_value(self) -> float: """Return the maximum value.""" - if hasattr(self, "_attr_max_value"): - self._report_deprecated_number_entity() - return self._attr_max_value # type: ignore[return-value] - if ( - hasattr(self, "entity_description") - and self.entity_description.max_value is not None - ): - self._report_deprecated_number_entity() # type: ignore[unreachable] - return self.entity_description.max_value return self._convert_to_state_value(self.native_max_value, ceil_decimal) @property @@ -342,15 +291,6 @@ class NumberEntity(Entity): @final def step(self) -> float: """Return the increment/decrement step.""" - if hasattr(self, "_attr_step"): - self._report_deprecated_number_entity() - return self._attr_step # type: ignore[return-value] - if ( - hasattr(self, "entity_description") - and self.entity_description.step is not None - ): - self._report_deprecated_number_entity() # type: ignore[unreachable] - return self.entity_description.step if hasattr(self, "_attr_native_step"): return self._attr_native_step if (native_step := self.native_step) is not None: @@ -396,17 +336,6 @@ class NumberEntity(Entity): if self._number_option_unit_of_measurement: return self._number_option_unit_of_measurement - if hasattr(self, "_attr_unit_of_measurement"): - self._report_deprecated_number_entity() - return self._attr_unit_of_measurement - if ( - hasattr(self, "entity_description") - and self.entity_description.unit_of_measurement is not None - ): - return ( # type: ignore[unreachable] - self.entity_description.unit_of_measurement - ) - native_unit_of_measurement = self.native_unit_of_measurement if ( @@ -427,10 +356,6 @@ class NumberEntity(Entity): @final def value(self) -> float | None: """Return the entity value to represent the entity state.""" - if hasattr(self, "_attr_value"): - self._report_deprecated_number_entity() - return self._attr_value - if (native_value := self.native_value) is None: return native_value return self._convert_to_state_value(native_value, round) @@ -457,7 +382,6 @@ class NumberEntity(Entity): self, value: float, method: Callable[[float, int], float] ) -> float: """Convert a value in the number's native unit to the configured unit.""" - native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement device_class = self.device_class @@ -487,7 +411,6 @@ class NumberEntity(Entity): def convert_to_native_value(self, value: float) -> float: """Convert a value to the number's native unit.""" - native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement device_class = self.device_class @@ -508,21 +431,6 @@ class NumberEntity(Entity): return value - def _report_deprecated_number_entity(self) -> None: - """Report that the number entity has not been upgraded.""" - if not self._deprecated_number_entity_reported: - self._deprecated_number_entity_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - ( - "Entity %s (%s) is using deprecated NumberEntity features which" - " will be unsupported from Home Assistant Core 2022.10, please %s" - ), - self.entity_id, - type(self), - report_issue, - ) - @callback def async_registry_entry_updated(self) -> None: """Run when the entity registry entry has been updated.""" diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 80a567df696..d9cf27c12aa 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -40,7 +40,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( MockConfigEntry, - MockEntityPlatform, MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, @@ -253,64 +252,6 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_4.value is None -async def test_deprecation_warnings( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test overriding the deprecated attributes is possible and warnings are logged.""" - number = MockDefaultNumberEntityDeprecated() - number.hass = hass - number.platform = MockEntityPlatform(hass) - assert number.max_value == 100.0 - assert number.min_value == 0.0 - assert number.step == 1.0 - assert number.unit_of_measurement is None - assert number.value == 0.5 - - number_2 = MockNumberEntityDeprecated() - number_2.hass = hass - number_2.platform = MockEntityPlatform(hass) - assert number_2.max_value == 0.5 - assert number_2.min_value == -0.5 - assert number_2.step == 0.1 - assert number_2.unit_of_measurement == "cats" - assert number_2.value == 0.5 - - number_3 = MockNumberEntityAttrDeprecated() - number_3.hass = hass - number_3.platform = MockEntityPlatform(hass) - assert number_3.max_value == 1000.0 - assert number_3.min_value == -1000.0 - assert number_3.step == 100.0 - assert number_3.unit_of_measurement == "dogs" - assert number_3.value == 500.0 - - number_4 = MockNumberEntityDescrDeprecated() - number_4.hass = hass - number_4.platform = MockEntityPlatform(hass) - assert number_4.max_value == 10.0 - assert number_4.min_value == -10.0 - assert number_4.step == 2.0 - assert number_4.unit_of_measurement == "rabbits" - assert number_4.value == 0.5 - - assert ( - "tests.components.number.test_init::MockNumberEntityDeprecated is overriding " - " deprecated methods on an instance of NumberEntity" - ) - assert ( - "Entity None () " - "is using deprecated NumberEntity features" in caplog.text - ) - assert ( - "Entity None () " - "is using deprecated NumberEntity features" in caplog.text - ) - assert ( - "tests.components.number.test_init is setting deprecated attributes on an " - "instance of NumberEntityDescription" in caplog.text - ) - - async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" number = MockDefaultNumberEntity() @@ -360,104 +301,6 @@ async def test_set_value(hass: HomeAssistant, enable_custom_integrations: None) assert state.state == "60.0" -async def test_deprecated_attributes( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: - """Test entity using deprecated attributes.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init(empty=True) - platform.ENTITIES.append(platform.LegacyMockNumberEntity()) - entity = platform.ENTITIES[0] - entity._attr_name = "Test" - entity._attr_max_value = 25 - entity._attr_min_value = -25 - entity._attr_step = 2.5 - entity._attr_value = 51.0 - - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - state = hass.states.get("number.test") - assert state.state == "51.0" - assert state.attributes.get(ATTR_MAX) == 25.0 - assert state.attributes.get(ATTR_MIN) == -25.0 - assert state.attributes.get(ATTR_STEP) == 2.5 - - await hass.services.async_call( - DOMAIN, - SERVICE_SET_VALUE, - {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get("number.test") - assert state.state == "0.0" - - # test ValueError trigger - with pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_VALUE, - {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, - blocking=True, - ) - - await hass.async_block_till_done() - state = hass.states.get("number.test") - assert state.state == "0.0" - - -async def test_deprecated_methods( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: - """Test entity using deprecated methods.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init(empty=True) - platform.ENTITIES.append( - platform.LegacyMockNumberEntity( - name="Test", - max_value=25.0, - min_value=-25.0, - step=2.5, - value=51.0, - ) - ) - - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - state = hass.states.get("number.test") - assert state.state == "51.0" - assert state.attributes.get(ATTR_MAX) == 25.0 - assert state.attributes.get(ATTR_MIN) == -25.0 - assert state.attributes.get(ATTR_STEP) == 2.5 - - await hass.services.async_call( - DOMAIN, - SERVICE_SET_VALUE, - {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get("number.test") - assert state.state == "0.0" - - # test ValueError trigger - with pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_VALUE, - {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, - blocking=True, - ) - - await hass.async_block_till_done() - state = hass.states.get("number.test") - assert state.state == "0.0" - - @pytest.mark.parametrize( ( "unit_system", From 9eedc8a602351af8253a4360db92044d988fbbb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 22:09:26 -0500 Subject: [PATCH 513/857] Fix esphome binary sensors when state is missing (#95140) * Fix esphome binary sensors when state is missing * Fix esphome binary sensors when state is missing * Fix esphome binary sensors when state is missing --- .../components/esphome/binary_sensor.py | 5 +- tests/components/esphome/conftest.py | 66 ++++++++++++++--- .../components/esphome/test_binary_sensor.py | 70 +++++++++++++++++-- 3 files changed, 125 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index ce77c28e349..81ffee1a380 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -49,10 +49,9 @@ class EsphomeBinarySensor( # Status binary sensors indicated connected state. # So in their case what's usually _availability_ is now state return self._entry_data.available - state = self._state - if not self._has_state or state.missing_state: + if not self._has_state or self._state.missing_state: return None - return state.state + return self._state.state @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index e5e78ca3bf1..8a2fe1a3d4a 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations from asyncio import Event -from collections.abc import Callable +from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -142,13 +142,30 @@ async def mock_dashboard(hass): yield data +class MockESPHomeDevice: + """Mock an esphome device.""" + + def __init__(self, entry: MockConfigEntry) -> None: + """Init the mock.""" + self.entry = entry + self.state_callback: Callable[[EntityState], None] + + def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: + """Set the state callback.""" + self.state_callback = state_callback + + def set_state(self, state: EntityState) -> None: + """Mock setting state.""" + self.state_callback(state) + + async def _mock_generic_device_entry( hass: HomeAssistant, mock_client: APIClient, mock_device_info: dict[str, Any], mock_list_entities_services: tuple[list[EntityInfo], list[UserService]], states: list[EntityState], -) -> MockConfigEntry: +) -> MockESPHomeDevice: entry = MockConfigEntry( domain=DOMAIN, data={ @@ -158,6 +175,7 @@ async def _mock_generic_device_entry( }, ) entry.add_to_hass(hass) + mock_device = MockESPHomeDevice(entry) device_info = DeviceInfo( name="test", @@ -169,6 +187,7 @@ async def _mock_generic_device_entry( async def _subscribe_states(callback: Callable[[EntityState], None]) -> None: """Subscribe to state.""" + mock_device.set_state_callback(callback) for state in states: callback(state) @@ -194,7 +213,7 @@ async def _mock_generic_device_entry( await hass.async_block_till_done() - return entry + return mock_device @pytest.fixture @@ -205,9 +224,11 @@ async def mock_voice_assistant_entry( """Set up an ESPHome entry with voice assistant.""" async def _mock_voice_assistant_entry(version: int) -> MockConfigEntry: - return await _mock_generic_device_entry( - hass, mock_client, {"voice_assistant_version": version}, ([], []), [] - ) + return ( + await _mock_generic_device_entry( + hass, mock_client, {"voice_assistant_version": version}, ([], []), [] + ) + ).entry return _mock_voice_assistant_entry @@ -227,8 +248,11 @@ async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfi @pytest.fixture async def mock_generic_device_entry( hass: HomeAssistant, -) -> MockConfigEntry: - """Set up an ESPHome entry.""" +) -> Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], +]: + """Set up an ESPHome entry and return the MockConfigEntry.""" async def _mock_device_entry( mock_client: APIClient, @@ -236,8 +260,32 @@ async def mock_generic_device_entry( user_service: list[UserService], states: list[EntityState], ) -> MockConfigEntry: + return ( + await _mock_generic_device_entry( + hass, mock_client, {}, (entity_info, user_service), states + ) + ).entry + + return _mock_device_entry + + +@pytest.fixture +async def mock_esphome_device( + hass: HomeAssistant, +) -> Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], +]: + """Set up an ESPHome entry and return the MockESPHomeDevice.""" + + async def _mock_device( + mock_client: APIClient, + entity_info: list[EntityInfo], + user_service: list[UserService], + states: list[EntityState], + ) -> MockESPHomeDevice: return await _mock_generic_device_entry( hass, mock_client, {}, (entity_info, user_service), states ) - return _mock_device_entry + return _mock_device diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 8f1d5a670c4..231bd51c0a3 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,11 +1,24 @@ """Test ESPHome binary sensors.""" -from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState +from collections.abc import Awaitable, Callable + +from aioesphomeapi import ( + APIClient, + BinarySensorInfo, + BinarySensorState, + EntityInfo, + EntityState, + UserService, +) import pytest from homeassistant.components.esphome import DomainData from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockESPHomeDevice + +from tests.common import MockConfigEntry + async def test_assist_in_progress( hass: HomeAssistant, @@ -37,7 +50,10 @@ async def test_binary_sensor_generic_entity( hass: HomeAssistant, mock_client: APIClient, binary_state: tuple[bool, str], - mock_generic_device_entry, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -63,7 +79,12 @@ async def test_binary_sensor_generic_entity( async def test_status_binary_sensor( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -89,7 +110,12 @@ async def test_status_binary_sensor( async def test_binary_sensor_missing_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test a generic binary_sensor that is missing state.""" entity_info = [ @@ -111,3 +137,39 @@ async def test_binary_sensor_missing_state( state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNKNOWN + + +async def test_binary_sensor_has_state_false( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic binary_sensor where has_state is false.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + mock_device.set_state(BinarySensorState(key=1, state=True, missing_state=False)) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_ON From 79f9a8a25733c044787f409ba453877d979ec5b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 22:10:47 -0500 Subject: [PATCH 514/857] Add test coverage for esphome lock platform (#95023) --- .coveragerc | 1 - tests/components/esphome/test_lock.py | 132 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/components/esphome/test_lock.py diff --git a/.coveragerc b/.coveragerc index a5153373770..70d9fd5e0e2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -312,7 +312,6 @@ omit = homeassistant/components/esphome/domain_data.py homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/light.py - homeassistant/components/esphome/lock.py homeassistant/components/esphome/number.py homeassistant/components/esphome/switch.py homeassistant/components/etherscan/sensor.py diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py new file mode 100644 index 00000000000..6e6461d34b1 --- /dev/null +++ b/tests/components/esphome/test_lock.py @@ -0,0 +1,132 @@ +"""Test ESPHome locks.""" + + +from unittest.mock import call + +from aioesphomeapi import APIClient, LockCommand, LockEntityState, LockInfo, LockState + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKING, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + + +async def test_lock_entity_no_open( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic lock entity that does not support open.""" + entity_info = [ + LockInfo( + object_id="mylock", + key=1, + name="my lock", + unique_id="my_lock", + supports_open=False, + requires_code=False, + ) + ] + states = [LockEntityState(key=1, state=LockState.UNLOCKING)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("lock.test_my_lock") + assert state is not None + assert state.state == STATE_UNLOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, + blocking=True, + ) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.reset_mock() + + +async def test_lock_entity_start_locked( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic lock entity that does not support open.""" + entity_info = [ + LockInfo( + object_id="mylock", + key=1, + name="my lock", + unique_id="my_lock", + ) + ] + states = [LockEntityState(key=1, state=LockState.LOCKED)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("lock.test_my_lock") + assert state is not None + assert state.state == STATE_LOCKED + + +async def test_lock_entity_supports_open( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic lock entity that supports open.""" + entity_info = [ + LockInfo( + object_id="mylock", + key=1, + name="my lock", + unique_id="my_lock", + supports_open=True, + requires_code=True, + ) + ] + states = [LockEntityState(key=1, state=LockState.LOCKING)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("lock.test_my_lock") + assert state is not None + assert state.state == STATE_LOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, + blocking=True, + ) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.reset_mock() + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, + blocking=True, + ) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.UNLOCK, None)]) + + mock_client.lock_command.reset_mock() + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, + blocking=True, + ) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN)]) From 57a910a1443b764330303bda13a439175112ceac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 22:12:36 -0500 Subject: [PATCH 515/857] Relocate esphome entity code into its own module (#95092) --- homeassistant/components/esphome/__init__.py | 287 +--------------- .../components/esphome/alarm_control_panel.py | 6 +- .../components/esphome/binary_sensor.py | 6 +- homeassistant/components/esphome/button.py | 5 +- homeassistant/components/esphome/camera.py | 5 +- homeassistant/components/esphome/climate.py | 6 +- homeassistant/components/esphome/cover.py | 6 +- homeassistant/components/esphome/entity.py | 310 ++++++++++++++++++ homeassistant/components/esphome/fan.py | 6 +- homeassistant/components/esphome/light.py | 6 +- homeassistant/components/esphome/lock.py | 6 +- .../components/esphome/media_player.py | 6 +- homeassistant/components/esphome/number.py | 6 +- homeassistant/components/esphome/select.py | 4 +- homeassistant/components/esphome/sensor.py | 6 +- homeassistant/components/esphome/switch.py | 6 +- 16 files changed, 376 insertions(+), 301 deletions(-) create mode 100644 homeassistant/components/esphome/entity.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 0c962d82074..fdf0092389b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,20 +1,14 @@ """Support for esphome devices.""" from __future__ import annotations -from collections.abc import Callable -import functools import logging -import math -from typing import Any, Generic, NamedTuple, TypeVar, cast +from typing import Any, NamedTuple, TypeVar from aioesphomeapi import ( APIClient, APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, - EntityCategory as EsphomeEntityCategory, - EntityInfo, - EntityState, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -36,7 +30,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, EVENT_HOMEASSISTANT_STOP, - EntityCategory, __version__ as ha_version, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback @@ -45,12 +38,6 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( IssueSeverity, @@ -68,7 +55,6 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData -from .enum_mapper import EsphomeEnumMapper from .voice_assistant import VoiceAssistantUDPServer CONF_DEVICE_NAME = "device_name" @@ -669,274 +655,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove an esphome config entry.""" await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() - - -_InfoT = TypeVar("_InfoT", bound=EntityInfo) -_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") -_StateT = TypeVar("_StateT", bound=EntityState) - - -async def platform_async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - *, - component_key: str, - info_type: type[_InfoT], - entity_type: type[_EntityT], - state_type: type[_StateT], -) -> None: - """Set up an esphome platform. - - This method is in charge of receiving, distributing and storing - info and state updates. - """ - entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) - entry_data.info[component_key] = {} - entry_data.old_info[component_key] = {} - entry_data.state.setdefault(state_type, {}) - - @callback - def async_list_entities(infos: list[EntityInfo]) -> None: - """Update entities of this platform when entities are listed.""" - old_infos = entry_data.info[component_key] - new_infos: dict[int, EntityInfo] = {} - add_entities: list[_EntityT] = [] - for info in infos: - if info.key in old_infos: - # Update existing entity - old_infos.pop(info.key) - else: - # Create new entity - entity = entity_type(entry_data, component_key, info, state_type) - add_entities.append(entity) - new_infos[info.key] = info - - # Remove old entities - for info in old_infos.values(): - entry_data.async_remove_entity(hass, component_key, info.key) - - # First copy the now-old info into the backup object - entry_data.old_info[component_key] = entry_data.info[component_key] - # Then update the actual info - entry_data.info[component_key] = new_infos - - for key, new_info in new_infos.items(): - async_dispatcher_send( - hass, - entry_data.signal_component_key_static_info_updated(component_key, key), - new_info, - ) - - if add_entities: - # Add entities to Home Assistant - async_add_entities(add_entities) - - entry_data.cleanup_callbacks.append( - entry_data.async_register_static_info_callback(info_type, async_list_entities) - ) - - -def esphome_state_property( - func: Callable[[_EntityT], _R] -) -> Callable[[_EntityT], _R | None]: - """Wrap a state property of an esphome entity. - - This checks if the state object in the entity is set, and - prevents writing NAN values to the Home Assistant state machine. - """ - - @functools.wraps(func) - def _wrapper(self: _EntityT) -> _R | None: - # pylint: disable-next=protected-access - if not self._has_state: - return None - val = func(self) - if isinstance(val, float) and math.isnan(val): - # Home Assistant doesn't use NAN values in state machine - # (not JSON serializable) - return None - return val - - return _wrapper - - -ICON_SCHEMA = vol.Schema(cv.icon) - - -ENTITY_CATEGORIES: EsphomeEnumMapper[ - EsphomeEntityCategory, EntityCategory | None -] = EsphomeEnumMapper( - { - EsphomeEntityCategory.NONE: None, - EsphomeEntityCategory.CONFIG: EntityCategory.CONFIG, - EsphomeEntityCategory.DIAGNOSTIC: EntityCategory.DIAGNOSTIC, - } -) - - -class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): - """Define a base esphome entity.""" - - _attr_should_poll = False - _static_info: _InfoT - _state: _StateT - _has_state: bool - - def __init__( - self, - entry_data: RuntimeEntryData, - component_key: str, - entity_info: EntityInfo, - state_type: type[_StateT], - ) -> None: - """Initialize.""" - self._entry_data = entry_data - self._on_entry_data_changed() - self._component_key = component_key - self._key = entity_info.key - self._state_type = state_type - self._on_static_info_update(entity_info) - assert entry_data.device_info is not None - device_info = entry_data.device_info - self._device_info = device_info - self._attr_has_entity_name = bool(device_info.friendly_name) - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} - ) - self._entry_id = entry_data.entry_id - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - entry_data = self._entry_data - hass = self.hass - component_key = self._component_key - key = self._key - - self.async_on_remove( - async_dispatcher_connect( - hass, - f"esphome_{self._entry_id}_remove_{component_key}_{key}", - functools.partial(self.async_remove, force_remove=True), - ) - ) - self.async_on_remove( - async_dispatcher_connect( - hass, - entry_data.signal_device_updated, - self._on_device_update, - ) - ) - self.async_on_remove( - entry_data.async_subscribe_state_update( - self._state_type, key, self._on_state_update - ) - ) - self.async_on_remove( - async_dispatcher_connect( - hass, - entry_data.signal_component_key_static_info_updated(component_key, key), - self._on_static_info_update, - ) - ) - self._update_state_from_entry_data() - - @callback - def _on_static_info_update(self, static_info: EntityInfo) -> None: - """Save the static info for this entity when it changes. - - This method can be overridden in child classes to know - when the static info changes. - """ - static_info = cast(_InfoT, static_info) - self._static_info = static_info - self._attr_unique_id = static_info.unique_id - self._attr_entity_registry_enabled_default = not static_info.disabled_by_default - self._attr_name = static_info.name - if entity_category := static_info.entity_category: - self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) - else: - self._attr_entity_category = None - if icon := static_info.icon: - self._attr_icon = cast(str, ICON_SCHEMA(icon)) - else: - self._attr_icon = None - - @callback - def _update_state_from_entry_data(self) -> None: - """Update state from entry data.""" - - state = self._entry_data.state - key = self._key - state_type = self._state_type - has_state = key in state[state_type] - if has_state: - self._state = cast(_StateT, state[state_type][key]) - self._has_state = has_state - - @callback - def _on_state_update(self) -> None: - """Call when state changed. - - Behavior can be changed in child classes - """ - self._update_state_from_entry_data() - self.async_write_ha_state() - - @callback - def _on_entry_data_changed(self) -> None: - entry_data = self._entry_data - self._api_version = entry_data.api_version - self._client = entry_data.client - - @callback - def _on_device_update(self) -> None: - """Call when device updates or entry data changes.""" - self._on_entry_data_changed() - if not self._entry_data.available: - # Only write state if the device has gone unavailable - # since _on_state_update will be called if the device - # is available when the full state arrives - # through the next entity state packet. - self.async_write_ha_state() - - @property - def available(self) -> bool: - """Return if the entity is available.""" - if self._device_info.has_deep_sleep: - # During deep sleep the ESP will not be connectable (by design) - # For these cases, show it as available - return True - - return self._entry_data.available - - -class EsphomeAssistEntity(Entity): - """Define a base entity for Assist Pipeline entities.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, entry_data: RuntimeEntryData) -> None: - """Initialize the binary sensor.""" - self._entry_data: RuntimeEntryData = entry_data - assert entry_data.device_info is not None - device_info = entry_data.device_info - self._device_info = device_info - self._attr_unique_id = ( - f"{device_info.mac_address}-{self.entity_description.key}" - ) - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} - ) - - @callback - def _update(self) -> None: - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update callback.""" - await super().async_added_to_hass() - self.async_on_remove( - self._entry_data.async_subscribe_assist_pipeline_update(self._update) - ) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index efa95dda710..6fadd7d4408 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -30,7 +30,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, EsphomeEnumMapper, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + platform_async_setup_entry, +) +from .enum_mapper import EsphomeEnumMapper _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ AlarmControlPanelState, str diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 81ffee1a380..a755bcf10ef 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -13,8 +13,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry from .domain_data import DomainData +from .entity import ( + EsphomeAssistEntity, + EsphomeEntity, + platform_async_setup_entry, +) async def async_setup_entry( diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 4b2f02b266d..71bb7017c55 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -9,7 +9,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + platform_async_setup_entry, +) async def async_setup_entry( diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 390208f689d..17f73f7c770 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -13,7 +13,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + platform_async_setup_entry, +) async def async_setup_entry( diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index b01e89ec2c8..5c252e888d9 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -54,7 +54,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper FAN_QUIET = "quiet" diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 4fb9924613d..347ff98e689 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -17,7 +17,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py new file mode 100644 index 00000000000..18bf15ce4ee --- /dev/null +++ b/homeassistant/components/esphome/entity.py @@ -0,0 +1,310 @@ +"""Support for esphome entities.""" +from __future__ import annotations + +from collections.abc import Callable +import functools +import math +from typing import ( # pylint: disable=unused-import + Any, + Generic, + TypeVar, + cast, +) + +from aioesphomeapi import ( + EntityCategory as EsphomeEntityCategory, + EntityInfo, + EntityState, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .domain_data import DomainData + +# Import config flow so that it's added to the registry +from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper + +_R = TypeVar("_R") +_InfoT = TypeVar("_InfoT", bound=EntityInfo) +_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") +_StateT = TypeVar("_StateT", bound=EntityState) + + +async def platform_async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + *, + component_key: str, + info_type: type[_InfoT], + entity_type: type[_EntityT], + state_type: type[_StateT], +) -> None: + """Set up an esphome platform. + + This method is in charge of receiving, distributing and storing + info and state updates. + """ + entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) + entry_data.info[component_key] = {} + entry_data.old_info[component_key] = {} + entry_data.state.setdefault(state_type, {}) + + @callback + def async_list_entities(infos: list[EntityInfo]) -> None: + """Update entities of this platform when entities are listed.""" + old_infos = entry_data.info[component_key] + new_infos: dict[int, EntityInfo] = {} + add_entities: list[_EntityT] = [] + for info in infos: + if info.key in old_infos: + # Update existing entity + old_infos.pop(info.key) + else: + # Create new entity + entity = entity_type(entry_data, component_key, info, state_type) + add_entities.append(entity) + new_infos[info.key] = info + + # Remove old entities + for info in old_infos.values(): + entry_data.async_remove_entity(hass, component_key, info.key) + + # First copy the now-old info into the backup object + entry_data.old_info[component_key] = entry_data.info[component_key] + # Then update the actual info + entry_data.info[component_key] = new_infos + + for key, new_info in new_infos.items(): + async_dispatcher_send( + hass, + entry_data.signal_component_key_static_info_updated(component_key, key), + new_info, + ) + + if add_entities: + # Add entities to Home Assistant + async_add_entities(add_entities) + + entry_data.cleanup_callbacks.append( + entry_data.async_register_static_info_callback(info_type, async_list_entities) + ) + + +def esphome_state_property( + func: Callable[[_EntityT], _R] +) -> Callable[[_EntityT], _R | None]: + """Wrap a state property of an esphome entity. + + This checks if the state object in the entity is set, and + prevents writing NAN values to the Home Assistant state machine. + """ + + @functools.wraps(func) + def _wrapper(self: _EntityT) -> _R | None: + # pylint: disable-next=protected-access + if not self._has_state: + return None + val = func(self) + if isinstance(val, float) and math.isnan(val): + # Home Assistant doesn't use NAN values in state machine + # (not JSON serializable) + return None + return val + + return _wrapper + + +ICON_SCHEMA = vol.Schema(cv.icon) + + +ENTITY_CATEGORIES: EsphomeEnumMapper[ + EsphomeEntityCategory, EntityCategory | None +] = EsphomeEnumMapper( + { + EsphomeEntityCategory.NONE: None, + EsphomeEntityCategory.CONFIG: EntityCategory.CONFIG, + EsphomeEntityCategory.DIAGNOSTIC: EntityCategory.DIAGNOSTIC, + } +) + + +class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): + """Define a base esphome entity.""" + + _attr_should_poll = False + _static_info: _InfoT + _state: _StateT + _has_state: bool + + def __init__( + self, + entry_data: RuntimeEntryData, + component_key: str, + entity_info: EntityInfo, + state_type: type[_StateT], + ) -> None: + """Initialize.""" + self._entry_data = entry_data + self._on_entry_data_changed() + self._component_key = component_key + self._key = entity_info.key + self._state_type = state_type + self._on_static_info_update(entity_info) + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info + self._attr_has_entity_name = bool(device_info.friendly_name) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + self._entry_id = entry_data.entry_id + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + entry_data = self._entry_data + hass = self.hass + component_key = self._component_key + key = self._key + + self.async_on_remove( + async_dispatcher_connect( + hass, + f"esphome_{self._entry_id}_remove_{component_key}_{key}", + functools.partial(self.async_remove, force_remove=True), + ) + ) + self.async_on_remove( + async_dispatcher_connect( + hass, + entry_data.signal_device_updated, + self._on_device_update, + ) + ) + self.async_on_remove( + entry_data.async_subscribe_state_update( + self._state_type, key, self._on_state_update + ) + ) + self.async_on_remove( + async_dispatcher_connect( + hass, + entry_data.signal_component_key_static_info_updated(component_key, key), + self._on_static_info_update, + ) + ) + self._update_state_from_entry_data() + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Save the static info for this entity when it changes. + + This method can be overridden in child classes to know + when the static info changes. + """ + static_info = cast(_InfoT, static_info) + self._static_info = static_info + self._attr_unique_id = static_info.unique_id + self._attr_entity_registry_enabled_default = not static_info.disabled_by_default + self._attr_name = static_info.name + if entity_category := static_info.entity_category: + self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) + else: + self._attr_entity_category = None + if icon := static_info.icon: + self._attr_icon = cast(str, ICON_SCHEMA(icon)) + else: + self._attr_icon = None + + @callback + def _update_state_from_entry_data(self) -> None: + """Update state from entry data.""" + + state = self._entry_data.state + key = self._key + state_type = self._state_type + has_state = key in state[state_type] + if has_state: + self._state = cast(_StateT, state[state_type][key]) + self._has_state = has_state + + @callback + def _on_state_update(self) -> None: + """Call when state changed. + + Behavior can be changed in child classes + """ + self._update_state_from_entry_data() + self.async_write_ha_state() + + @callback + def _on_entry_data_changed(self) -> None: + entry_data = self._entry_data + self._api_version = entry_data.api_version + self._client = entry_data.client + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + self._on_entry_data_changed() + if not self._entry_data.available: + # Only write state if the device has gone unavailable + # since _on_state_update will be called if the device + # is available when the full state arrives + # through the next entity state packet. + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return if the entity is available.""" + if self._device_info.has_deep_sleep: + # During deep sleep the ESP will not be connectable (by design) + # For these cases, show it as available + return True + + return self._entry_data.available + + +class EsphomeAssistEntity(Entity): + """Define a base entity for Assist Pipeline entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, entry_data: RuntimeEntryData) -> None: + """Initialize the binary sensor.""" + self._entry_data: RuntimeEntryData = entry_data + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info + self._attr_unique_id = ( + f"{device_info.mac_address}-{self.entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + + @callback + def _update(self) -> None: + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + await super().async_added_to_hass() + self.async_on_remove( + self._entry_data.async_subscribe_assist_pipeline_update(self._update) + ) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 040e6585a4b..388413f161f 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -22,7 +22,11 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index a17c49caa73..f4232e320b0 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -31,7 +31,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index d13d2d333dc..0cfc25e3882 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -11,7 +11,11 @@ from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index d554207f563..9933f523c26 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -25,7 +25,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 786512f3a42..ead3d5c4307 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -16,7 +16,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 1323c9f5666..2de6ddd7111 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -9,13 +9,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( +from .domain_data import DomainData +from .entity import ( EsphomeAssistEntity, EsphomeEntity, esphome_state_property, platform_async_setup_entry, ) -from .domain_data import DomainData from .entry_data import RuntimeEntryData diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 6c1fca1ffef..ac2fb9629a8 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -25,7 +25,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index e71853a1287..4ecee203fa0 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -11,7 +11,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( From ef2e55ecec5ebf96cd4cbb56fa798b6a6a49b22d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 22:18:38 -0500 Subject: [PATCH 516/857] Add dual IP stack support to HomeKit (#94126) * Add dual IP stack support to HomeKit * fix tests * Update homeassistant/components/network/__init__.py * remove scopes * Bump HAP-python to 4.7.0 fixes pairing with newer iOS changelog: https://github.com/ikalchev/HAP-python/compare/v4.6.0...4.7.0 * fixes * update tests * Relocate get_announced_addresses from zeroconf to network needed for #94126 * rename * rename * Update homeassistant/components/network/__init__.py * Update homeassistant/components/network/__init__.py * rename * fix import * coverage --- homeassistant/components/homekit/__init__.py | 20 +++++++++++--------- tests/components/homekit/test_homekit.py | 16 ++++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 2b56a056821..9a25a28aa1c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -28,7 +28,6 @@ from homeassistant.components.device_automation.trigger import ( ) from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN -from homeassistant.components.network import MDNS_TARGET_IP from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -168,7 +167,9 @@ BRIDGE_SCHEMA = vol.All( ), vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_ADVERTISE_IP): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_ADVERTISE_IP): vol.All( + cv.ensure_list, ipaddress.ip_address, cv.string + ), vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, vol.Optional(CONF_DEVICES): cv.ensure_list, @@ -303,9 +304,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # ip_address and advertise_ip are yaml only ip_address = conf.get(CONF_IP_ADDRESS, [None]) - advertise_ip = conf.get( - CONF_ADVERTISE_IP, await network.async_get_source_ip(hass, MDNS_TARGET_IP) - ) + advertise_ips: list[str] = conf.get( + CONF_ADVERTISE_IP + ) or await network.async_get_announce_addresses(hass) + # exclude_accessory_mode is only used for config flow # to indicate that the config entry was setup after # we started creating config entries for entities that @@ -331,7 +333,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: exclude_accessory_mode, entity_config, homekit_mode, - advertise_ip, + advertise_ips, entry.entry_id, entry.title, devices=devices, @@ -508,7 +510,7 @@ class HomeKit: exclude_accessory_mode: bool, entity_config: dict, homekit_mode: str, - advertise_ip: str | None, + advertise_ips: list[str], entry_id: str, entry_title: str, devices: list[str] | None = None, @@ -521,7 +523,7 @@ class HomeKit: self._filter = entity_filter self._config = entity_config self._exclude_accessory_mode = exclude_accessory_mode - self._advertise_ip = advertise_ip + self._advertise_ips = advertise_ips self._entry_id = entry_id self._entry_title = entry_title self._homekit_mode = homekit_mode @@ -547,7 +549,7 @@ class HomeKit: address=self._ip_address, port=self._port, persist_file=persist_file, - advertised_address=self._advertise_ip, + advertised_address=self._advertise_ips, async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=get_loader(), diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index fb1191b59de..5c154a50bec 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -116,7 +116,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): exclude_accessory_mode=False, entity_config={}, homekit_mode=homekit_mode, - advertise_ip=None, + advertise_ips=None, entry_id=entry.entry_id, entry_title=entry.title, devices=devices, @@ -170,7 +170,7 @@ async def test_setup_min(hass: HomeAssistant, mock_async_zeroconf: None) -> None ANY, {}, HOMEKIT_MODE_BRIDGE, - "1.2.3.4", + ["1.2.3.4", "10.10.10.10"], entry.entry_id, entry.title, devices=[], @@ -212,7 +212,7 @@ async def test_removing_entry( ANY, {}, HOMEKIT_MODE_BRIDGE, - "1.2.3.4", + ["1.2.3.4", "10.10.10.10"], entry.entry_id, entry.title, devices=[], @@ -245,7 +245,7 @@ async def test_homekit_setup( {}, {}, HOMEKIT_MODE_BRIDGE, - advertise_ip=None, + advertise_ips=None, entry_id=entry.entry_id, entry_title=entry.title, ) @@ -322,7 +322,7 @@ async def test_homekit_setup_ip_address( ) -async def test_homekit_setup_advertise_ip( +async def test_homekit_setup_advertise_ips( hass: HomeAssistant, hk_driver, mock_async_zeroconf: None ) -> None: """Test setup with given IP address to advertise.""" @@ -1485,7 +1485,7 @@ async def test_yaml_updates_update_config_entry_for_name( ANY, {}, HOMEKIT_MODE_BRIDGE, - "1.2.3.4", + ["1.2.3.4", "10.10.10.10"], entry.entry_id, entry.title, devices=[], @@ -1858,7 +1858,7 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: False, {}, HOMEKIT_MODE_BRIDGE, - "1.2.3.4", + ["1.2.3.4", "10.10.10.10"], entry.entry_id, entry.title, devices=[], @@ -1893,7 +1893,7 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: False, {}, HOMEKIT_MODE_BRIDGE, - "1.2.3.4", + ["1.2.3.4", "10.10.10.10"], entry.entry_id, entry.title, devices=[], From 528c2060945ecc8914c54416dbcc2fc35b10f69b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 24 Jun 2023 21:34:57 -0700 Subject: [PATCH 517/857] Add script specific error messages for `response_variable` (#95188) --- homeassistant/core.py | 11 +++++++ homeassistant/helpers/script.py | 23 +++++++++++++-- tests/helpers/test_script.py | 51 +++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 1993e657368..47b52d9ff76 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1753,6 +1753,17 @@ class ServiceRegistry: """ return service.lower() in self._services.get(domain.lower(), []) + def supports_response(self, domain: str, service: str) -> SupportsResponse: + """Return whether or not the service supports response data. + + This exists so that callers can return more helpful error messages given + the context. Will return NONE if the service does not exist as there is + other error handling when calling the service if it does not exist. + """ + if not (handler := self._services[domain][service]): + return SupportsResponse.NONE + return handler.supports_response + def register( self, domain: str, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index ee4346ff388..93d04d5f6df 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -69,6 +69,7 @@ from homeassistant.core import ( Event, HassJob, HomeAssistant, + SupportsResponse, callback, ) from homeassistant.util import slugify @@ -661,12 +662,30 @@ class _ScriptRun: self._hass, self._action, self._variables ) + # Validate response data paraters. This check ignores services that do + # not exist which will raise an appropriate error in the service call below. + response_variable = self._action.get(CONF_RESPONSE_VARIABLE) + return_response = response_variable is not None + if self._hass.services.has_service(params[CONF_DOMAIN], params[CONF_SERVICE]): + supports_response = self._hass.services.supports_response( + params[CONF_DOMAIN], params[CONF_SERVICE] + ) + if supports_response == SupportsResponse.ONLY and not return_response: + raise vol.Invalid( + f"Script requires '{CONF_RESPONSE_VARIABLE}' for response data " + f"for service call {params[CONF_DOMAIN]}.{params[CONF_SERVICE]}" + ) + if supports_response == SupportsResponse.NONE and return_response: + raise vol.Invalid( + f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service " + f"'{CONF_RESPONSE_VARIABLE}' which does not support response data." + ) + running_script = ( params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger" or params[CONF_DOMAIN] in ("python_script", "script") ) - response_variable = self._action.get(CONF_RESPONSE_VARIABLE) trace_set_result(params=params, running_script=running_script) response_data = await self._async_run_long_action( self._hass.async_create_task( @@ -674,7 +693,7 @@ class _ScriptRun: **params, blocking=True, context=self._context, - return_response=(response_variable is not None), + return_response=return_response, ) ), ) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index de13557024a..7f66ec25977 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -28,6 +28,7 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, + SupportsResponse, callback, ) from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound @@ -333,7 +334,7 @@ async def test_calling_service_template(hass: HomeAssistant) -> None: async def test_calling_service_response_data( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Test the calling of a service with return values.""" + """Test the calling of a service with response data.""" context = Context() def mock_service(call: ServiceCall) -> ServiceResponse: @@ -342,7 +343,9 @@ async def test_calling_service_response_data( return {"data": "value-12345"} return None - hass.services.async_register("test", "script", mock_service, supports_response=True) + hass.services.async_register( + "test", "script", mock_service, supports_response=SupportsResponse.OPTIONAL + ) sequence = cv.SCRIPT_SCHEMA( [ { @@ -404,6 +407,50 @@ async def test_calling_service_response_data( ) +@pytest.mark.parametrize( + ("supports_response", "params", "expected_error"), + [ + ( + SupportsResponse.NONE, + {"response_variable": "foo"}, + "does not support 'response_variable'", + ), + (SupportsResponse.ONLY, {}, "requires 'response_variable'"), + ], +) +async def test_service_response_data_errors( + hass: HomeAssistant, + supports_response: SupportsResponse, + params: dict[str, str], + expected_error: str, +) -> None: + """Test the calling of a service with response data error cases.""" + context = Context() + + def mock_service(call: ServiceCall) -> ServiceResponse: + """Mock service call.""" + raise ValueError("Never invoked") + + hass.services.async_register( + "test", "script", mock_service, supports_response=supports_response + ) + + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "service step1", + "service": "test.script", + **params, + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + with pytest.raises(vol.Invalid, match=expected_error): + await script_obj.async_run(context=context) + await hass.async_block_till_done() + + async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: """Test the calling of a service with a data_template with a templated key.""" context = Context() From 58ddd1749563b3af6d805724b3c6b018b5015c1e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 25 Jun 2023 12:59:04 +0200 Subject: [PATCH 518/857] Add entity translations to Deluge (#95184) * Add entity translations to Deluge * Update sensor.py * Fix black --- homeassistant/components/deluge/sensor.py | 14 ++++++++------ homeassistant/components/deluge/strings.json | 19 +++++++++++++++++++ homeassistant/components/deluge/switch.py | 2 ++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index eed194640dd..9242e3e2d5e 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -28,11 +28,11 @@ def get_state(data: dict[str, float], key: str) -> str | float: download = data[DATA_KEYS[1]] - data[DATA_KEYS[3]] if key == CURRENT_STATUS: if upload > 0 and download > 0: - return "Up/Down" + return "seeding_and_downloading" if upload > 0 and download == 0: - return "Seeding" + return "seeding" if upload == 0 and download > 0: - return "Downloading" + return "downloading" return STATE_IDLE kb_spd = float(upload if key == UPLOAD_SPEED else download) / 1024 return round(kb_spd, 2 if kb_spd < 0.1 else 1) @@ -48,12 +48,14 @@ class DelugeSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( DelugeSensorEntityDescription( key=CURRENT_STATUS, - name="Status", + translation_key="status", value=lambda data: get_state(data, CURRENT_STATUS), + device_class=SensorDeviceClass.ENUM, + options=["seeding_and_downloading", "seeding", "downloading", "idle"], ), DelugeSensorEntityDescription( key=DOWNLOAD_SPEED, - name="Down speed", + translation_key="download_speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -61,7 +63,7 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( ), DelugeSensorEntityDescription( key=UPLOAD_SPEED, - name="Up speed", + translation_key="upload_speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index f11e1a2bd3e..e0266d004e2 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -19,5 +19,24 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status", + "state": { + "seeding_and_downloading": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading", + "idle": "[%key:common::state::idle%]" + } + }, + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + } + } } } diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index f9e89543d26..483b02844d6 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -24,6 +24,8 @@ async def async_setup_entry( class DelugeSwitch(DelugeEntity, SwitchEntity): """Representation of a Deluge switch.""" + _attr_name = None + def __init__(self, coordinator: DelugeDataUpdateCoordinator) -> None: """Initialize the Deluge switch.""" super().__init__(coordinator) From dffe468ceba3d22083d47f95e1698b523aa1ab1b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 25 Jun 2023 13:01:22 +0200 Subject: [PATCH 519/857] Use device class translations for Broadlink (#95183) --- homeassistant/components/broadlink/sensor.py | 7 +------ .../components/broadlink/strings.json | 18 ------------------ 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 50c58d41667..747418e1e79 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -25,18 +25,16 @@ from .entity import BroadlinkEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="air_quality", - translation_key="air_quality", + device_class=SensorDeviceClass.AQI, ), SensorEntityDescription( key="humidity", - translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -51,21 +49,18 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="power", - translation_key="power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volt", - translation_key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current", - translation_key="current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index adff2303c74..87567bcb7b1 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -46,30 +46,12 @@ }, "entity": { "sensor": { - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "air_quality": { - "name": "[%key:component::sensor::entity_component::aqi::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "light": { "name": "[%key:component::sensor::entity_component::illuminance::name%]" }, "noise": { "name": "Noise" }, - "power": { - "name": "[%key:component::sensor::entity_component::power::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, - "current": { - "name": "[%key:component::sensor::entity_component::current::name%]" - }, "overload": { "name": "Overload" }, From f84887d5f8d2e2546214b95903429325e541f9a7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 25 Jun 2023 13:10:37 +0200 Subject: [PATCH 520/857] Use device class translations for Coolmaster (#95182) --- .../components/coolmaster/binary_sensor.py | 3 +-- homeassistant/components/coolmaster/button.py | 3 +-- homeassistant/components/coolmaster/climate.py | 7 ++----- homeassistant/components/coolmaster/entity.py | 2 ++ homeassistant/components/coolmaster/sensor.py | 3 +-- .../components/coolmaster/strings.json | 17 +++++++++++++++++ 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py index 884cf98b742..29fd5797124 100644 --- a/homeassistant/components/coolmaster/binary_sensor.py +++ b/homeassistant/components/coolmaster/binary_sensor.py @@ -32,12 +32,11 @@ async def async_setup_entry( class CoolmasterCleanFilter(CoolmasterEntity, BinarySensorEntity): """Representation of a unit's filter state (true means need to be cleaned).""" - _attr_has_entity_name = True entity_description = BinarySensorEntityDescription( key="clean_filter", + translation_key="clean_filter", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Clean filter", icon="mdi:air-filter", ) diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py index a32a9833dd9..e4dfb371a0b 100644 --- a/homeassistant/components/coolmaster/button.py +++ b/homeassistant/components/coolmaster/button.py @@ -28,11 +28,10 @@ async def async_setup_entry( class CoolmasterResetFilter(CoolmasterEntity, ButtonEntity): """Reset the clean filter timer (once filter was cleaned).""" - _attr_has_entity_name = True entity_description = ButtonEntityDescription( key="reset_filter", + translation_key="reset_filter", entity_category=EntityCategory.CONFIG, - name="Reset filter", icon="mdi:air-filter", ) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index d27f776c655..6ae6613bcca 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -53,6 +53,8 @@ async def async_setup_entry( class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Representation of a coolmaster climate device.""" + _attr_name = None + def __init__(self, coordinator, unit_id, info, supported_modes): """Initialize the climate device.""" super().__init__(coordinator, unit_id, info) @@ -63,11 +65,6 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Return unique ID for this device.""" return self._unit_id - @property - def name(self): - """Return the name of the climate device.""" - return self.unique_id - @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" diff --git a/homeassistant/components/coolmaster/entity.py b/homeassistant/components/coolmaster/entity.py index 65f21b77534..1607e220a55 100644 --- a/homeassistant/components/coolmaster/entity.py +++ b/homeassistant/components/coolmaster/entity.py @@ -12,6 +12,8 @@ from .const import DOMAIN class CoolmasterEntity(CoordinatorEntity[CoolmasterDataUpdateCoordinator]): """Representation of a Coolmaster entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: CoolmasterDataUpdateCoordinator, diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py index 59b0e71abb2..5c6774e8c92 100644 --- a/homeassistant/components/coolmaster/sensor.py +++ b/homeassistant/components/coolmaster/sensor.py @@ -28,11 +28,10 @@ async def async_setup_entry( class CoolmasterCleanFilter(CoolmasterEntity, SensorEntity): """Representation of a unit's error code.""" - _attr_has_entity_name = True entity_description = SensorEntityDescription( key="error_code", + translation_key="error_code", entity_category=EntityCategory.DIAGNOSTIC, - name="Error code", icon="mdi:alert", ) diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 6bba26b6bc9..7baa6444c1d 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -19,5 +19,22 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_units": "Could not find any HVAC units in CoolMasterNet host." } + }, + "entity": { + "binary_sensor": { + "clean_filter": { + "name": "Clean filter" + } + }, + "button": { + "reset_filter": { + "name": "Reset filter" + } + }, + "sensor": { + "error_code": { + "name": "Error code" + } + } } } From 2ce23c17ca75dabbeaaa59898019f24e90a2291c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 25 Jun 2023 14:58:08 +0200 Subject: [PATCH 521/857] Update KNX frontend - add Group monitor telegram detail view (#95144) * Use TelegramDict for WS communication * Update knx_frontend --- homeassistant/components/knx/const.py | 14 --- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/telegrams.py | 15 ++- homeassistant/components/knx/websocket.py | 56 ++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/knx/test_websocket.py | 125 +++++++++++---------- 7 files changed, 89 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 5546a2d6fd9..bacd678bb99 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -114,20 +114,6 @@ class KNXConfigEntryData(TypedDict, total=False): telegram_log_size: int # not required -class KNXBusMonitorMessage(TypedDict): - """KNX bus monitor message.""" - - destination_address: str - destination_text: str | None - payload: str - type: str - value: str | None - source_address: str - source_text: str | None - direction: str - timestamp: str - - class ColorTempModes(Enum): """Color temperature modes for config validation.""" diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 822b18866c7..6cb6a4ffcf7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,6 +13,6 @@ "requirements": [ "xknx==2.10.0", "xknxproject==3.2.0", - "knx-frontend==2023.6.9.195839" + "knx-frontend==2023.6.23.191712" ] } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 5b429b0bdc1..09307794066 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -20,9 +20,13 @@ from .project import KNXProject class TelegramDict(TypedDict): """Represent a Telegram as a dict.""" + # this has to be in sync with the frontend implementation destination: str destination_name: str direction: str + dpt_main: int | None + dpt_sub: int | None + dpt_name: str | None payload: int | tuple[int, ...] | None source: str source_name: str @@ -57,7 +61,7 @@ class Telegrams: async def _xknx_telegram_cb(self, telegram: Telegram) -> None: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) - self.recent_telegrams.appendleft(telegram_dict) + self.recent_telegrams.append(telegram_dict) for job in self._jobs: self.hass.async_run_hass_job(job, telegram_dict) @@ -80,6 +84,9 @@ class Telegrams: def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: """Convert a Telegram to a dict.""" dst_name = "" + dpt_main = None + dpt_sub = None + dpt_name = None payload_data: int | tuple[int, ...] | None = None src_name = "" transcoder = None @@ -104,6 +111,9 @@ class Telegrams: if transcoder is not None: try: value = transcoder.from_knx(telegram.payload.value) + dpt_main = transcoder.dpt_main_number + dpt_sub = transcoder.dpt_sub_number + dpt_name = transcoder.value_type unit = transcoder.unit except XKNXException: value = "Error decoding value" @@ -112,6 +122,9 @@ class Telegrams: destination=f"{telegram.destination_address}", destination_name=dst_name, direction=telegram.direction.value, + dpt_main=dpt_main, + dpt_sub=dpt_sub, + dpt_name=dpt_name, payload=payload_data, source=f"{telegram.source_address}", source_name=src_name, diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index feb53ddc908..ad29fd19928 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -3,15 +3,14 @@ from __future__ import annotations from typing import TYPE_CHECKING, Final -from knx_frontend import entrypoint_js, is_dev_build, locate_dir +import knx_frontend as knx_panel import voluptuous as vol -from xknx.telegram import TelegramDirection from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, KNXBusMonitorMessage +from .const import DOMAIN from .telegrams import TelegramDict if TYPE_CHECKING: @@ -30,19 +29,18 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_subscribe_telegram) if DOMAIN not in hass.data.get("frontend_panels", {}): - path = locate_dir() hass.http.register_static_path( URL_BASE, - path, - cache_headers=not is_dev_build(), + path=knx_panel.locate_dir(), + cache_headers=knx_panel.is_prod_build, ) await panel_custom.async_register_panel( hass=hass, frontend_url_path=DOMAIN, - webcomponent_name="knx-frontend", + webcomponent_name=knx_panel.webcomponent_name, sidebar_title=DOMAIN.upper(), sidebar_icon="mdi:bus-electric", - module_url=f"{URL_BASE}/{entrypoint_js()}", + module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}", embed_iframe=True, require_admin=True, ) @@ -145,10 +143,7 @@ def ws_group_monitor_info( ) -> None: """Handle get info command of group monitor.""" knx: KNXModule = hass.data[DOMAIN] - recent_telegrams = [ - _telegram_dict_to_group_monitor(telegram) - for telegram in knx.telegrams.recent_telegrams - ] + recent_telegrams = [*knx.telegrams.recent_telegrams] connection.send_result( msg["id"], { @@ -178,7 +173,7 @@ def ws_subscribe_telegram( """Forward telegram to websocket subscription.""" connection.send_event( msg["id"], - _telegram_dict_to_group_monitor(telegram), + telegram, ) connection.subscriptions[msg["id"]] = knx.telegrams.async_listen_telegram( @@ -186,38 +181,3 @@ def ws_subscribe_telegram( name="KNX GroupMonitor subscription", ) connection.send_result(msg["id"]) - - -def _telegram_dict_to_group_monitor(telegram: TelegramDict) -> KNXBusMonitorMessage: - """Convert a TelegramDict to a KNXBusMonitorMessage object.""" - direction = ( - "group_monitor_incoming" - if telegram["direction"] == TelegramDirection.INCOMING.value - else "group_monitor_outgoing" - ) - - _payload = telegram["payload"] - if isinstance(_payload, tuple): - payload = f"0x{bytes(_payload).hex()}" - elif isinstance(_payload, int): - payload = f"{_payload:d}" - else: - payload = "" - - timestamp = telegram["timestamp"].strftime("%H:%M:%S.%f")[:-3] - - if (value := telegram["value"]) is not None: - unit = telegram["unit"] - value = f"{value}{' ' + unit if unit else ''}" - - return KNXBusMonitorMessage( - destination_address=telegram["destination"], - destination_text=telegram["destination_name"], - direction=direction, - payload=payload, - source_address=telegram["source"], - source_text=telegram["source_name"], - timestamp=timestamp, - type=telegram["telegramtype"], - value=value, - ) diff --git a/requirements_all.txt b/requirements_all.txt index 76cbe362ded..2546116f246 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1089,7 +1089,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx-frontend==2023.6.9.195839 +knx-frontend==2023.6.23.191712 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fcf65c18a1..d323757226c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -842,7 +842,7 @@ justnimbus==0.6.0 kegtron-ble==0.4.0 # homeassistant.components.knx -knx-frontend==2023.6.9.195839 +knx-frontend==2023.6.23.191712 # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 115b92f70e8..76a9544552f 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -183,22 +183,22 @@ async def test_knx_subscribe_telegrams_command_recent_telegrams( recent_tgs = res["result"]["recent_telegrams"] assert len(recent_tgs) == 2 - # telegrams are sorted from newest to oldest - assert recent_tgs[0]["destination_address"] == "1/2/4" - assert recent_tgs[0]["payload"] == "1" - assert recent_tgs[0]["type"] == "GroupValueWrite" - assert ( - recent_tgs[0]["source_address"] == "0.0.0" - ) # needs to be the IA currently connected to - assert recent_tgs[0]["direction"] == "group_monitor_outgoing" - assert recent_tgs[0]["timestamp"] is not None + # telegrams are sorted from oldest to newest + assert recent_tgs[0]["destination"] == "1/3/4" + assert recent_tgs[0]["payload"] == 1 + assert recent_tgs[0]["telegramtype"] == "GroupValueWrite" + assert recent_tgs[0]["source"] == "1.2.3" + assert recent_tgs[0]["direction"] == "Incoming" + assert isinstance(recent_tgs[0]["timestamp"], str) - assert recent_tgs[1]["destination_address"] == "1/3/4" - assert recent_tgs[1]["payload"] == "1" - assert recent_tgs[1]["type"] == "GroupValueWrite" - assert recent_tgs[1]["source_address"] == "1.2.3" - assert recent_tgs[1]["direction"] == "group_monitor_incoming" - assert recent_tgs[1]["timestamp"] is not None + assert recent_tgs[1]["destination"] == "1/2/4" + assert recent_tgs[1]["payload"] == 1 + assert recent_tgs[1]["telegramtype"] == "GroupValueWrite" + assert ( + recent_tgs[1]["source"] == "0.0.0" + ) # needs to be the IA currently connected to + assert recent_tgs[1]["direction"] == "Outgoing" + assert isinstance(recent_tgs[1]["timestamp"], str) async def test_knx_subscribe_telegrams_command_no_project( @@ -231,45 +231,45 @@ async def test_knx_subscribe_telegrams_command_no_project( # receive events res = await client.receive_json() - assert res["event"]["destination_address"] == "1/2/3" - assert res["event"]["payload"] == "" - assert res["event"]["type"] == "GroupValueRead" - assert res["event"]["source_address"] == "1.2.3" - assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["destination"] == "1/2/3" + assert res["event"]["payload"] is None + assert res["event"]["telegramtype"] == "GroupValueRead" + assert res["event"]["source"] == "1.2.3" + assert res["event"]["direction"] == "Incoming" assert res["event"]["timestamp"] is not None res = await client.receive_json() - assert res["event"]["destination_address"] == "1/3/4" - assert res["event"]["payload"] == "1" - assert res["event"]["type"] == "GroupValueWrite" - assert res["event"]["source_address"] == "1.2.3" - assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["destination"] == "1/3/4" + assert res["event"]["payload"] == 1 + assert res["event"]["telegramtype"] == "GroupValueWrite" + assert res["event"]["source"] == "1.2.3" + assert res["event"]["direction"] == "Incoming" assert res["event"]["timestamp"] is not None res = await client.receive_json() - assert res["event"]["destination_address"] == "1/3/4" - assert res["event"]["payload"] == "0" - assert res["event"]["type"] == "GroupValueWrite" - assert res["event"]["source_address"] == "1.2.3" - assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["destination"] == "1/3/4" + assert res["event"]["payload"] == 0 + assert res["event"]["telegramtype"] == "GroupValueWrite" + assert res["event"]["source"] == "1.2.3" + assert res["event"]["direction"] == "Incoming" assert res["event"]["timestamp"] is not None res = await client.receive_json() - assert res["event"]["destination_address"] == "1/3/8" - assert res["event"]["payload"] == "0x3445" - assert res["event"]["type"] == "GroupValueWrite" - assert res["event"]["source_address"] == "1.2.3" - assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["destination"] == "1/3/8" + assert res["event"]["payload"] == [52, 69] + assert res["event"]["telegramtype"] == "GroupValueWrite" + assert res["event"]["source"] == "1.2.3" + assert res["event"]["direction"] == "Incoming" assert res["event"]["timestamp"] is not None res = await client.receive_json() - assert res["event"]["destination_address"] == "1/2/4" - assert res["event"]["payload"] == "1" - assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["destination"] == "1/2/4" + assert res["event"]["payload"] == 1 + assert res["event"]["telegramtype"] == "GroupValueWrite" assert ( - res["event"]["source_address"] == "0.0.0" + res["event"]["source"] == "0.0.0" ) # needs to be the IA currently connected to - assert res["event"]["direction"] == "group_monitor_outgoing" + assert res["event"]["direction"] == "Outgoing" assert res["event"]["timestamp"] is not None @@ -289,42 +289,45 @@ async def test_knx_subscribe_telegrams_command_project( # incoming DPT 1 telegram await knx.receive_write("0/0/1", True) res = await client.receive_json() - assert res["event"]["destination_address"] == "0/0/1" - assert res["event"]["destination_text"] == "Binary" - assert res["event"]["payload"] == "1" - assert res["event"]["type"] == "GroupValueWrite" - assert res["event"]["source_address"] == "1.2.3" - assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["destination"] == "0/0/1" + assert res["event"]["destination_name"] == "Binary" + assert res["event"]["payload"] == 1 + assert res["event"]["telegramtype"] == "GroupValueWrite" + assert res["event"]["source"] == "1.2.3" + assert res["event"]["direction"] == "Incoming" assert res["event"]["timestamp"] is not None # incoming DPT 5 telegram await knx.receive_write("0/1/1", (0x50,), source="1.1.6") res = await client.receive_json() - assert res["event"]["destination_address"] == "0/1/1" - assert res["event"]["destination_text"] == "percent" - assert res["event"]["payload"] == "0x50" - assert res["event"]["value"] == "31 %" - assert res["event"]["type"] == "GroupValueWrite" - assert res["event"]["source_address"] == "1.1.6" + assert res["event"]["destination"] == "0/1/1" + assert res["event"]["destination_name"] == "percent" + assert res["event"]["payload"] == [ + 80, + ] + assert res["event"]["value"] == 31 + assert res["event"]["unit"] == "%" + assert res["event"]["telegramtype"] == "GroupValueWrite" + assert res["event"]["source"] == "1.1.6" assert ( - res["event"]["source_text"] + res["event"]["source_name"] == "Enertex Bayern GmbH Enertex KNX LED Dimmsequenzer 20A/5x REG" ) - assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["direction"] == "Incoming" assert res["event"]["timestamp"] is not None # incoming undecodable telegram (wrong payload type) await knx.receive_write("0/1/1", True, source="1.1.6") res = await client.receive_json() - assert res["event"]["destination_address"] == "0/1/1" - assert res["event"]["destination_text"] == "percent" - assert res["event"]["payload"] == "1" + assert res["event"]["destination"] == "0/1/1" + assert res["event"]["destination_name"] == "percent" + assert res["event"]["payload"] == 1 assert res["event"]["value"] == "Error decoding value" - assert res["event"]["type"] == "GroupValueWrite" - assert res["event"]["source_address"] == "1.1.6" + assert res["event"]["telegramtype"] == "GroupValueWrite" + assert res["event"]["source"] == "1.1.6" assert ( - res["event"]["source_text"] + res["event"]["source_name"] == "Enertex Bayern GmbH Enertex KNX LED Dimmsequenzer 20A/5x REG" ) - assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["direction"] == "Incoming" assert res["event"]["timestamp"] is not None From 9051750add568fcad24721180f108e261c2b2f9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jun 2023 08:50:48 -0500 Subject: [PATCH 522/857] Cache entity translation lookups and keys (#95180) --- homeassistant/helpers/entity.py | 35 +++++++++++++++++++++++---------- tests/helpers/test_entity.py | 4 ++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 33899b76c87..dbf9fe2f2f0 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -312,6 +312,10 @@ class Entity(ABC): _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None + # Translation cache + _cached_name_translation_key: str | None = None + _cached_device_class_name: str | None = None + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -382,14 +386,19 @@ class Entity(ABC): def _device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" + if self._cached_device_class_name is not None: + return self._cached_device_class_name if not self.has_entity_name: return None device_class_key = self.device_class or "_" + platform = self.platform name_translation_key = ( - f"component.{self.platform.domain}.entity_component." - f"{device_class_key}.name" + f"component.{platform.domain}.entity_component." f"{device_class_key}.name" ) - return self.platform.component_translations.get(name_translation_key) + self._cached_device_class_name = platform.component_translations.get( + name_translation_key + ) + return self._cached_device_class_name def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class.""" @@ -397,24 +406,30 @@ class Entity(ABC): def _name_translation_key(self) -> str | None: """Return translation key for entity name.""" + if self._cached_name_translation_key is not None: + return self._cached_name_translation_key if self.translation_key is None: return None - return ( - f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + platform = self.platform + self._cached_name_translation_key = ( + f"component.{platform.platform_name}.entity.{platform.domain}" f".{self.translation_key}.name" ) + return self._cached_name_translation_key @property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" if hasattr(self, "_attr_name"): return self._attr_name - if self.has_entity_name and ( - name_translation_key := self._name_translation_key() + if ( + self.has_entity_name + and (name_translation_key := self._name_translation_key()) + and (name := self.platform.platform_translations.get(name_translation_key)) ): - if name_translation_key in self.platform.platform_translations: - name: str = self.platform.platform_translations[name_translation_key] - return name + if TYPE_CHECKING: + assert isinstance(name, str) + return name if hasattr(self, "entity_description"): description_name = self.entity_description.name if description_name is UNDEFINED and self._default_to_device_class_name(): diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 1861dc54844..85a7932aef8 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity, entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.typing import UNDEFINED from tests.common import ( @@ -983,6 +984,9 @@ async def _test_friendly_name( assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name assert (expected_warning in caplog.text) is warn_implicit_name + await async_update_entity(hass, ent.entity_id) + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name + @pytest.mark.parametrize( ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), From 67586caaf9f0bbeeb387be879f746ddfa1b23a9b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 25 Jun 2023 16:00:52 +0200 Subject: [PATCH 523/857] Cleanup ping (#95168) --- .strict-typing | 1 + .../components/ping/binary_sensor.py | 41 ++++++++----------- .../components/ping/device_tracker.py | 25 +++++++---- mypy.ini | 10 +++++ tests/components/ping/test_binary_sensor.py | 4 +- 5 files changed, 45 insertions(+), 36 deletions(-) diff --git a/.strict-typing b/.strict-typing index 500dd076767..465892430c1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -244,6 +244,7 @@ homeassistant.components.overkiz.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* +homeassistant.components.ping.* homeassistant.components.powerwall.* homeassistant.components.proximity.* homeassistant.components.prusalink.* diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index c8b4ce5a204..786012d466c 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -6,14 +6,14 @@ from contextlib import suppress from datetime import timedelta import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import async_timeout from icmplib import NameLookupError, async_ping import voluptuous as vol from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, ) @@ -53,7 +53,7 @@ PING_MATCHER_BUSYBOX = re.compile( WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms") -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME): cv.string, @@ -89,27 +89,14 @@ async def async_setup_platform( class PingBinarySensor(RestoreEntity, BinarySensorEntity): """Representation of a Ping Binary sensor.""" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + def __init__(self, name: str, ping: PingDataSubProcess | PingDataICMPLib) -> None: """Initialize the Ping Binary sensor.""" - self._available = False - self._name = name + self._attr_available = False + self._attr_name = name self._ping = ping - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def available(self) -> bool: - """Return if we have done the first ping.""" - return self._available - - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.CONNECTIVITY - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" @@ -130,7 +117,7 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): async def async_update(self) -> None: """Get the latest data.""" await self._ping.async_update() - self._available = True + self._attr_available = True async def async_added_to_hass(self) -> None: """Restore previous state on restart to avoid blocking startup.""" @@ -138,7 +125,7 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): last_state = await self.async_get_last_state() if last_state is not None: - self._available = True + self._attr_available = True if last_state is None or last_state.state != STATE_ON: self._ping.data = None @@ -221,7 +208,7 @@ class PingDataSubProcess(PingData): self._ip_address, ] - async def async_ping(self): + async def async_ping(self) -> dict[str, Any] | None: """Send ICMP echo request and return details if success.""" pinger = await asyncio.create_subprocess_exec( *self._ping_cmd, @@ -249,7 +236,7 @@ class PingDataSubProcess(PingData): out_error, ) - if pinger.returncode > 1: + if pinger.returncode and pinger.returncode > 1: # returncode of 1 means the host is unreachable _LOGGER.exception( "Error running command: `%s`, return code: %s", @@ -261,9 +248,13 @@ class PingDataSubProcess(PingData): match = PING_MATCHER_BUSYBOX.search( str(out_data).rsplit("\n", maxsplit=1)[-1] ) + if TYPE_CHECKING: + assert match is not None rtt_min, rtt_avg, rtt_max = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1]) + if TYPE_CHECKING: + assert match is not None rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} except asyncio.TimeoutError: @@ -274,7 +265,7 @@ class PingDataSubProcess(PingData): ) if pinger: with suppress(TypeError): - await pinger.kill() + await pinger.kill() # type: ignore[func-returns-value] del pinger return None diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 68111df89ea..f546bd6bacc 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -2,14 +2,13 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta import logging import subprocess from icmplib import async_multiping import voluptuous as vol -from homeassistant import util from homeassistant.components.device_tracker import ( CONF_SCAN_INTERVAL, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, @@ -22,6 +21,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import gather_with_concurrency from homeassistant.util.process import kill_subprocess @@ -44,7 +44,14 @@ PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( class HostSubProcess: """Host object with ping detection.""" - def __init__(self, ip_address, dev_id, hass, config, privileged): + def __init__( + self, + ip_address: str, + dev_id: str, + hass: HomeAssistant, + config: ConfigType, + privileged: bool | None, + ) -> None: """Initialize the Host pinger.""" self.hass = hass self.ip_address = ip_address @@ -52,7 +59,7 @@ class HostSubProcess: self._count = config[CONF_PING_COUNT] self._ping_cmd = ["ping", "-n", "-q", "-c1", "-W1", ip_address] - def ping(self): + def ping(self) -> bool | None: """Send an ICMP echo request and return True if success.""" with subprocess.Popen( self._ping_cmd, @@ -108,7 +115,7 @@ async def async_setup_scanner( for (dev_id, ip) in config[CONF_HOSTS].items() ] - async def async_update(now): + async def async_update(now: datetime) -> None: """Update all the hosts on every interval time.""" results = await gather_with_concurrency( CONCURRENT_PING_LIMIT, @@ -124,7 +131,7 @@ async def async_setup_scanner( else: - async def async_update(now): + async def async_update(now: datetime) -> None: """Update all the hosts on every interval time.""" responses = await async_multiping( list(ip_to_dev_id), @@ -141,14 +148,14 @@ async def async_setup_scanner( ) ) - async def _async_update_interval(now): + async def _async_update_interval(now: datetime) -> None: try: await async_update(now) finally: if not hass.is_stopping: async_track_point_in_utc_time( - hass, _async_update_interval, util.dt.utcnow() + interval + hass, _async_update_interval, now + interval ) - await _async_update_interval(None) + await _async_update_interval(dt_util.now()) return True diff --git a/mypy.ini b/mypy.ini index 68095329374..e4c67bfd909 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2202,6 +2202,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ping.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index eae3d881fc0..3389534483f 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -12,13 +12,13 @@ from tests.common import get_fixture_path @pytest.fixture -def mock_ping(): +def mock_ping() -> None: """Mock icmplib.ping.""" with patch("homeassistant.components.ping.icmp_ping"): yield -async def test_reload(hass: HomeAssistant, mock_ping) -> None: +async def test_reload(hass: HomeAssistant, mock_ping: None) -> None: """Verify we can reload trend sensors.""" await setup.async_setup_component( From 62e518badb529593bd0a98f573d001393493ab3d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 25 Jun 2023 14:30:59 +0000 Subject: [PATCH 524/857] Add new attributes to OpenWeatherMap weather entity (#95173) * Add new attrs to current condition * Add new attrs to forecast --- .../components/openweathermap/const.py | 4 +++ .../components/openweathermap/weather.py | 33 +++++++++++++++++++ .../weather_update_coordinator.py | 11 ++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 4af068f2b84..d53fbc136b2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -35,6 +35,7 @@ ATTR_API_DEW_POINT = "dew_point" ATTR_API_WEATHER = "weather" ATTR_API_TEMPERATURE = "temperature" ATTR_API_FEELS_LIKE_TEMPERATURE = "feels_like_temperature" +ATTR_API_WIND_GUST = "wind_gust" ATTR_API_WIND_SPEED = "wind_speed" ATTR_API_WIND_BEARING = "wind_bearing" ATTR_API_HUMIDITY = "humidity" @@ -50,7 +51,10 @@ ATTR_API_FORECAST = "forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +ATTR_API_FORECAST_CLOUDS = "clouds" ATTR_API_FORECAST_CONDITION = "condition" +ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE = "feels_like_temperature" +ATTR_API_FORECAST_HUMIDITY = "humidity" ATTR_API_FORECAST_PRECIPITATION = "precipitation" ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" ATTR_API_FORECAST_PRESSURE = "pressure" diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index da29031d513..30f98bb39d1 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -4,7 +4,10 @@ from __future__ import annotations from typing import cast from homeassistant.components.weather import ( + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -29,9 +32,15 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTR_API_CLOUDS, ATTR_API_CONDITION, + ATTR_API_DEW_POINT, + ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FORECAST, + ATTR_API_FORECAST_CLOUDS, ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST_HUMIDITY, ATTR_API_FORECAST_PRECIPITATION, ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, ATTR_API_FORECAST_PRESSURE, @@ -44,6 +53,7 @@ from .const import ( ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, ATTRIBUTION, DEFAULT_NAME, @@ -64,6 +74,9 @@ FORECAST_MAP = { ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_API_FORECAST_CLOUDS: ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_API_FORECAST_HUMIDITY: ATTR_FORECAST_HUMIDITY, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: ATTR_FORECAST_NATIVE_APPARENT_TEMP, } @@ -116,6 +129,16 @@ class OpenWeatherMapWeather(WeatherEntity): """Return the current condition.""" return self._weather_coordinator.data[ATTR_API_CONDITION] + @property + def cloud_coverage(self) -> float | None: + """Return the Cloud coverage in %.""" + return self._weather_coordinator.data[ATTR_API_CLOUDS] + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return self._weather_coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] + @property def native_temperature(self) -> float | None: """Return the temperature.""" @@ -131,6 +154,16 @@ class OpenWeatherMapWeather(WeatherEntity): """Return the humidity.""" return self._weather_coordinator.data[ATTR_API_HUMIDITY] + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return self._weather_coordinator.data[ATTR_API_DEW_POINT] + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return self._weather_coordinator.data[ATTR_API_WIND_GUST] + @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 4602615769a..521c1f87ca2 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -21,7 +21,10 @@ from .const import ( ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FORECAST, + ATTR_API_FORECAST_CLOUDS, ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST_HUMIDITY, ATTR_API_FORECAST_PRECIPITATION, ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, ATTR_API_FORECAST_PRESSURE, @@ -41,6 +44,7 @@ from .const import ( ATTR_API_WEATHER, ATTR_API_WEATHER_CODE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, CONDITION_CLASSES, DOMAIN, @@ -130,6 +134,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_API_PRESSURE: current_weather.pressure.get("press"), ATTR_API_HUMIDITY: current_weather.humidity, ATTR_API_WIND_BEARING: current_weather.wind().get("deg"), + ATTR_API_WIND_GUST: current_weather.wind().get("gust"), ATTR_API_WIND_SPEED: current_weather.wind().get("speed"), ATTR_API_CLOUDS: current_weather.clouds, ATTR_API_RAIN: self._get_rain(current_weather.rain), @@ -174,7 +179,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_API_FORECAST_CONDITION: self._get_condition( entry.weather_code, entry.reference_time("unix") ), - ATTR_API_CLOUDS: entry.clouds, + ATTR_API_FORECAST_CLOUDS: entry.clouds, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: entry.temperature("celsius").get( + "feels_like_day" + ), + ATTR_API_FORECAST_HUMIDITY: entry.humidity, } temperature_dict = entry.temperature("celsius") From c1045d6c821c0d02186c0d40e094b6f3b173deb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jun 2023 18:54:02 -0500 Subject: [PATCH 525/857] Fix hass_storage not clearing task (#95209) hass_storage would never allow another load if the no data path was hit discovered while writing tests for https://github.com/home-assistant/core/pull/95202 --- tests/common.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/common.py b/tests/common.py index 512bdeef594..ab5c39f5cd1 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1218,6 +1218,8 @@ def mock_storage( if store._data is None: # No data to load if store.key not in data: + # Make sure the next attempt will still load + store._load_task = None return None mock_data = data.get(store.key) From 1d9821efa2df742ebed15d3f69a513794fad4864 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 25 Jun 2023 18:00:50 -0700 Subject: [PATCH 526/857] Fix spelling mistake in script.py (#95210) Update script.py --- homeassistant/helpers/script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 93d04d5f6df..f39d358dc6d 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -662,7 +662,7 @@ class _ScriptRun: self._hass, self._action, self._variables ) - # Validate response data paraters. This check ignores services that do + # Validate response data parameters. This check ignores services that do # not exist which will raise an appropriate error in the service call below. response_variable = self._action.get(CONF_RESPONSE_VARIABLE) return_response = response_variable is not None From f4756fe1f9beb7391bc7b052e6b5c80bb9c37906 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 26 Jun 2023 03:05:35 +0200 Subject: [PATCH 527/857] Update xknx to 2.11.0: Add support for Light DPT 9 color temperature (#95213) Update xknx to 2.11.0 --- homeassistant/components/knx/const.py | 1 + homeassistant/components/knx/light.py | 25 +++++++++++++++------- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index bacd678bb99..1ec89b47409 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -118,6 +118,7 @@ class ColorTempModes(Enum): """Color temperature modes for config validation.""" ABSOLUTE = "DPT-7.600" + ABSOLUTE_FLOAT = "DPT-9" RELATIVE = "DPT-5.001" diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index f5ef8f61b84..07747f094c3 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -4,7 +4,11 @@ from __future__ import annotations from typing import Any, cast from xknx import XKNX -from xknx.devices.light import Light as XknxLight, XYYColor +from xknx.devices.light import ( + ColorTemperatureType, + Light as XknxLight, + XYYColor, +) from homeassistant import config_entries from homeassistant.components.light import ( @@ -56,16 +60,20 @@ def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: group_address_tunable_white_state = None group_address_color_temp = None group_address_color_temp_state = None - if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.ABSOLUTE: - group_address_color_temp = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) - group_address_color_temp_state = config.get( - LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS - ) - elif config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.RELATIVE: + color_temperature_type = ColorTemperatureType.UINT_2_BYTE + if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.RELATIVE: group_address_tunable_white = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) group_address_tunable_white_state = config.get( LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS ) + else: + # absolute uint or float + group_address_color_temp = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) + group_address_color_temp_state = config.get( + LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS + ) + if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.ABSOLUTE_FLOAT: + color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE return XknxLight( xknx, @@ -140,6 +148,7 @@ def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: group_address_brightness_white_state=individual_color_addresses( LightSchema.CONF_WHITE, LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS ), + color_temperature_type=color_temperature_type, min_kelvin=config[LightSchema.CONF_MIN_KELVIN], max_kelvin=config[LightSchema.CONF_MAX_KELVIN], ) @@ -239,7 +248,7 @@ class KNXLight(KnxEntity, LightEntity): """Return the color temperature in Kelvin.""" if self._device.supports_color_temperature: if kelvin := self._device.current_color_temperature: - return kelvin + return int(kelvin) if self._device.supports_tunable_white: relative_ct = self._device.current_tunable_white if relative_ct is not None: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 6cb6a4ffcf7..79b729017b2 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.10.0", + "xknx==2.11.0", "xknxproject==3.2.0", "knx-frontend==2023.6.23.191712" ] diff --git a/requirements_all.txt b/requirements_all.txt index 2546116f246..b6058900420 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.17.2 # homeassistant.components.knx -xknx==2.10.0 +xknx==2.11.0 # homeassistant.components.knx xknxproject==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d323757226c..d91b73fb075 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1967,7 +1967,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.17.2 # homeassistant.components.knx -xknx==2.10.0 +xknx==2.11.0 # homeassistant.components.knx xknxproject==3.2.0 From 85d6e03dd33bbf8d54ef1bbb2e06d0b83ed52b64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jun 2023 20:18:21 -0500 Subject: [PATCH 528/857] Require newly configured esphome device to allow Home Assistant service calls (#95143) * Require esphome service calls to be enabled For existing devices, calling Home Assistant services continues to be allowed. For newly configured devices, it must now be enabled in the options flow * fix * adjust * coverage * adjust * fix test * Update homeassistant/components/esphome/strings.json Co-authored-by: Paulus Schoutsen * Update homeassistant/components/esphome/strings.json Co-authored-by: Paulus Schoutsen * Update homeassistant/components/esphome/strings.json Co-authored-by: Paulus Schoutsen * Update homeassistant/components/esphome/__init__.py Co-authored-by: Paulus Schoutsen * Update homeassistant/components/esphome/__init__.py Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> * Update homeassistant/components/esphome/__init__.py Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/__init__.py | 41 +++++- .../components/esphome/config_flow.py | 48 ++++++- homeassistant/components/esphome/const.py | 4 + .../components/esphome/entry_data.py | 9 ++ homeassistant/components/esphome/strings.json | 13 ++ tests/components/esphome/conftest.py | 9 +- tests/components/esphome/test_config_flow.py | 123 ++++++++++++++---- 7 files changed, 217 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index fdf0092389b..af2c1d59505 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -49,7 +49,11 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from .bluetooth import async_connect_scanner -from .const import DOMAIN +from .const import ( + CONF_ALLOW_SERVICE_CALLS, + DEFAULT_ALLOW_SERVICE_CALLS, + DOMAIN, +) from .dashboard import async_get_dashboard, async_setup as async_setup_dashboard from .domain_data import DomainData @@ -154,11 +158,16 @@ async def async_setup_entry( # noqa: C901 noise_psk=noise_psk, ) + services_issue = f"service_calls_not_enabled-{entry.unique_id}" + if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): + async_delete_issue(hass, DOMAIN, services_issue) + domain_data = DomainData.get(hass) entry_data = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=domain_data.get_or_create_store(hass, entry), + original_options=dict(entry.options), ) domain_data.set_entry_data(entry, entry_data) @@ -177,6 +186,8 @@ async def async_setup_entry( # noqa: C901 @callback def async_on_service_call(service: HomeassistantServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" + device_info = entry_data.device_info + assert device_info is not None domain, service_name = service.service.split(".", 1) service_data = service.data @@ -194,7 +205,7 @@ async def async_setup_entry( # noqa: C901 return if service.is_event: - # ESPHome uses servicecall packet for both events and service calls + # ESPHome uses service call packet for both events and service calls # Ensure the user can only send events of form 'esphome.xyz' if domain != "esphome": _LOGGER.error( @@ -215,12 +226,34 @@ async def async_setup_entry( # noqa: C901 **service_data, }, ) - else: + elif entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): hass.async_create_task( hass.services.async_call( domain, service_name, service_data, blocking=True ) ) + else: + async_create_issue( + hass, + DOMAIN, + services_issue, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="service_calls_not_allowed", + translation_placeholders={ + "name": device_info.friendly_name or device_info.name, + }, + ) + _LOGGER.error( + "%s: Service call %s.%s: with data %s rejected; " + "If you trust this device and want to allow access for it to make " + "Home Assistant service calls, you can enable this " + "functionality in the options flow", + device_info.friendly_name or device_info.name, + domain, + service_name, + service_data, + ) async def _send_home_assistant_state( entity_id: str, attribute: str | None, state: State | None @@ -449,6 +482,8 @@ async def async_setup_entry( # noqa: C901 await reconnect_logic.start() entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback) + entry.async_on_unload(entry.add_update_listener(entry_data.async_update_listener)) + return True diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index acc94bc7ea0..11deb5bb486 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -20,14 +20,19 @@ import voluptuous as vol from homeassistant.components import dhcp, zeroconf from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac from . import CONF_DEVICE_NAME, CONF_NOISE_PSK -from .const import DOMAIN +from .const import ( + CONF_ALLOW_SERVICE_CALLS, + DEFAULT_ALLOW_SERVICE_CALLS, + DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + DOMAIN, +) from .dashboard import async_get_dashboard, async_set_dashboard_info ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" @@ -237,6 +242,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_NOISE_PSK: self._noise_psk or "", CONF_DEVICE_NAME: self._device_name, } + config_options = { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + } if self._reauth_entry: entry = self._reauth_entry self.hass.config_entries.async_update_entry( @@ -253,6 +261,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=self._name, data=config_data, + options=config_options, ) async def async_step_encryption_key( @@ -388,3 +397,38 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._noise_psk = noise_psk return True + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(OptionsFlow): + """Handle a option flow for esphome.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_ALLOW_SERVICE_CALLS, + default=self.config_entry.options.get( + CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS + ), + ): bool, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 617c817924b..a53bb2db8ed 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,3 +1,7 @@ """ESPHome constants.""" DOMAIN = "esphome" + +CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" +DEFAULT_ALLOW_SERVICE_CALLS = True +DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 41c5687e661..4cde32e6a79 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -109,6 +109,7 @@ class RuntimeEntryData: entity_info_callbacks: dict[ type[EntityInfo], list[Callable[[list[EntityInfo]], None]] ] = field(default_factory=dict) + original_options: dict[str, Any] = field(default_factory=dict) @property def name(self) -> str: @@ -365,3 +366,11 @@ class RuntimeEntryData: return store_data self.store.async_delay_save(_memorized_storage, SAVE_DELAY) + + async def async_update_listener( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle options update.""" + if self.original_options == entry.options: + return + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 81350c2c653..915e55fde32 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -46,6 +46,15 @@ }, "flow_title": "{name}" }, + "options": { + "step": { + "init": { + "data": { + "allow_service_calls": "Allow the device to make Home Assistant service calls." + } + } + } + }, "entity": { "binary_sensor": { "assist_in_progress": { @@ -69,6 +78,10 @@ "api_password_deprecated": { "title": "API Password deprecated on {name}", "description": "The API password for ESPHome is deprecated and the use of an API encryption key is recommended instead.\n\nRemove the API password and add an encryption key to your ESPHome device to resolve this issue." + }, + "service_calls_not_allowed": { + "title": "{name} is not permitted to call Home Assistant services", + "description": "The ESPHome device attempted to make a Home Assistant service call, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to make Home Assistant service calls, you can enable this functionality in the options flow." } } } diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 8a2fe1a3d4a..8bb41a92d80 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -24,6 +24,10 @@ from homeassistant.components.esphome import ( DOMAIN, dashboard, ) +from homeassistant.components.esphome.const import ( + CONF_ALLOW_SERVICE_CALLS, + DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -173,6 +177,9 @@ async def _mock_generic_device_entry( CONF_PORT: 6053, CONF_PASSWORD: "", }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, ) entry.add_to_hass(hass) mock_device = MockESPHomeDevice(entry) @@ -208,7 +215,7 @@ async def _mock_generic_device_entry( return result with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): - await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) await try_connect_done.wait() await hass.async_block_till_done() diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 7d901733d81..affe65949b2 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aioesphomeapi import ( + APIClient, APIConnectionError, DeviceInfo, InvalidAuthAPIError, @@ -20,6 +21,10 @@ from homeassistant.components.esphome import ( DomainData, dashboard, ) +from homeassistant.components.esphome.const import ( + CONF_ALLOW_SERVICE_CALLS, + DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, +) from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant @@ -32,7 +37,7 @@ from tests.common import MockConfigEntry INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM=" -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=False) def mock_setup_entry(): """Mock setting up a config entry.""" with patch("homeassistant.components.esphome.async_setup_entry", return_value=True): @@ -40,7 +45,7 @@ def mock_setup_entry(): async def test_user_connection_works( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( @@ -66,6 +71,9 @@ async def test_user_connection_works( CONF_NOISE_PSK: "", CONF_DEVICE_NAME: "test", } + assert result["options"] == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + } assert result["title"] == "test" assert result["result"].unique_id == "11:22:33:44:55:aa" @@ -79,7 +87,7 @@ async def test_user_connection_works( async def test_user_connection_updates_host( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test setup up the same name updates the host.""" entry = MockConfigEntry( @@ -108,7 +116,7 @@ async def test_user_connection_updates_host( async def test_user_resolve_error( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test user step with IP resolve error.""" @@ -133,7 +141,7 @@ async def test_user_resolve_error( async def test_user_connection_error( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test user step with connection error.""" mock_client.device_info.side_effect = APIConnectionError @@ -154,7 +162,7 @@ async def test_user_connection_error( async def test_user_with_password( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test user step with password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -210,7 +218,7 @@ async def test_user_invalid_password( async def test_login_connection_error( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test user step with connection error on login attempt.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -236,7 +244,7 @@ async def test_login_connection_error( async def test_discovery_initiation( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test discovery importing works.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -268,7 +276,7 @@ async def test_discovery_initiation( async def test_discovery_no_mac( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -287,7 +295,9 @@ async def test_discovery_no_mac( assert flow["reason"] == "mdns_missing_mac" -async def test_discovery_already_configured(hass: HomeAssistant, mock_client) -> None: +async def test_discovery_already_configured( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: """Test discovery aborts if already configured via hostname.""" entry = MockConfigEntry( domain=DOMAIN, @@ -314,7 +324,9 @@ async def test_discovery_already_configured(hass: HomeAssistant, mock_client) -> assert result["reason"] == "already_configured" -async def test_discovery_duplicate_data(hass: HomeAssistant, mock_client) -> None: +async def test_discovery_duplicate_data( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: """Test discovery aborts if same mDNS packet arrives.""" service_info = zeroconf.ZeroconfServiceInfo( host="192.168.43.183", @@ -339,7 +351,9 @@ async def test_discovery_duplicate_data(hass: HomeAssistant, mock_client) -> Non assert result["reason"] == "already_in_progress" -async def test_discovery_updates_unique_id(hass: HomeAssistant, mock_client) -> None: +async def test_discovery_updates_unique_id( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -369,7 +383,7 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant, mock_client) -> async def test_user_requires_psk( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test user step with requiring encryption key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError @@ -390,7 +404,7 @@ async def test_user_requires_psk( async def test_encryption_key_valid_psk( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test encryption key step with valid key.""" @@ -424,7 +438,7 @@ async def test_encryption_key_valid_psk( async def test_encryption_key_invalid_psk( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test encryption key step with invalid key.""" @@ -473,7 +487,7 @@ async def test_reauth_initiation( async def test_reauth_confirm_valid( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test reauth initiation with valid PSK.""" entry = MockConfigEntry( @@ -502,7 +516,11 @@ async def test_reauth_confirm_valid( async def test_reauth_fixed_via_dashboard( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard + hass: HomeAssistant, + mock_client, + mock_zeroconf: None, + mock_dashboard, + mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard.""" @@ -554,6 +572,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( mock_zeroconf: None, mock_dashboard, mock_config_entry, + mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( @@ -596,6 +615,7 @@ async def test_reauth_fixed_via_remove_password( mock_client, mock_config_entry, mock_dashboard, + mock_setup_entry: None, ) -> None: """Test reauth fixed automatically by seeing password removed.""" mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") @@ -615,7 +635,11 @@ async def test_reauth_fixed_via_remove_password( async def test_reauth_fixed_via_dashboard_at_confirm( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard + hass: HomeAssistant, + mock_client, + mock_zeroconf: None, + mock_dashboard, + mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard at confirm step.""" @@ -668,7 +692,7 @@ async def test_reauth_fixed_via_dashboard_at_confirm( async def test_reauth_confirm_invalid( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( @@ -709,7 +733,7 @@ async def test_reauth_confirm_invalid( async def test_reauth_confirm_invalid_with_unique_id( - hass: HomeAssistant, mock_client, mock_zeroconf: None + hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( @@ -750,7 +774,9 @@ async def test_reauth_confirm_invalid_with_unique_id( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -async def test_discovery_dhcp_updates_host(hass: HomeAssistant, mock_client) -> None: +async def test_discovery_dhcp_updates_host( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( domain=DOMAIN, @@ -774,7 +800,9 @@ async def test_discovery_dhcp_updates_host(hass: HomeAssistant, mock_client) -> assert entry.data[CONF_HOST] == "192.168.43.184" -async def test_discovery_dhcp_no_changes(hass: HomeAssistant, mock_client) -> None: +async def test_discovery_dhcp_no_changes( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( domain=DOMAIN, @@ -827,7 +855,11 @@ async def test_discovery_hassio(hass: HomeAssistant, mock_dashboard) -> None: async def test_zeroconf_encryption_key_via_dashboard( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard + hass: HomeAssistant, + mock_client, + mock_zeroconf: None, + mock_dashboard, + mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -889,7 +921,11 @@ async def test_zeroconf_encryption_key_via_dashboard( async def test_zeroconf_no_encryption_key_via_dashboard( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard + hass: HomeAssistant, + mock_client, + mock_zeroconf: None, + mock_dashboard, + mock_setup_entry: None, ) -> None: """Test encryption key not retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -920,3 +956,42 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "encryption_key" + + +@pytest.mark.parametrize("option_value", [True, False]) +async def test_option_flow( + hass: HomeAssistant, + option_value: bool, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test config flow options.""" + entry = await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + } + + with patch( + "homeassistant.components.esphome.async_setup_entry", return_value=True + ) as mock_reload: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ALLOW_SERVICE_CALLS: option_value, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value} + assert len(mock_reload.mock_calls) == int(option_value) From d700415045d9fe1a6dd06c5265c0fc9eea258af3 Mon Sep 17 00:00:00 2001 From: Kevin Cathcart Date: Sun, 25 Jun 2023 22:25:58 -0400 Subject: [PATCH 529/857] Support notification_id in notify.persistent_notification (#74822) * Support notification_id in notify.persistent_notification * Apply suggestions from code review Co-authored-by: Scott Giminiani --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Scott Giminiani --- homeassistant/components/notify/__init__.py | 11 ++++-- homeassistant/components/notify/const.py | 7 ---- homeassistant/components/notify/services.yaml | 8 ++++ .../notify/test_persistent_notification.py | 39 +++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index eae47b55179..e9e61527884 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -19,7 +19,6 @@ from .const import ( # noqa: F401 ATTR_TITLE, DOMAIN, NOTIFY_SERVICE_SCHEMA, - PERSISTENT_NOTIFICATION_SERVICE_SCHEMA, SERVICE_NOTIFY, SERVICE_PERSISTENT_NOTIFICATION, ) @@ -70,13 +69,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: title_tpl.hass = hass title = title_tpl.async_render(parse_result=False) - pn.async_create(hass, message.async_render(parse_result=False), title) + notification_id = None + if data := service.data.get(ATTR_DATA): + notification_id = data.get(pn.ATTR_NOTIFICATION_ID) + + pn.async_create( + hass, message.async_render(parse_result=False), title, notification_id + ) hass.services.async_register( DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, persistent_notification, - schema=PERSISTENT_NOTIFICATION_SERVICE_SCHEMA, + schema=NOTIFY_SERVICE_SCHEMA, ) return True diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index d30702915d9..38dba680635 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -31,10 +31,3 @@ NOTIFY_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_DATA): dict, } ) - -PERSISTENT_NOTIFICATION_SERVICE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_MESSAGE): cv.template, - vol.Optional(ATTR_TITLE): cv.template, - } -) diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 1a0de7344a3..9311acf2ba9 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -51,3 +51,11 @@ persistent_notification: example: "Your Garage Door Friend" selector: text: + data: + name: Data + description: + Extended information for notification. Optional depending on the + platform. + example: platform specific + selector: + object: diff --git a/tests/components/notify/test_persistent_notification.py b/tests/components/notify/test_persistent_notification.py index 6f273ac3d9b..580b2bdb614 100644 --- a/tests/components/notify/test_persistent_notification.py +++ b/tests/components/notify/test_persistent_notification.py @@ -25,3 +25,42 @@ async def test_async_send_message(hass: HomeAssistant) -> None: assert notification["message"] == "Hello" assert notification["title"] == "Test notification" + + +async def test_async_supports_notification_id(hass: HomeAssistant) -> None: + """Test that notify.persistent_notification supports notification_id.""" + await async_setup_component(hass, pn.DOMAIN, {"core": {}}) + await async_setup_component(hass, notify.DOMAIN, {}) + await hass.async_block_till_done() + + message = { + "message": "Hello", + "title": "Test notification", + "data": {"notification_id": "my_id"}, + } + await hass.services.async_call( + notify.DOMAIN, notify.SERVICE_PERSISTENT_NOTIFICATION, message + ) + await hass.async_block_till_done() + + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + + # Send second message with same ID + + message = { + "message": "Goodbye", + "title": "Notification was updated", + "data": {"notification_id": "my_id"}, + } + await hass.services.async_call( + notify.DOMAIN, notify.SERVICE_PERSISTENT_NOTIFICATION, message + ) + await hass.async_block_till_done() + + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + + notification = notifications[list(notifications)[0]] + assert notification["message"] == "Goodbye" + assert notification["title"] == "Notification was updated" From 3b7095c63b981b8cc1a2a837862d756dbfe6eb8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jun 2023 21:31:31 -0500 Subject: [PATCH 530/857] Fix esphome not removing entities when static info changes (#95202) Co-authored-by: Paulus Schoutsen --- homeassistant/components/esphome/__init__.py | 1 + .../components/esphome/alarm_control_panel.py | 1 - .../components/esphome/binary_sensor.py | 1 - homeassistant/components/esphome/button.py | 1 - homeassistant/components/esphome/camera.py | 1 - homeassistant/components/esphome/climate.py | 1 - homeassistant/components/esphome/cover.py | 1 - .../components/esphome/diagnostics.py | 3 +- .../components/esphome/domain_data.py | 11 +- homeassistant/components/esphome/entity.py | 53 +++---- .../components/esphome/entry_data.py | 132 +++++++++++++----- homeassistant/components/esphome/fan.py | 1 - homeassistant/components/esphome/light.py | 1 - homeassistant/components/esphome/lock.py | 1 - .../components/esphome/media_player.py | 1 - homeassistant/components/esphome/number.py | 1 - homeassistant/components/esphome/select.py | 1 - homeassistant/components/esphome/sensor.py | 2 - homeassistant/components/esphome/switch.py | 1 - tests/components/esphome/conftest.py | 30 ++-- tests/components/esphome/test_entity.py | 103 ++++++++++++++ 21 files changed, 244 insertions(+), 104 deletions(-) create mode 100644 tests/components/esphome/test_entity.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index af2c1d59505..76f218d3668 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -675,6 +675,7 @@ async def _cleanup_instance( data.disconnect_callbacks = [] for cleanup_callback in data.cleanup_callbacks: cleanup_callback() + await data.async_cleanup() await data.client.disconnect() return data diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 6fadd7d4408..f69560945c3 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -73,7 +73,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="alarm_control_panel", info_type=AlarmControlPanelInfo, entity_type=EsphomeAlarmControlPanel, state_type=AlarmControlPanelEntityState, diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index a755bcf10ef..65a237de4f7 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -29,7 +29,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="binary_sensor", info_type=BinarySensorInfo, entity_type=EsphomeBinarySensor, state_type=BinarySensorState, diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 71bb7017c55..7087cb034ae 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -23,7 +23,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="button", info_type=ButtonInfo, entity_type=EsphomeButton, state_type=EntityState, diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 17f73f7c770..94a9b03b90c 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -27,7 +27,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="camera", info_type=CameraInfo, entity_type=EsphomeCamera, state_type=CameraState, diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 5c252e888d9..2c1d005f9be 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -72,7 +72,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="climate", info_type=ClimateInfo, entity_type=EsphomeClimateEntity, state_type=ClimateState, diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 347ff98e689..45ef8a132f9 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -32,7 +32,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="cover", info_type=CoverInfo, entity_type=EsphomeCover, state_type=CoverState, diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 8de1501bc43..292d1921abf 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -1,7 +1,7 @@ """Diagnostics support for ESPHome.""" from __future__ import annotations -from typing import Any, cast +from typing import Any from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.diagnostics import async_redact_data @@ -28,7 +28,6 @@ async def async_get_config_entry_diagnostics( entry_data = DomainData.get(hass).get_entry_data(config_entry) if (storage_data := await entry_data.store.async_load()) is not None: - storage_data = cast("dict[str, Any]", storage_data) diag["storage_data"] = storage_data if config_entry.unique_id and ( diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index 1379b274122..2fc32129d1f 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -12,10 +12,9 @@ from typing_extensions import Self from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.storage import Store from .const import DOMAIN -from .entry_data import RuntimeEntryData +from .entry_data import ESPHomeStorage, RuntimeEntryData STORAGE_VERSION = 1 MAX_CACHED_SERVICES = 128 @@ -26,7 +25,7 @@ class DomainData: """Define a class that stores global esphome data in hass.data[DOMAIN].""" _entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict) - _stores: dict[str, Store] = field(default_factory=dict) + _stores: dict[str, ESPHomeStorage] = field(default_factory=dict) _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( default_factory=lambda: LRU(MAX_CACHED_SERVICES) ) @@ -83,11 +82,13 @@ class DomainData: """Check whether the given entry is loaded.""" return entry.entry_id in self._entry_datas - def get_or_create_store(self, hass: HomeAssistant, entry: ConfigEntry) -> Store: + def get_or_create_store( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> ESPHomeStorage: """Get or create a Store instance for the given config entry.""" return self._stores.setdefault( entry.entry_id, - Store( + ESPHomeStorage( hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder ), ) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 18bf15ce4ee..dbb16fe481d 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -27,7 +27,6 @@ import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, - async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -49,7 +48,6 @@ async def platform_async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, *, - component_key: str, info_type: type[_InfoT], entity_type: type[_EntityT], state_type: type[_StateT], @@ -60,42 +58,35 @@ async def platform_async_setup_entry( info and state updates. """ entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) - entry_data.info[component_key] = {} - entry_data.old_info[component_key] = {} + entry_data.info[info_type] = {} entry_data.state.setdefault(state_type, {}) @callback def async_list_entities(infos: list[EntityInfo]) -> None: """Update entities of this platform when entities are listed.""" - old_infos = entry_data.info[component_key] + current_infos = entry_data.info[info_type] new_infos: dict[int, EntityInfo] = {} add_entities: list[_EntityT] = [] + for info in infos: - if info.key in old_infos: - # Update existing entity - old_infos.pop(info.key) - else: + if not current_infos.pop(info.key, None): # Create new entity - entity = entity_type(entry_data, component_key, info, state_type) + entity = entity_type(entry_data, info, state_type) add_entities.append(entity) new_infos[info.key] = info - # Remove old entities - for info in old_infos.values(): - entry_data.async_remove_entity(hass, component_key, info.key) - - # First copy the now-old info into the backup object - entry_data.old_info[component_key] = entry_data.info[component_key] - # Then update the actual info - entry_data.info[component_key] = new_infos - - for key, new_info in new_infos.items(): - async_dispatcher_send( - hass, - entry_data.signal_component_key_static_info_updated(component_key, key), - new_info, + # Anything still in current_infos is now gone + if current_infos: + hass.async_create_task( + entry_data.async_remove_entities(current_infos.values()) ) + # Then update the actual info + entry_data.info[info_type] = new_infos + + if new_infos: + entry_data.async_update_entity_infos(new_infos.values()) + if add_entities: # Add entities to Home Assistant async_add_entities(add_entities) @@ -154,14 +145,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): def __init__( self, entry_data: RuntimeEntryData, - component_key: str, entity_info: EntityInfo, state_type: type[_StateT], ) -> None: """Initialize.""" self._entry_data = entry_data self._on_entry_data_changed() - self._component_key = component_key self._key = entity_info.key self._state_type = state_type self._on_static_info_update(entity_info) @@ -178,13 +167,11 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Register callbacks.""" entry_data = self._entry_data hass = self.hass - component_key = self._component_key key = self._key self.async_on_remove( - async_dispatcher_connect( - hass, - f"esphome_{self._entry_id}_remove_{component_key}_{key}", + entry_data.async_register_key_static_info_remove_callback( + self._static_info, functools.partial(self.async_remove, force_remove=True), ) ) @@ -201,10 +188,8 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) ) self.async_on_remove( - async_dispatcher_connect( - hass, - entry_data.signal_component_key_static_info_updated(component_key, key), - self._on_static_info_update, + entry_data.async_register_key_static_info_updated_callback( + self._static_info, self._on_static_info_update ) ) self._update_state_from_entry_data() diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 4cde32e6a79..e0d989c4b8b 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from aioesphomeapi import ( COMPONENT_TYPE_TO_INFO, @@ -41,6 +41,8 @@ from homeassistant.helpers.storage import Store from .dashboard import async_get_dashboard +INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()} + _SENTINEL = object() SAVE_DELAY = 120 _LOGGER = logging.getLogger(__name__) @@ -65,26 +67,31 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { } +class StoreData(TypedDict, total=False): + """ESPHome storage data.""" + + device_info: dict[str, Any] + services: list[dict[str, Any]] + api_version: dict[str, Any] + + +class ESPHomeStorage(Store[StoreData]): + """ESPHome Storage.""" + + @dataclass class RuntimeEntryData: """Store runtime data for esphome config entries.""" entry_id: str client: APIClient - store: Store + store: ESPHomeStorage state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) # When the disconnect callback is called, we mark all states # as stale so we will always dispatch a state update when the # device reconnects. This is the same format as state_subscriptions. stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set) - info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) - - # A second list of EntityInfo objects - # This is necessary for when an entity is being removed. HA requires - # some static info to be accessible during removal (unique_id, maybe others) - # If an entity can't find anything in the info array, it will look for info here. - old_info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) - + info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict) services: dict[int, UserService] = field(default_factory=dict) available: bool = False device_info: DeviceInfo | None = None @@ -96,7 +103,8 @@ class RuntimeEntryData: ] = field(default_factory=dict) loaded_platforms: set[Platform] = field(default_factory=set) platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - _storage_contents: dict[str, Any] | None = None + _storage_contents: StoreData | None = None + _pending_storage: Callable[[], StoreData] | None = None ble_connections_free: int = 0 ble_connections_limit: int = 0 _ble_connection_free_futures: list[asyncio.Future[int]] = field( @@ -109,6 +117,12 @@ class RuntimeEntryData: entity_info_callbacks: dict[ type[EntityInfo], list[Callable[[list[EntityInfo]], None]] ] = field(default_factory=dict) + entity_info_key_remove_callbacks: dict[ + tuple[type[EntityInfo], int], list[Callable[[], Coroutine[Any, Any, None]]] + ] = field(default_factory=dict) + entity_info_key_updated_callbacks: dict[ + tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]] + ] = field(default_factory=dict) original_options: dict[str, Any] = field(default_factory=dict) @property @@ -133,12 +147,6 @@ class RuntimeEntryData: """Return the signal to listen to for updates on static info.""" return f"esphome_{self.entry_id}_on_list" - def signal_component_key_static_info_updated( - self, component_key: str, key: int - ) -> str: - """Return the signal to listen to for updates on static info for a specific component_key and key.""" - return f"esphome_{self.entry_id}_static_info_updated_{component_key}_{key}" - @callback def async_register_static_info_callback( self, @@ -154,6 +162,38 @@ class RuntimeEntryData: return _unsub + @callback + def async_register_key_static_info_remove_callback( + self, + static_info: EntityInfo, + callback_: Callable[[], Coroutine[Any, Any, None]], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when static info is removed for a specific key.""" + callback_key = (type(static_info), static_info.key) + callbacks = self.entity_info_key_remove_callbacks.setdefault(callback_key, []) + callbacks.append(callback_) + + def _unsub() -> None: + callbacks.remove(callback_) + + return _unsub + + @callback + def async_register_key_static_info_updated_callback( + self, + static_info: EntityInfo, + callback_: Callable[[EntityInfo], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when static info is updated for a specific key.""" + callback_key = (type(static_info), static_info.key) + callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) + callbacks.append(callback_) + + def _unsub() -> None: + callbacks.remove(callback_) + + return _unsub + @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: """Update the BLE connection limits.""" @@ -203,13 +243,25 @@ class RuntimeEntryData: self.assist_pipeline_update_callbacks.append(update_callback) return _unsubscribe - @callback - def async_remove_entity( - self, hass: HomeAssistant, component_key: str, key: int - ) -> None: + async def async_remove_entities(self, static_infos: Iterable[EntityInfo]) -> None: """Schedule the removal of an entity.""" - signal = f"esphome_{self.entry_id}_remove_{component_key}_{key}" - async_dispatcher_send(hass, signal) + callbacks: list[Coroutine[Any, Any, None]] = [] + for static_info in static_infos: + callback_key = (type(static_info), static_info.key) + if key_callbacks := self.entity_info_key_remove_callbacks.get(callback_key): + callbacks.extend([callback_() for callback_ in key_callbacks]) + if callbacks: + await asyncio.gather(*callbacks) + + @callback + def async_update_entity_infos(self, static_infos: Iterable[EntityInfo]) -> None: + """Call static info updated callbacks.""" + for static_info in static_infos: + callback_key = (type(static_info), static_info.key) + for callback_ in self.entity_info_key_updated_callbacks.get( + callback_key, [] + ): + callback_(static_info) async def _ensure_platforms_loaded( self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[Platform] @@ -288,7 +340,7 @@ class RuntimeEntryData: and subscription_key not in stale_state and not ( type(state) is SensorState # pylint: disable=unidiomatic-typecheck - and (platform_info := self.info.get(Platform.SENSOR)) + and (platform_info := self.info.get(SensorInfo)) and (entity_info := platform_info.get(state.key)) and (cast(SensorInfo, entity_info)).force_update ) @@ -326,47 +378,57 @@ class RuntimeEntryData: """Load the retained data from store and return de-serialized data.""" if (restored := await self.store.async_load()) is None: return [], [] - restored = cast("dict[str, Any]", restored) self._storage_contents = restored.copy() self.device_info = DeviceInfo.from_dict(restored.pop("device_info")) self.api_version = APIVersion.from_dict(restored.pop("api_version", {})) - infos = [] + infos: list[EntityInfo] = [] for comp_type, restored_infos in restored.items(): + if TYPE_CHECKING: + restored_infos = cast(list[dict[str, Any]], restored_infos) if comp_type not in COMPONENT_TYPE_TO_INFO: continue for info in restored_infos: cls = COMPONENT_TYPE_TO_INFO[comp_type] infos.append(cls.from_dict(info)) - services = [] - for service in restored.get("services", []): - services.append(UserService.from_dict(service)) + services = [ + UserService.from_dict(service) for service in restored.pop("services", []) + ] return infos, services async def async_save_to_store(self) -> None: """Generate dynamic data to store and save it to the filesystem.""" if self.device_info is None: raise ValueError("device_info is not set yet") - store_data: dict[str, Any] = { + store_data: StoreData = { "device_info": self.device_info.to_dict(), "services": [], "api_version": self.api_version.to_dict(), } - - for comp_type, infos in self.info.items(): - store_data[comp_type] = [info.to_dict() for info in infos.values()] + for info_type, infos in self.info.items(): + comp_type = INFO_TO_COMPONENT_TYPE[info_type] + store_data[comp_type] = [info.to_dict() for info in infos.values()] # type: ignore[literal-required] for service in self.services.values(): store_data["services"].append(service.to_dict()) if store_data == self._storage_contents: return - def _memorized_storage() -> dict[str, Any]: + def _memorized_storage() -> StoreData: + self._pending_storage = None self._storage_contents = store_data return store_data + self._pending_storage = _memorized_storage self.store.async_delay_save(_memorized_storage, SAVE_DELAY) + async def async_cleanup(self) -> None: + """Cleanup the entry data when disconnected or unloading.""" + if self._pending_storage: + # Ensure we save the data if we are unloading before the + # save delay has passed. + await self.store.async_save(self._pending_storage()) + async def async_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 388413f161f..c6be200e2b2 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -40,7 +40,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="fan", info_type=FanInfo, entity_type=EsphomeFan, state_type=FanState, diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index f4232e320b0..aa67a8124fc 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -48,7 +48,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="light", info_type=LightInfo, entity_type=EsphomeLight, state_type=LightState, diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 0cfc25e3882..00b94cd15ff 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -26,7 +26,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="lock", info_type=LockInfo, entity_type=EsphomeLock, state_type=LockEntityState, diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 9933f523c26..9d008300966 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -43,7 +43,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="media_player", info_type=MediaPlayerInfo, entity_type=EsphomeMediaPlayer, state_type=MediaPlayerEntityState, diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index ead3d5c4307..e876fe412f6 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -34,7 +34,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="number", info_type=NumberInfo, entity_type=EsphomeNumber, state_type=NumberState, diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 2de6ddd7111..9849f7cded8 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -29,7 +29,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="select", info_type=SelectInfo, entity_type=EsphomeSelect, state_type=SelectState, diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index ac2fb9629a8..3185a5eb536 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -41,7 +41,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="sensor", info_type=SensorInfo, entity_type=EsphomeSensor, state_type=SensorState, @@ -50,7 +49,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="text_sensor", info_type=TextSensorInfo, entity_type=EsphomeTextSensor, state_type=TextSensorState, diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 4ecee203fa0..99894b8501e 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -26,7 +26,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="switch", info_type=SwitchInfo, entity_type=EsphomeSwitch, state_type=SwitchState, diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 8bb41a92d80..d78af769a17 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -169,19 +169,22 @@ async def _mock_generic_device_entry( mock_device_info: dict[str, Any], mock_list_entities_services: tuple[list[EntityInfo], list[UserService]], states: list[EntityState], + entry: MockConfigEntry | None = None, ) -> MockESPHomeDevice: - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "test.local", - CONF_PORT: 6053, - CONF_PASSWORD: "", - }, - options={ - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS - }, - ) - entry.add_to_hass(hass) + if not entry: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, + ) + entry.add_to_hass(hass) + mock_device = MockESPHomeDevice(entry) device_info = DeviceInfo( @@ -290,9 +293,10 @@ async def mock_esphome_device( entity_info: list[EntityInfo], user_service: list[UserService], states: list[EntityState], + entry: MockConfigEntry | None = None, ) -> MockESPHomeDevice: return await _mock_generic_device_entry( - hass, mock_client, {}, (entity_info, user_service), states + hass, mock_client, {}, (entity_info, user_service), states, entry ) return _mock_device diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py new file mode 100644 index 00000000000..39bfec852e7 --- /dev/null +++ b/tests/components/esphome/test_entity.py @@ -0,0 +1,103 @@ +"""Test ESPHome binary sensors.""" +from collections.abc import Awaitable, Callable +from typing import Any + +from aioesphomeapi import ( + APIClient, + BinarySensorInfo, + BinarySensorState, + EntityInfo, + EntityState, + UserService, +) + +from homeassistant.const import ATTR_RESTORED, STATE_ON +from homeassistant.core import HomeAssistant + +from .conftest import MockESPHomeDevice + + +async def test_entities_removed( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic binary_sensor where has_state is false.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + BinarySensorInfo( + object_id="mybinary_sensor_to_be_removed", + key=2, + name="my binary_sensor to be removed", + unique_id="mybinary_sensor_to_be_removed", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=True, missing_state=False), + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + entry = mock_device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") + assert state is not None + assert state.state == STATE_ON + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 + + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.attributes[ATTR_RESTORED] is True + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") + assert state is not None + assert state.attributes[ATTR_RESTORED] is True + + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + entry=entry, + ) + assert mock_device.entry.entry_id == entry_id + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") + assert state is None + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 From 408c613731c6f5174e9d8eea80af69492175edd3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 26 Jun 2023 04:32:06 +0200 Subject: [PATCH 531/857] Update mypy to 1.4.1 (#95220) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 203fdb64e85..e29a663e9b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==2.15.4 coverage==7.2.4 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.4.0 +mypy==1.4.1 pre-commit==3.1.0 pydantic==1.10.9 pylint==2.17.4 From a338e7e2426f70467462cce2be60e86b9d1cec43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 09:59:01 +0200 Subject: [PATCH 532/857] Use entity registry id in toggle_entity device automations (#94995) * Use entity registry id in toggle_entity device automations * Update tests --------- Co-authored-by: J. Nick Koston --- .../components/device_automation/__init__.py | 16 ++ .../components/device_automation/entity.py | 4 +- .../components/device_automation/helpers.py | 12 ++ .../device_automation/toggle_entity.py | 8 +- .../components/humidifier/device_action.py | 14 +- .../components/light/device_action.py | 23 ++- .../components/remote/device_action.py | 14 +- .../components/switch/device_action.py | 14 +- .../components/device_automation/test_init.py | 22 +-- tests/components/fan/test_device_action.py | 52 +++++- tests/components/fan/test_device_trigger.py | 133 ++++++++++++--- .../humidifier/test_device_action.py | 113 +++++++++++-- .../humidifier/test_device_condition.py | 37 ++-- .../humidifier/test_device_trigger.py | 10 +- tests/components/light/test_device_action.py | 147 +++++++--------- .../components/light/test_device_condition.py | 123 +++++++++++--- tests/components/light/test_device_trigger.py | 158 +++++++++++++----- .../media_player/test_device_trigger.py | 34 +--- tests/components/remote/test_device_action.py | 96 +++++++---- .../remote/test_device_condition.py | 129 +++++++++++--- .../components/remote/test_device_trigger.py | 155 +++++++++++++---- tests/components/switch/test_device_action.py | 96 +++++++---- .../switch/test_device_condition.py | 128 +++++++++++--- .../components/switch/test_device_trigger.py | 155 +++++++++++++---- .../components/update/test_device_trigger.py | 123 ++++++++++++-- tests/components/wemo/test_device_trigger.py | 6 +- tests/components/zha/test_device_action.py | 10 +- 27 files changed, 1352 insertions(+), 480 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 80c635dc994..af2fd61081c 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, CONF_DOMAIN, + CONF_ENTITY_ID, CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, callback @@ -340,6 +341,21 @@ def async_get_entity_registry_entry_or_raise( return entry +@callback +def async_validate_entity_schema( + hass: HomeAssistant, config: ConfigType, schema: vol.Schema +) -> ConfigType: + """Validate schema and resolve entity registry entry id to entity_id.""" + config = schema(config) + + registry = er.async_get(hass) + config[CONF_ENTITY_ID] = er.async_resolve_entity_id( + registry, config[CONF_ENTITY_ID] + ) + + return config + + def handle_device_errors( func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]] ) -> Callable[ diff --git a/homeassistant/components/device_automation/entity.py b/homeassistant/components/device_automation/entity.py index f38daf2dae6..87ff5a2cb52 100644 --- a/homeassistant/components/device_automation/entity.py +++ b/homeassistant/components/device_automation/entity.py @@ -23,7 +23,7 @@ ENTITY_TRIGGERS = [ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In([CONF_CHANGED_STATES]), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -73,7 +73,7 @@ async def _async_get_automations( { **template, "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": domain, } for template in automation_templates diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 5f844c36aa5..8e000733536 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -25,6 +25,8 @@ STATIC_VALIDATOR = { DeviceAutomationType.TRIGGER: "TRIGGER_SCHEMA", } +TOGGLE_ENTITY_DOMAINS = {"fan", "humidifier", "light", "remote", "switch"} + async def async_validate_device_automation_config( hass: HomeAssistant, @@ -43,6 +45,16 @@ async def async_validate_device_automation_config( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) + # Bypass checks for toggle entity domains + if ( + automation_type == DeviceAutomationType.ACTION + and validated_config[CONF_DOMAIN] in TOGGLE_ENTITY_DOMAINS + ): + return cast( + ConfigType, + await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config), + ) + # Only call the dynamic validator if the referenced device exists and the relevant # config entry is loaded registry = dr.async_get(hass) diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index e5061cb691e..189fc750e50 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -78,14 +78,14 @@ DEVICE_ACTION_TYPES = [CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON] ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(DEVICE_ACTION_TYPES), } ) CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In([CONF_IS_OFF, CONF_IS_ON]), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -93,7 +93,7 @@ CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( _TOGGLE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In([CONF_TURNED_OFF, CONF_TURNED_ON]), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -196,7 +196,7 @@ async def _async_get_automations( { **template, "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": domain, } for template in automation_templates diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 1c027ba22e6..f0f2d415a6f 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -3,7 +3,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -41,7 +44,14 @@ SET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ONOFF_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) -ACTION_SCHEMA = vol.Any(SET_HUMIDITY_SCHEMA, SET_MODE_SCHEMA, ONOFF_SCHEMA) +_ACTION_SCHEMA = vol.Any(SET_HUMIDITY_SCHEMA, SET_MODE_SCHEMA, ONOFF_SCHEMA) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_get_actions( diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 2b4c32cb3b1..2e3338e1253 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -3,7 +3,11 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -37,9 +41,9 @@ TYPE_BRIGHTNESS_INCREASE = "brightness_increase" TYPE_BRIGHTNESS_DECREASE = "brightness_decrease" TYPE_FLASH = "flash" -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_DOMAIN): DOMAIN, vol.Required(CONF_TYPE): vol.In( toggle_entity.DEVICE_ACTION_TYPES @@ -51,6 +55,13 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_call_action_from_config( hass: HomeAssistant, config: ConfigType, @@ -126,13 +137,15 @@ async def async_get_action_capabilities( if config[CONF_TYPE] != toggle_entity.CONF_TURN_ON: return {} + entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) + try: - supported_color_modes = get_supported_color_modes(hass, config[ATTR_ENTITY_ID]) + supported_color_modes = get_supported_color_modes(hass, entry.entity_id) except HomeAssistantError: supported_color_modes = None try: - supported_features = get_supported_features(hass, config[ATTR_ENTITY_ID]) + supported_features = get_supported_features(hass, entry.entity_id) except HomeAssistantError: supported_features = 0 diff --git a/homeassistant/components/remote/device_action.py b/homeassistant/components/remote/device_action.py index 09c540b5e01..936c7aca37a 100644 --- a/homeassistant/components/remote/device_action.py +++ b/homeassistant/components/remote/device_action.py @@ -3,7 +3,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import CONF_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -12,7 +15,14 @@ from . import DOMAIN # mypy: disallow-any-generics -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_call_action_from_config( diff --git a/homeassistant/components/switch/device_action.py b/homeassistant/components/switch/device_action.py index 1aed2fa2467..ce9f0a36117 100644 --- a/homeassistant/components/switch/device_action.py +++ b/homeassistant/components/switch/device_action.py @@ -3,7 +3,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import CONF_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -12,7 +15,14 @@ from . import DOMAIN # mypy: disallow-any-generics -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_call_action_from_config( diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index d48fb520eba..d0f013299b1 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -114,7 +114,7 @@ async def test_websocket_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "fake_integration", "test", "5678", device_id=device_entry.id ) expected_actions = [ @@ -122,21 +122,21 @@ async def test_websocket_get_actions( "domain": "fake_integration", "type": "turn_off", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, { "domain": "fake_integration", "type": "turn_on", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, { "domain": "fake_integration", "type": "toggle", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, ] @@ -169,7 +169,7 @@ async def test_websocket_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "fake_integration", "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -178,7 +178,7 @@ async def test_websocket_get_conditions( "domain": "fake_integration", "type": "is_off", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, { @@ -186,7 +186,7 @@ async def test_websocket_get_conditions( "domain": "fake_integration", "type": "is_on", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, ] @@ -223,7 +223,7 @@ async def test_websocket_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "fake_integration", "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -232,7 +232,7 @@ async def test_websocket_get_triggers( "domain": "fake_integration", "type": "changed_states", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, { @@ -240,7 +240,7 @@ async def test_websocket_get_triggers( "domain": "fake_integration", "type": "turned_off", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, { @@ -248,7 +248,7 @@ async def test_websocket_get_triggers( "domain": "fake_integration", "type": "turned_on", "device_id": device_entry.id, - "entity_id": "fake_integration.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, ] diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 1fa3b3a1370..5404c80340e 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -35,7 +35,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_actions = [] @@ -44,7 +44,7 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in ["turn_on", "turn_off", "toggle"] @@ -78,7 +78,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -92,7 +92,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["turn_on", "turn_off", "toggle"] @@ -103,8 +103,10 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant) -> None: +async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -118,7 +120,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "turn_off", }, }, @@ -130,7 +132,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "turn_on", }, }, @@ -142,7 +144,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "toggle", }, }, @@ -171,3 +173,37 @@ async def test_action(hass: HomeAssistant) -> None: assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 assert len(toggle_calls) == 1 + + +async def test_action_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_turn_off", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.entity_id, + "type": "turn_off", + }, + }, + ] + }, + ) + + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + + hass.bus.async_fire("test_event_turn_off") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index aa26dd8f07d..f1de07a9e97 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -46,7 +46,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -55,7 +55,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["turned_off", "turned_on", "changed_states"] @@ -89,7 +89,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["turned_off", "turned_on", "changed_states"] @@ -144,9 +144,44 @@ async def test_get_trigger_capabilities( assert capabilities == expected_capabilities -async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a switch trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + +async def test_if_fires_on_state_change( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off triggers firing.""" - hass.states.async_set("fan.entity", STATE_OFF) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_OFF) assert await async_setup_component( hass, @@ -158,7 +193,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "turned_on", }, "action": { @@ -180,7 +215,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "turned_off", }, "action": { @@ -202,7 +237,7 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "changed_states", }, "action": { @@ -224,28 +259,31 @@ async def test_if_fires_on_state_change(hass: HomeAssistant, calls) -> None: ) # Fake that the entity is turning on. - hass.states.async_set("fan.entity", STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - "turn_on - device - fan.entity - off - on - None", - "turn_on_or_off - device - fan.entity - off - on - None", + f"turn_on - device - {entry.entity_id} - off - on - None", + f"turn_on_or_off - device - {entry.entity_id} - off - on - None", } # Fake that the entity is turning off. - hass.states.async_set("fan.entity", STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - "turn_off - device - fan.entity - on - off - None", - "turn_on_or_off - device - fan.entity - on - off - None", + f"turn_off - device - {entry.entity_id} - on - off - None", + f"turn_on_or_off - device - {entry.entity_id} - on - off - None", } -async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> None: - """Test for triggers firing with delay.""" - entity_id = "fan.entity" - hass.states.async_set(entity_id, STATE_ON) +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_OFF) assert await async_setup_component( hass, @@ -257,7 +295,56 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entity_id, + "entity_id": entry.id, + "type": "turned_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "turn_on " + "- {{ trigger.platform }} " + "- {{ trigger.entity_id }} " + "- {{ trigger.from_state.state }} " + "- {{ trigger.to_state.state }} " + "- {{ trigger.for }}" + ) + }, + }, + }, + ] + }, + ) + + # Fake that the entity is turning on. + hass.states.async_set(entry.entity_id, STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"turn_on - device - {entry.entity_id} - off - on - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, }, @@ -281,10 +368,9 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> }, ) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -292,5 +378,6 @@ async def test_if_fires_on_state_change_with_for(hass: HomeAssistant, calls) -> assert len(calls) == 1 await hass.async_block_till_done() assert ( - calls[0].data["some"] == f"turn_off device - {entity_id} - on - off - 0:00:05" + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - on - off - 0:00:05" ) diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 9c6de7adffe..4e20d16ea1d 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -53,7 +53,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -65,7 +65,8 @@ async def test_get_actions( f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} ) expected_actions = [] - basic_action_types = ["turn_on", "turn_off", "toggle", "set_humidity"] + basic_action_types = ["set_humidity"] + toggle_action_types = ["turn_on", "turn_off", "toggle"] expected_actions += [ { "domain": DOMAIN, @@ -76,6 +77,16 @@ async def test_get_actions( } for action in basic_action_types ] + expected_actions += [ + { + "domain": DOMAIN, + "type": action, + "device_id": device_entry.id, + "entity_id": entity_entry.id, + "metadata": {"secondary": False}, + } + for action in toggle_action_types + ] expected_actions += [ { "domain": DOMAIN, @@ -115,7 +126,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -124,6 +135,8 @@ async def test_get_actions_hidden_auxiliary( hidden_by=hidden_by, supported_features=0, ) + basic_action_types = ["set_humidity"] + toggle_action_types = ["turn_on", "turn_off", "toggle"] expected_actions = [] expected_actions += [ { @@ -133,7 +146,17 @@ async def test_get_actions_hidden_auxiliary( "entity_id": f"{DOMAIN}.test_5678", "metadata": {"secondary": True}, } - for action in ["turn_on", "turn_off", "toggle", "set_humidity"] + for action in basic_action_types + ] + expected_actions += [ + { + "domain": DOMAIN, + "type": action, + "device_id": device_entry.id, + "entity_id": entity_entry.id, + "metadata": {"secondary": True}, + } + for action in toggle_action_types ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -141,10 +164,12 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant) -> None: +async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test for actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + hass.states.async_set( - "humidifier.entity", + entry.entity_id, STATE_ON, {const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY]}, ) @@ -162,7 +187,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "turn_off", }, }, @@ -174,7 +199,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "turn_on", }, }, @@ -183,7 +208,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "toggle", }, }, @@ -195,7 +220,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "humidifier.entity", + "entity_id": entry.entity_id, "type": "set_humidity", "humidity": 35, }, @@ -208,7 +233,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "humidifier.entity", + "entity_id": entry.entity_id, "type": "set_mode", "mode": const.MODE_AWAY, }, @@ -269,6 +294,67 @@ async def test_action(hass: HomeAssistant) -> None: assert len(turn_off_calls) == 1 assert len(toggle_calls) == 1 + assert set_humidity_calls[0].domain == DOMAIN + assert set_humidity_calls[0].service == "set_humidity" + assert set_humidity_calls[0].data == {"entity_id": entry.entity_id, "humidity": 35} + assert set_mode_calls[0].domain == DOMAIN + assert set_mode_calls[0].service == "set_mode" + assert set_mode_calls[0].data == {"entity_id": entry.entity_id, "mode": "away"} + assert turn_on_calls[0].domain == DOMAIN + assert turn_on_calls[0].service == "turn_on" + assert turn_on_calls[0].data == {"entity_id": entry.entity_id} + assert turn_off_calls[0].domain == DOMAIN + assert turn_off_calls[0].service == "turn_off" + assert turn_off_calls[0].data == {"entity_id": entry.entity_id} + assert toggle_calls[0].domain == DOMAIN + assert toggle_calls[0].service == "toggle" + assert toggle_calls[0].data == {"entity_id": entry.entity_id} + + +async def test_action_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set( + entry.entity_id, + STATE_ON, + {const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY]}, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_mode", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.entity_id, + "type": "set_mode", + "mode": const.MODE_AWAY, + }, + }, + ] + }, + ) + + set_mode_calls = async_mock_service(hass, "humidifier", "set_mode") + + hass.bus.async_fire("test_event_set_mode") + await hass.async_block_till_done() + assert len(set_mode_calls) == 1 + + assert set_mode_calls[0].domain == DOMAIN + assert set_mode_calls[0].service == "set_mode" + assert set_mode_calls[0].data == {"entity_id": entry.entity_id, "mode": "away"} + @pytest.mark.parametrize( ( @@ -380,7 +466,7 @@ async def test_capabilities( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -399,7 +485,7 @@ async def test_capabilities( { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.entity_id, "type": action, }, ) @@ -418,7 +504,6 @@ async def test_capabilities( ("action", "capability_name", "extra"), [ ("set_humidity", "humidity", {"type": "integer"}), - ("set_mode", "mode", {"type": "select", "options": []}), ], ) async def test_capabilities_missing_entity( diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index 06356c64260..22dfc5c31d5 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -59,7 +59,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -78,7 +78,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in basic_condition_types @@ -89,7 +89,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.entity_id, "metadata": {"secondary": False}, } for condition in expected_condition_types @@ -123,7 +123,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -137,7 +137,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_off", "is_on"] @@ -148,9 +148,13 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" - hass.states.async_set("humidifier.entity", STATE_ON, {ATTR_MODE: const.MODE_AWAY}) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) assert await async_setup_component( hass, @@ -164,7 +168,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "is_on", } ], @@ -183,7 +187,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.id, "type": "is_off", } ], @@ -202,7 +206,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "humidifier.entity", + "entity_id": entry.entity_id, "type": "is_mode", "mode": "away", } @@ -218,7 +222,6 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: }, ) await hass.async_block_till_done() - assert hass.states.get("humidifier.entity").state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -227,14 +230,14 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_on event - test_event1" - hass.states.async_set("humidifier.entity", STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_off event - test_event2" - hass.states.async_set("humidifier.entity", STATE_ON, {ATTR_MODE: const.MODE_AWAY}) + hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) hass.bus.async_fire("test_event3") await hass.async_block_till_done() @@ -242,7 +245,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 3 assert calls[2].data["some"] == "is_mode - event - test_event3" - hass.states.async_set("humidifier.entity", STATE_ON, {ATTR_MODE: const.MODE_HOME}) + hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_HOME}) # Should not fire hass.bus.async_fire("test_event3") @@ -386,7 +389,7 @@ async def test_capabilities( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -395,7 +398,7 @@ async def test_capabilities( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", + entity_entry.entity_id, STATE_ON, capabilities_state, ) @@ -405,7 +408,7 @@ async def test_capabilities( { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.entity_id, "type": condition, }, ) diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index b69a59de1d2..afdbc046042 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -89,7 +89,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": entity_entry.entity_id, + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in toggle_trigger_types @@ -150,7 +150,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": entity_entry.entity_id, + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in toggle_trigger_types @@ -231,7 +231,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "turned_on", }, "action": { @@ -255,7 +255,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "turned_off", }, "action": { @@ -279,7 +279,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "changed_states", }, "action": { diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 46967d17f87..4f97eaec012 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -12,9 +12,12 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -49,7 +52,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -63,13 +66,24 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in [ "turn_off", "turn_on", "toggle", + ] + ] + expected_actions += [ + { + "domain": DOMAIN, + "type": action, + "device_id": device_entry.id, + "entity_id": entity_entry.entity_id, + "metadata": {"secondary": False}, + } + for action in [ "brightness_decrease", "brightness_increase", "flash", @@ -104,7 +118,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -120,7 +134,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["turn_on", "turn_off", "toggle"] @@ -168,7 +182,7 @@ async def test_get_action_capabilities( capabilities = await async_get_device_automation_capabilities( hass, DeviceAutomationType.ACTION, action ) - assert capabilities == {"extra_fields": []} + assert capabilities == {"extra_fields": []} or capabilities == {} @pytest.mark.parametrize( @@ -319,16 +333,13 @@ async def test_get_action_capabilities_features( async def test_action( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1 = platform.ENTITIES[0] + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -340,7 +351,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turn_off", }, }, @@ -349,7 +360,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turn_on", }, }, @@ -358,7 +369,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "toggle", }, }, @@ -367,7 +378,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, "type": "flash", }, }, @@ -376,7 +387,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, "type": "flash", "flash": "long", }, @@ -389,7 +400,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, "type": "brightness_increase", }, }, @@ -401,7 +412,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, "type": "brightness_decrease", }, }, @@ -410,7 +421,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, "type": "turn_on", "brightness_pct": 75, }, @@ -419,89 +430,59 @@ async def test_action( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - assert len(calls) == 0 - - hass.bus.async_fire("test_off") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF - - hass.bus.async_fire("test_off") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF - - hass.bus.async_fire("test_on") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - - hass.bus.async_fire("test_on") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - - hass.bus.async_fire("test_toggle") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF - - hass.bus.async_fire("test_toggle") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - - hass.bus.async_fire("test_toggle") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF - - hass.bus.async_fire("test_flash_short") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - - hass.bus.async_fire("test_toggle") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF - - hass.bus.async_fire("test_flash_long") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON turn_on_calls = async_mock_service(hass, DOMAIN, "turn_on") + turn_off_calls = async_mock_service(hass, DOMAIN, "turn_off") + toggle_calls = async_mock_service(hass, DOMAIN, "toggle") + + hass.bus.async_fire("test_toggle") + await hass.async_block_till_done() + assert len(toggle_calls) == 1 + assert toggle_calls[-1].data == {"entity_id": entry.entity_id} + + hass.bus.async_fire("test_off") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} hass.bus.async_fire("test_brightness_increase") await hass.async_block_till_done() - assert len(turn_on_calls) == 1 - assert turn_on_calls[0].data["entity_id"] == ent1.entity_id - assert turn_on_calls[0].data["brightness_step_pct"] == 10 + assert turn_on_calls[-1].data == { + "entity_id": entry.entity_id, + "brightness_step_pct": 10, + } hass.bus.async_fire("test_brightness_decrease") await hass.async_block_till_done() - assert len(turn_on_calls) == 2 - assert turn_on_calls[1].data["entity_id"] == ent1.entity_id - assert turn_on_calls[1].data["brightness_step_pct"] == -10 + assert turn_on_calls[-1].data == { + "entity_id": entry.entity_id, + "brightness_step_pct": -10, + } hass.bus.async_fire("test_brightness") await hass.async_block_till_done() - assert len(turn_on_calls) == 3 - assert turn_on_calls[2].data["entity_id"] == ent1.entity_id - assert turn_on_calls[2].data["brightness_pct"] == 75 + assert turn_on_calls[-1].data == { + "entity_id": entry.entity_id, + "brightness_pct": 75, + } hass.bus.async_fire("test_on") await hass.async_block_till_done() - assert len(turn_on_calls) == 4 - assert turn_on_calls[3].data["entity_id"] == ent1.entity_id - assert "brightness_pct" not in turn_on_calls[3].data + assert turn_on_calls[-1].data == {"entity_id": entry.entity_id} hass.bus.async_fire("test_flash_short") await hass.async_block_till_done() - assert len(turn_on_calls) == 5 - assert turn_on_calls[4].data["entity_id"] == ent1.entity_id - assert turn_on_calls[4].data["flash"] == FLASH_SHORT + assert turn_on_calls[-1].data == { + "entity_id": entry.entity_id, + "flash": FLASH_SHORT, + } hass.bus.async_fire("test_flash_long") await hass.async_block_till_done() - assert len(turn_on_calls) == 6 - assert turn_on_calls[5].data["entity_id"] == ent1.entity_id - assert turn_on_calls[5].data["flash"] == FLASH_LONG + assert turn_on_calls[-1].data == {"entity_id": entry.entity_id, "flash": FLASH_LONG} diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index b57a2fe8dfe..b38c225347a 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -46,7 +46,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -55,7 +55,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in ["is_off", "is_on"] @@ -89,7 +89,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_off", "is_on"] @@ -144,17 +144,49 @@ async def test_get_condition_capabilities( assert capabilities == expected_capabilities +async def test_get_condition_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a light condition.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + for condition in conditions: + condition["entity_id"] = entity_registry.async_get( + condition["entity_id"] + ).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.CONDITION, condition + ) + assert capabilities == expected_capabilities + + async def test_if_state( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -168,7 +200,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_on", } ], @@ -187,7 +219,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_off", } ], @@ -203,7 +235,6 @@ async def test_if_state( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -212,7 +243,7 @@ async def test_if_state( assert len(calls) == 1 assert calls[0].data["some"] == "is_on event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -220,10 +251,65 @@ async def test_if_state( assert calls[1].data["some"] == "is_off event - test_event2" +async def test_if_state_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + async def test_if_fires_on_for_condition( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for firing if condition is on with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + point1 = dt_util.utcnow() point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) @@ -248,7 +334,7 @@ async def test_if_fires_on_for_condition( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, }, @@ -266,7 +352,6 @@ async def test_if_fires_on_for_condition( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -279,7 +364,7 @@ async def test_if_fires_on_for_condition( await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index d0b6eaec2aa..085193e3b34 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -7,7 +7,7 @@ from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.light import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -46,7 +46,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -55,7 +55,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -89,7 +89,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -144,19 +144,47 @@ async def test_get_trigger_capabilities( assert capabilities == expected_capabilities +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a light trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + async def test_if_fires_on_state_change( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - await hass.async_block_till_done() - - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -168,7 +196,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turned_on", }, "action": { @@ -192,7 +220,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turned_off", }, "action": { @@ -216,7 +244,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "changed_states", }, "action": { @@ -239,39 +267,35 @@ async def test_if_fires_on_state_change( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - f"turn_off device - {ent1.entity_id} - on - off - None", - f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + f"turn_off device - {entry.entity_id} - on - off - None", + f"turn_on_or_off device - {entry.entity_id} - on - off - None", } - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - f"turn_on device - {ent1.entity_id} - off - on - None", - f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + f"turn_on device - {entry.entity_id} - off - on - None", + f"turn_on_or_off device - {entry.entity_id} - off - on - None", } -async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: - """Test for triggers firing with delay.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - await hass.async_block_till_done() - - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -283,7 +307,61 @@ async def test_if_fires_on_state_change_with_for( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] == f"turn_on device - {entry.entity_id} - on - off - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, }, @@ -307,16 +385,16 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() assert len(calls) == 1 await hass.async_block_till_done() - assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( - ent1.entity_id + assert ( + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - on - off - 0:00:05" ) diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 8bf71f85647..42608eacb09 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -58,11 +58,9 @@ async def test_get_triggers( DOMAIN, "test", "5678", device_id=device_entry.id ) - entity_trigger_types = { - "changed_states", - } trigger_types = { "buffering", + "changed_states", "idle", "paused", "playing", @@ -80,17 +78,6 @@ async def test_get_triggers( } for trigger in trigger_types ] - expected_triggers += [ - { - "platform": "device", - "domain": DOMAIN, - "type": trigger, - "device_id": device_entry.id, - "entity_id": entity_entry.entity_id, - "metadata": {"secondary": False}, - } - for trigger in entity_trigger_types - ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) @@ -128,11 +115,9 @@ async def test_get_triggers_hidden_auxiliary( entity_category=entity_category, hidden_by=hidden_by, ) - entity_trigger_types = { - "changed_states", - } trigger_types = { "buffering", + "changed_states", "idle", "paused", "playing", @@ -150,17 +135,6 @@ async def test_get_triggers_hidden_auxiliary( } for trigger in trigger_types ] - expected_triggers += [ - { - "platform": "device", - "domain": DOMAIN, - "type": trigger, - "device_id": device_entry.id, - "entity_id": entity_entry.entity_id, - "metadata": {"secondary": True}, - } - for trigger in entity_trigger_types - ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) @@ -263,9 +237,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id - if trigger == "changed_states" - else entry.id, + "entity_id": entry.id, "type": trigger, }, "action": { diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 563136e5d6d..97ff2fd58a0 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -5,7 +5,7 @@ from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -41,7 +41,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_actions = [ @@ -49,7 +49,7 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in ["turn_off", "turn_on", "toggle"] @@ -83,7 +83,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -97,7 +97,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["turn_off", "turn_on", "toggle"] @@ -109,16 +109,13 @@ async def test_get_actions_hidden_auxiliary( async def test_action( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -126,29 +123,29 @@ async def test_action( { automation.DOMAIN: [ { - "trigger": {"platform": "event", "event_type": "test_event1"}, + "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turn_off", }, }, { - "trigger": {"platform": "event", "event_type": "test_event2"}, + "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turn_on", }, }, { - "trigger": {"platform": "event", "event_type": "test_event3"}, + "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "toggle", }, }, @@ -156,29 +153,58 @@ async def test_action( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - assert len(calls) == 0 - hass.bus.async_fire("test_event1") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF + turn_on_calls = async_mock_service(hass, DOMAIN, "turn_on") + turn_off_calls = async_mock_service(hass, DOMAIN, "turn_off") + toggle_calls = async_mock_service(hass, DOMAIN, "toggle") - hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_toggle") await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF + assert len(toggle_calls) == 1 + assert toggle_calls[-1].data == {"entity_id": entry.entity_id} - hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_off") await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(turn_off_calls) == 1 + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} - hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_on") await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(turn_on_calls) == 1 + assert turn_on_calls[-1].data == {"entity_id": entry.entity_id} - hass.bus.async_fire("test_event3") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF - hass.bus.async_fire("test_event3") +async def test_action_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_off"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "turn_off", + }, + }, + ] + }, + ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + + turn_off_calls = async_mock_service(hass, DOMAIN, "turn_off") + + hass.bus.async_fire("test_off") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 51ca928b39c..b07747771d9 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -8,7 +8,7 @@ from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -46,7 +46,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -55,7 +55,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in ["is_off", "is_on"] @@ -89,7 +89,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_off", "is_on"] @@ -144,17 +144,49 @@ async def test_get_condition_capabilities( assert capabilities == expected_capabilities +async def test_get_condition_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a remote condition.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + for condition in conditions: + condition["entity_id"] = entity_registry.async_get( + condition["entity_id"] + ).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.CONDITION, condition + ) + assert capabilities == expected_capabilities + + async def test_if_state( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -168,7 +200,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_on", } ], @@ -187,7 +219,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_off", } ], @@ -203,7 +235,6 @@ async def test_if_state( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -212,7 +243,7 @@ async def test_if_state( assert len(calls) == 1 assert calls[0].data["some"] == "is_on event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -220,21 +251,68 @@ async def test_if_state( assert calls[1].data["some"] == "is_off event - test_event2" +async def test_if_state_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + async def test_if_fires_on_for_condition( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for firing if condition is on with delay.""" point1 = dt_util.utcnow() point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) with freeze_time(point1) as freezer: assert await async_setup_component( @@ -248,7 +326,7 @@ async def test_if_fires_on_for_condition( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, }, @@ -266,7 +344,6 @@ async def test_if_fires_on_for_condition( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -279,7 +356,7 @@ async def test_if_fires_on_for_condition( await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index d45d15b67ee..b5dcca3dc4c 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -7,7 +7,7 @@ from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -46,7 +46,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -55,7 +55,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -89,7 +89,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -144,17 +144,47 @@ async def test_get_trigger_capabilities( assert capabilities == expected_capabilities +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a remote trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + async def test_if_fires_on_state_change( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -166,7 +196,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turned_on", }, "action": { @@ -190,7 +220,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turned_off", }, "action": { @@ -214,7 +244,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "changed_states", }, "action": { @@ -236,38 +266,35 @@ async def test_if_fires_on_state_change( ] }, ) - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - f"turn_off device - {ent1.entity_id} - on - off - None", - f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + f"turn_off device - {entry.entity_id} - on - off - None", + f"turn_on_or_off device - {entry.entity_id} - on - off - None", } - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - f"turn_on device - {ent1.entity_id} - off - on - None", - f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + f"turn_on device - {entry.entity_id} - off - on - None", + f"turn_on_or_off device - {entry.entity_id} - off - on - None", } -async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: - """Test for triggers firing with delay.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -279,7 +306,61 @@ async def test_if_fires_on_state_change_with_for( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - on - off - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, }, @@ -303,16 +384,16 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() assert len(calls) == 1 await hass.async_block_till_done() - assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( - ent1.entity_id + assert ( + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - on - off - 0:00:05" ) diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 1acea0b107a..85799a49a34 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -5,7 +5,7 @@ from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -41,7 +41,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_actions = [] @@ -50,7 +50,7 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in ["turn_off", "turn_on", "toggle"] @@ -84,7 +84,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -98,7 +98,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["turn_off", "turn_on", "toggle"] @@ -110,16 +110,13 @@ async def test_get_actions_hidden_auxiliary( async def test_action( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -127,29 +124,29 @@ async def test_action( { automation.DOMAIN: [ { - "trigger": {"platform": "event", "event_type": "test_event1"}, + "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turn_off", }, }, { - "trigger": {"platform": "event", "event_type": "test_event2"}, + "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turn_on", }, }, { - "trigger": {"platform": "event", "event_type": "test_event3"}, + "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "toggle", }, }, @@ -157,29 +154,58 @@ async def test_action( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - assert len(calls) == 0 - hass.bus.async_fire("test_event1") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF + turn_on_calls = async_mock_service(hass, DOMAIN, "turn_on") + turn_off_calls = async_mock_service(hass, DOMAIN, "turn_off") + toggle_calls = async_mock_service(hass, DOMAIN, "toggle") - hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_toggle") await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF + assert len(toggle_calls) == 1 + assert toggle_calls[-1].data == {"entity_id": entry.entity_id} - hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_off") await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(turn_off_calls) == 1 + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} - hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_on") await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(turn_on_calls) == 1 + assert turn_on_calls[-1].data == {"entity_id": entry.entity_id} - hass.bus.async_fire("test_event3") - await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_OFF - hass.bus.async_fire("test_event3") +async def test_action_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_off"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, + "type": "turn_off", + }, + }, + ] + }, + ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + + turn_off_calls = async_mock_service(hass, DOMAIN, "turn_off") + + hass.bus.async_fire("test_off") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index e2512624c15..c60954e335f 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -8,7 +8,7 @@ from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -46,7 +46,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -55,7 +55,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in ["is_off", "is_on"] @@ -89,7 +89,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_off", "is_on"] @@ -144,17 +144,49 @@ async def test_get_condition_capabilities( assert capabilities == expected_capabilities +async def test_get_condition_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a switch condition.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + for condition in conditions: + condition["entity_id"] = entity_registry.async_get( + condition["entity_id"] + ).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.CONDITION, condition + ) + assert capabilities == expected_capabilities + + async def test_if_state( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -168,7 +200,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_on", } ], @@ -187,7 +219,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_off", } ], @@ -203,7 +235,6 @@ async def test_if_state( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -212,7 +243,7 @@ async def test_if_state( assert len(calls) == 1 assert calls[0].data["some"] == "is_on event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -220,21 +251,67 @@ async def test_if_state( assert calls[1].data["some"] == "is_off event - test_event2" +async def test_if_state_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + async def test_if_fires_on_for_condition( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for firing if condition is on with delay.""" point1 = dt_util.utcnow() point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) with freeze_time(point1) as freezer: assert await async_setup_component( @@ -248,7 +325,7 @@ async def test_if_fires_on_for_condition( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, }, @@ -266,7 +343,6 @@ async def test_if_fires_on_for_condition( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -279,7 +355,7 @@ async def test_if_fires_on_for_condition( await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 7ca2e480f4d..32f8f65b114 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -7,7 +7,7 @@ from pytest_unordered import unordered import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -46,7 +46,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -55,7 +55,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -89,7 +89,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -144,17 +144,47 @@ async def test_get_trigger_capabilities( assert capabilities == expected_capabilities +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a switch trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + async def test_if_fires_on_state_change( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -166,7 +196,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turned_on", }, "action": { @@ -190,7 +220,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "turned_off", }, "action": { @@ -214,7 +244,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.id, "type": "changed_states", }, "action": { @@ -237,37 +267,35 @@ async def test_if_fires_on_state_change( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - f"turn_off device - {ent1.entity_id} - on - off - None", - f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + f"turn_off device - {entry.entity_id} - on - off - None", + f"turn_on_or_off device - {entry.entity_id} - on - off - None", } - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - f"turn_on device - {ent1.entity_id} - off - on - None", - f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + f"turn_on device - {entry.entity_id} - off - on - None", + f"turn_on_or_off device - {entry.entity_id} - off - on - None", } -async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: - """Test for triggers firing with delay.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + """Test for turn_on and turn_off triggers firing.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -279,7 +307,62 @@ async def test_if_fires_on_state_change_with_for( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent1.entity_id, + "entity_id": entry.entity_id, + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - on - off - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for triggers firing with delay.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, }, @@ -303,16 +386,16 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() assert len(calls) == 1 await hass.async_block_till_done() - assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( - ent1.entity_id + assert ( + calls[0].data["some"] + == f"turn_off device - {entry.entity_id} - on - off - 0:00:05" ) diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index a9abed935fb..b2d06a642a8 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -45,7 +45,7 @@ async def test_get_triggers( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_triggers = [ @@ -54,7 +54,7 @@ async def test_get_triggers( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -88,7 +88,7 @@ async def test_get_triggers_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -102,7 +102,7 @@ async def test_get_triggers_hidden_auxiliary( "domain": DOMAIN, "type": trigger, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for trigger in ["changed_states", "turned_off", "turned_on"] @@ -143,8 +143,42 @@ async def test_get_trigger_capabilities( assert capabilities == expected_capabilities +async def test_get_trigger_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a update trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + for trigger in triggers: + trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + async def test_if_fires_on_state_change( - hass: HomeAssistant, calls: list[ServiceCall], enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -153,6 +187,8 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + entry = entity_registry.async_get("update.update_available") + assert await async_setup_component( hass, automation.DOMAIN, @@ -163,7 +199,7 @@ async def test_if_fires_on_state_change( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "update.update_available", + "entity_id": entry.id, "type": "turned_on", }, "action": { @@ -232,16 +268,21 @@ async def test_if_fires_on_state_change( ) -async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls: list[ServiceCall], enable_custom_integrations: None +async def test_if_fires_on_state_change_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], + enable_custom_integrations: None, ) -> None: - """Test for triggers firing with delay.""" + """Test for turn_on and turn_off triggers firing.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + entry = entity_registry.async_get("update.update_available") + assert await async_setup_component( hass, automation.DOMAIN, @@ -252,7 +293,69 @@ async def test_if_fires_on_state_change_with_for( "platform": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "update.update_available", + "entity_id": entry.entity_id, + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "no_update {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_ON + assert not calls + + hass.states.async_set("update.update_available", STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "no_update device - update.update_available - on - off - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], + enable_custom_integrations: None, +) -> None: + """Test for triggers firing with delay.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + entry = entity_registry.async_get("update.update_available") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, }, diff --git a/tests/components/wemo/test_device_trigger.py b/tests/components/wemo/test_device_trigger.py index e7a1c11e6c8..fd5db46e6c6 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -71,7 +71,7 @@ async def test_get_triggers(hass: HomeAssistant, wemo_entity) -> None: { CONF_DEVICE_ID: wemo_entity.device_id, CONF_DOMAIN: Platform.SWITCH, - CONF_ENTITY_ID: wemo_entity.entity_id, + CONF_ENTITY_ID: wemo_entity.id, CONF_PLATFORM: "device", CONF_TYPE: "changed_states", "metadata": {"secondary": False}, @@ -79,7 +79,7 @@ async def test_get_triggers(hass: HomeAssistant, wemo_entity) -> None: { CONF_DEVICE_ID: wemo_entity.device_id, CONF_DOMAIN: Platform.SWITCH, - CONF_ENTITY_ID: wemo_entity.entity_id, + CONF_ENTITY_ID: wemo_entity.id, CONF_PLATFORM: "device", CONF_TYPE: "turned_off", "metadata": {"secondary": False}, @@ -87,7 +87,7 @@ async def test_get_triggers(hass: HomeAssistant, wemo_entity) -> None: { CONF_DEVICE_ID: wemo_entity.device_id, CONF_DOMAIN: Platform.SWITCH, - CONF_ENTITY_ID: wemo_entity.entity_id, + CONF_ENTITY_ID: wemo_entity.id, CONF_PLATFORM: "device", CONF_TYPE: "turned_on", "metadata": {"secondary": False}, diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 5b6d7c94539..32dbf2d88c0 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -14,7 +14,7 @@ from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.zha import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE @@ -164,6 +164,8 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non inovelli_reg_device = ha_device_registry.async_get_device( {(DOMAIN, inovelli_ieee_address)} ) + ha_entity_registry = er.async_get(hass) + inovelli_light = ha_entity_registry.async_get("light.inovelli_vzm31_sn_light") actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, inovelli_reg_device.id @@ -192,21 +194,21 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non { "device_id": inovelli_reg_device.id, "domain": Platform.LIGHT, - "entity_id": "light.inovelli_vzm31_sn_light", + "entity_id": inovelli_light.id, "metadata": {"secondary": False}, "type": "turn_off", }, { "device_id": inovelli_reg_device.id, "domain": Platform.LIGHT, - "entity_id": "light.inovelli_vzm31_sn_light", + "entity_id": inovelli_light.id, "metadata": {"secondary": False}, "type": "turn_on", }, { "device_id": inovelli_reg_device.id, "domain": Platform.LIGHT, - "entity_id": "light.inovelli_vzm31_sn_light", + "entity_id": inovelli_light.id, "metadata": {"secondary": False}, "type": "toggle", }, From 74fb1ba51d161b550f021aa1fe9cfce5b00972d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 26 Jun 2023 12:02:32 +0200 Subject: [PATCH 533/857] Update aioairzone-cloud to v0.1.9 (#95155) --- homeassistant/components/airzone_cloud/entity.py | 2 +- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index c7e59ee1a3f..077f7940940 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -86,7 +86,7 @@ class AirzoneWebServerEntity(AirzoneEntity): connections={(dr.CONNECTION_NETWORK_MAC, ws_id)}, identifiers={(DOMAIN, ws_id)}, manufacturer=MANUFACTURER, - name=f"WebServer {ws_id}", + name=ws_data[AZD_NAME], sw_version=ws_data[AZD_FIRMWARE], ) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index e64a5d9a7e2..28014580c7f 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.1.8"] + "requirements": ["aioairzone-cloud==0.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index b6058900420..171f8b04a41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,7 +191,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.8 +aioairzone-cloud==0.1.9 # homeassistant.components.airzone aioairzone==0.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d91b73fb075..5f1cd19dfb6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,7 +169,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.8 +aioairzone-cloud==0.1.9 # homeassistant.components.airzone aioairzone==0.6.4 From ad17a89531b0ac589fc7f3d3291a52dd8864b2e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Jun 2023 05:29:38 -0500 Subject: [PATCH 534/857] Add additional coverage for ESPHome sensor and number (#95226) --- .coveragerc | 1 - homeassistant/components/esphome/number.py | 4 +- tests/components/esphome/test_number.py | 91 ++++++++++++++++++++ tests/components/esphome/test_sensor.py | 97 +++++++++++++++++++++- 4 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 tests/components/esphome/test_number.py diff --git a/.coveragerc b/.coveragerc index 70d9fd5e0e2..984adba59f7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -312,7 +312,6 @@ omit = homeassistant/components/esphome/domain_data.py homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/light.py - homeassistant/components/esphome/number.py homeassistant/components/esphome/switch.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index e876fe412f6..4e3d052e6ef 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -74,9 +74,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): def native_value(self) -> float | None: """Return the state of the entity.""" state = self._state - if math.isnan(state.state): - return None - if state.missing_state: + if state.missing_state or math.isnan(state.state): return None return state.state diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py new file mode 100644 index 00000000000..8157c5f5c3d --- /dev/null +++ b/tests/components/esphome/test_number.py @@ -0,0 +1,91 @@ +"""Test ESPHome numbers.""" + +import math +from unittest.mock import call + +from aioesphomeapi import ( + APIClient, + NumberInfo, + NumberMode as ESPHomeNumberMode, + NumberState, +) + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + + +async def test_generic_number_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic number entity.""" + entity_info = [ + NumberInfo( + object_id="mynumber", + key=1, + name="my number", + unique_id="my_number", + max_value=100, + min_value=0, + step=1, + unit_of_measurement="%", + ) + ] + states = [NumberState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("number.test_my_number") + assert state is not None + assert state.state == "50" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 50}, + blocking=True, + ) + mock_client.number_command.assert_has_calls([call(1, 50)]) + mock_client.number_command.reset_mock() + + +async def test_generic_number_nan( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic number entity with nan state.""" + entity_info = [ + NumberInfo( + object_id="mynumber", + key=1, + name="my number", + unique_id="my_number", + max_value=100, + min_value=0, + step=1, + unit_of_measurement="%", + mode=ESPHomeNumberMode.SLIDER, + ) + ] + states = [NumberState(key=1, state=math.nan)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("number.test_my_number") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 5517198341a..8f4eb0f9513 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -1,6 +1,9 @@ """Test ESPHome sensors.""" +import math + from aioesphomeapi import ( APIClient, + EntityCategory as ESPHomeEntityCategory, LastResetType, SensorInfo, SensorState, @@ -10,8 +13,10 @@ from aioesphomeapi import ( ) from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import ATTR_ICON, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory async def test_generic_numeric_sensor( @@ -41,6 +46,41 @@ async def test_generic_numeric_sensor( assert state.state == "50" +async def test_generic_numeric_sensor_with_entity_category_and_icon( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic sensor entity.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + entity_category=ESPHomeEntityCategory.CONFIG, + icon="mdi:leaf", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "50" + assert state.attributes[ATTR_ICON] == "mdi:leaf" + entity_reg = er.async_get(hass) + entry = entity_reg.async_get("sensor.test_my_sensor") + assert entry is not None + assert entry.unique_id == "my_sensor" + assert entry.entity_category is EntityCategory.CONFIG + + async def test_generic_numeric_sensor_state_class_measurement( hass: HomeAssistant, mock_client: APIClient, @@ -70,6 +110,11 @@ async def test_generic_numeric_sensor_state_class_measurement( assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + entity_reg = er.async_get(hass) + entry = entity_reg.async_get("sensor.test_my_sensor") + assert entry is not None + assert entry.unique_id == "my_sensor" + assert entry.entity_category is None async def test_generic_numeric_sensor_device_class_timestamp( @@ -130,6 +175,56 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING +async def test_generic_numeric_sensor_no_state( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic numeric sensor that has no state.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + ) + ] + states = [] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_generic_numeric_sensor_nan_state( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic numeric sensor that has nan state.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + ) + ] + states = [SensorState(key=1, state=math.nan, missing_state=False)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + async def test_generic_numeric_sensor_missing_state( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: From 8ccb0c3e141ce43ae4199fec6fe5b77917bd285d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 26 Jun 2023 12:49:18 +0200 Subject: [PATCH 535/857] Update types packages (#95222) --- requirements_test.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index e29a663e9b1..74369507229 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -34,18 +34,18 @@ respx==0.20.1 syrupy==4.0.2 tomli==2.0.1;python_version<"3.11" tqdm==4.64.0 -types-atomicwrites==1.4.1 +types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 types-chardet==0.1.5 -types-decorator==5.1.1 +types-decorator==5.1.8.3 types-enum34==1.1.8 types-ipaddress==1.0.8 -types-paho-mqtt==1.6.0.1 +types-paho-mqtt==1.6.0.6 types-pkg-resources==0.1.3 -types-python-dateutil==2.8.19.5 +types-python-dateutil==2.8.19.13 types-python-slugify==0.1.2 -types-pytz==2022.7.0.0 +types-pytz==2023.3.0.0 types-PyYAML==6.0.12.2 -types-requests==2.30.0.0 -types-toml==0.10.8.1 +types-requests==2.31.0.1 +types-toml==0.10.8.6 From d14f04eb7e339237b6fa8ebbcc2236670a69a805 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 12:52:31 +0200 Subject: [PATCH 536/857] Move Aurora coordinator to separate file (#95130) --- .coveragerc | 1 + homeassistant/components/aurora/__init__.py | 42 +-------------- .../components/aurora/coordinator.py | 52 +++++++++++++++++++ 3 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/aurora/coordinator.py diff --git a/.coveragerc b/.coveragerc index 984adba59f7..44e77956aef 100644 --- a/.coveragerc +++ b/.coveragerc @@ -90,6 +90,7 @@ omit = homeassistant/components/atome/* homeassistant/components/aurora/__init__.py homeassistant/components/aurora/binary_sensor.py + homeassistant/components/aurora/coordinator.py homeassistant/components/aurora/sensor.py homeassistant/components/avea/light.py homeassistant/components/avion/light.py diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index bac402fe633..50aff860e9f 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,9 +1,7 @@ """The aurora component.""" -from datetime import timedelta import logging -from aiohttp import ClientError from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry @@ -14,8 +12,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, ) from .const import ( @@ -27,6 +23,7 @@ from .const import ( DEFAULT_THRESHOLD, DOMAIN, ) +from .coordinator import AuroraDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -81,43 +78,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class AuroraDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the NOAA Aurora API.""" - - def __init__( - self, - hass: HomeAssistant, - name: str, - polling_interval: int, - api: str, - latitude: float, - longitude: float, - threshold: float, - ) -> None: - """Initialize the data updater.""" - - super().__init__( - hass=hass, - logger=_LOGGER, - name=name, - update_interval=timedelta(minutes=polling_interval), - ) - - self.api = api - self.name = name - self.latitude = int(latitude) - self.longitude = int(longitude) - self.threshold = int(threshold) - - async def _async_update_data(self): - """Fetch the data from the NOAA Aurora Forecast.""" - - try: - return await self.api.get_forecast_data(self.longitude, self.latitude) - except ClientError as error: - raise UpdateFailed(f"Error updating from NOAA: {error}") from error - - class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): """Implementation of the base Aurora Entity.""" diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py new file mode 100644 index 00000000000..c126e2a8c68 --- /dev/null +++ b/homeassistant/components/aurora/coordinator.py @@ -0,0 +1,52 @@ +"""The aurora component.""" + +from datetime import timedelta +import logging + +from aiohttp import ClientError +from auroranoaa import AuroraForecast + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) + +_LOGGER = logging.getLogger(__name__) + + +class AuroraDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the NOAA Aurora API.""" + + def __init__( + self, + hass: HomeAssistant, + name: str, + polling_interval: int, + api: AuroraForecast, + latitude: float, + longitude: float, + threshold: float, + ) -> None: + """Initialize the data updater.""" + + super().__init__( + hass=hass, + logger=_LOGGER, + name=name, + update_interval=timedelta(minutes=polling_interval), + ) + + self.api = api + self.name = name + self.latitude = int(latitude) + self.longitude = int(longitude) + self.threshold = int(threshold) + + async def _async_update_data(self): + """Fetch the data from the NOAA Aurora Forecast.""" + + try: + return await self.api.get_forecast_data(self.longitude, self.latitude) + except ClientError as error: + raise UpdateFailed(f"Error updating from NOAA: {error}") from error From 5a9815570002c7383534dee2e3421d970db64042 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 14:17:15 +0200 Subject: [PATCH 537/857] Add entity translations for EasyEnergy (#95235) --- homeassistant/components/easyenergy/sensor.py | 42 +++++++++---------- .../components/easyenergy/strings.json | 34 +++++++++++++++ 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 9cf5944dfaa..a64851f6696 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -48,7 +48,7 @@ class EasyEnergySensorEntityDescription( SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( EasyEnergySensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_gas", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", @@ -56,14 +56,14 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_gas", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", value_fn=lambda data: get_gas_price(data, 1), ), EasyEnergySensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_energy_usage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", @@ -71,7 +71,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( @@ -80,42 +80,42 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="average_price", - name="Average - today", + translation_key="average_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.average_usage_price, ), EasyEnergySensorEntityDescription( key="max_price", - name="Highest price - today", + translation_key="max_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_usage_prices[1], ), EasyEnergySensorEntityDescription( key="min_price", - name="Lowest price - today", + translation_key="min_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_usage_prices[0], ), EasyEnergySensorEntityDescription( key="highest_price_time", - name="Time of highest price - today", + translation_key="highest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.highest_usage_price_time, ), EasyEnergySensorEntityDescription( key="lowest_price_time", - name="Time of lowest price - today", + translation_key="lowest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.lowest_usage_price_time, ), EasyEnergySensorEntityDescription( key="percentage_of_max", - name="Current percentage of highest price - today", + translation_key="percentage_of_max", service_type="today_energy_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", @@ -123,7 +123,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_energy_return", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", @@ -131,7 +131,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( @@ -140,42 +140,42 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="average_price", - name="Average - today", + translation_key="average_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.average_return_price, ), EasyEnergySensorEntityDescription( key="max_price", - name="Highest price - today", + translation_key="max_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_return_prices[1], ), EasyEnergySensorEntityDescription( key="min_price", - name="Lowest price - today", + translation_key="min_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_return_prices[0], ), EasyEnergySensorEntityDescription( key="highest_price_time", - name="Time of highest price - today", + translation_key="highest_price_time", service_type="today_energy_return", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.highest_return_price_time, ), EasyEnergySensorEntityDescription( key="lowest_price_time", - name="Time of lowest price - today", + translation_key="lowest_price_time", service_type="today_energy_return", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.lowest_return_price_time, ), EasyEnergySensorEntityDescription( key="percentage_of_max", - name="Current percentage of highest price - today", + translation_key="percentage_of_max", service_type="today_energy_return", native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", @@ -183,7 +183,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="hours_priced_equal_or_lower", - name="Hours priced equal or lower than current - today", + translation_key="hours_priced_equal_or_lower", service_type="today_energy_usage", native_unit_of_measurement=UnitOfTime.HOURS, icon="mdi:clock", @@ -191,7 +191,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="hours_priced_equal_or_higher", - name="Hours priced equal or higher than current - today", + translation_key="hours_priced_equal_or_higher", service_type="today_energy_return", native_unit_of_measurement=UnitOfTime.HOURS, icon="mdi:clock", @@ -231,7 +231,7 @@ async def async_setup_entry( class EasyEnergySensorEntity( CoordinatorEntity[EasyEnergyDataUpdateCoordinator], SensorEntity ): - """Defines a easyEnergy sensor.""" + """Defines an easyEnergy sensor.""" _attr_has_entity_name = True _attr_attribution = "Data provided by easyEnergy" diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index ed89e0068d4..93fb264b01d 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -8,5 +8,39 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "current_hour_price": { + "name": "Current hour" + }, + "next_hour_price": { + "name": "Next hour" + }, + "average_price": { + "name": "Average - today" + }, + "max_price": { + "name": "Highest price - today" + }, + "min_price": { + "name": "Lowest price - today" + }, + "highest_price_time": { + "name": "Time of highest price - today" + }, + "lowest_price_time": { + "name": "Time of lowest price - today" + }, + "percentage_of_max": { + "name": "Current percentage of highest price - today" + }, + "hours_priced_equal_or_lower": { + "name": "Hours priced equal or lower than current - today" + }, + "hours_priced_equal_or_higher": { + "name": "Hours priced equal or higher than current - today" + } + } } } From 021a39a09cf0508bd66f69c7569cd0983b475092 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Jun 2023 07:21:45 -0500 Subject: [PATCH 538/857] Make deep sleep esphome entities unavailable on unexpected disconnect (#95211) --- homeassistant/components/esphome/__init__.py | 10 ++++++++-- homeassistant/components/esphome/entity.py | 2 +- homeassistant/components/esphome/entry_data.py | 1 + homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 76f218d3668..afaefe117ba 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -425,14 +425,20 @@ async def async_setup_entry( # noqa: C901 _async_check_firmware_version(hass, device_info, entry_data.api_version) _async_check_using_api_password(hass, device_info, bool(password)) - async def on_disconnect() -> None: + async def on_disconnect(expected_disconnect: bool) -> None: """Run disconnect callbacks on API disconnect.""" name = entry_data.device_info.name if entry_data.device_info else host - _LOGGER.debug("%s: %s disconnected, running disconnected callbacks", name, host) + _LOGGER.debug( + "%s: %s disconnected (expected=%s), running disconnected callbacks", + name, + host, + expected_disconnect, + ) for disconnect_cb in entry_data.disconnect_callbacks: disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False + entry_data.expected_disconnect = expected_disconnect # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects entry_data.stale_state = { diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index dbb16fe481d..15c136f17c3 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -259,7 +259,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): if self._device_info.has_deep_sleep: # During deep sleep the ESP will not be connectable (by design) # For these cases, show it as available - return True + return self._entry_data.expected_disconnect return self._entry_data.available diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index e0d989c4b8b..a7c81543a94 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -94,6 +94,7 @@ class RuntimeEntryData: info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict) services: dict[int, UserService] = field(default_factory=dict) available: bool = False + expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep) device_info: DeviceInfo | None = None api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 928bd851ca1..de47c904dd7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==14.1.1", + "aioesphomeapi==15.0.0", "bluetooth-data-tools==1.2.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 171f8b04a41..e67b8e2d7db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==14.1.1 +aioesphomeapi==15.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f1cd19dfb6..b0ced104c69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==14.1.1 +aioesphomeapi==15.0.0 # homeassistant.components.flo aioflo==2021.11.0 From a31e899741ab1642b6d9e1e5b04b9829885dbe21 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Jun 2023 08:24:12 -0400 Subject: [PATCH 539/857] Pass correct parameter when resolving media via WS (#90897) --- homeassistant/components/media_source/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index f3c5c92eaa6..62cf7815613 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -193,7 +193,7 @@ async def websocket_resolve_media( ) -> None: """Resolve media.""" try: - media = await async_resolve_media(hass, msg["media_content_id"]) + media = await async_resolve_media(hass, msg["media_content_id"], None) except Unresolvable as err: connection.send_error(msg["id"], "resolve_media_failed", str(err)) return From 45ff9d8f6300e3d71ff3e2ddea992fbf68a3bfa6 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 26 Jun 2023 05:31:28 -0700 Subject: [PATCH 540/857] Allow rounding two decimal places for Flume usage sensors (#95219) --- homeassistant/components/flume/sensor.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index b656f5e9715..9900b494821 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -1,5 +1,4 @@ """Sensor for displaying the number of result from Flume.""" -from numbers import Number from pyflume import FlumeData @@ -35,11 +34,13 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="current_interval", name="Current", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m", ), SensorEntityDescription( key="month_to_date", name="Current Month", + suggested_display_precision=2, native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, @@ -47,6 +48,7 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="week_to_date", name="Current Week", + suggested_display_precision=2, native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, @@ -54,6 +56,7 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="today", name="Current Day", + suggested_display_precision=2, native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, @@ -61,18 +64,21 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="last_60_min", name="60 Minutes", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/h", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="last_24_hrs", name="24 Hours", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/d", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="last_30_days", name="30 Days", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/mo", state_class=SensorStateClass.MEASUREMENT, ), @@ -139,8 +145,4 @@ class FlumeSensor(FlumeEntity[FlumeDeviceDataUpdateCoordinator], SensorEntity): if sensor_key not in self.coordinator.flume_device.values: return None - return _format_state_value(self.coordinator.flume_device.values[sensor_key]) - - -def _format_state_value(value): - return round(value, 1) if isinstance(value, Number) else None + return self.coordinator.flume_device.values[sensor_key] From c75e831b65e3ce5a209e5f102166ad95136cb6bd Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 26 Jun 2023 14:32:02 +0200 Subject: [PATCH 541/857] Cosign support (#95236) --- .github/workflows/builder.yml | 139 +++++++++++++++++----------------- build.yaml | 16 ++-- machine/build.yaml | 6 +- 3 files changed, 84 insertions(+), 77 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index fce25f3fd75..965b7d912d9 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -48,18 +48,6 @@ jobs: with: ignore-dev: true - - name: Generate meta info - shell: bash - run: | - echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > OFFICIAL_IMAGE - - - name: Signing meta info file - uses: home-assistant/actions/helpers/codenotary@master - with: - source: file://${{ github.workspace }}/OFFICIAL_IMAGE - asset: OFFICIAL_IMAGE-${{ steps.version.outputs.version }} - token: ${{ secrets.CAS_TOKEN }} - build_python: name: Build PyPi package environment: ${{ needs.init.outputs.channel }} @@ -101,6 +89,10 @@ jobs: if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write strategy: matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} @@ -197,12 +189,6 @@ jobs: run: | echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - - name: Login to DockerHub - uses: docker/login-action@v2.2.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry uses: docker/login-action@v2.2.0 with: @@ -216,6 +202,7 @@ jobs: args: | $BUILD_ARGS \ --${{ matrix.arch }} \ + --cosign \ --target /data \ --generic ${{ needs.init.outputs.version }} env: @@ -237,6 +224,10 @@ jobs: if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write strategy: matrix: machine: @@ -275,12 +266,6 @@ jobs: echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV fi - - name: Login to DockerHub - uses: docker/login-action@v2.2.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry uses: docker/login-action@v2.2.0 with: @@ -294,6 +279,7 @@ jobs: args: | $BUILD_ARGS \ --target /data/machine \ + --cosign \ --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" env: CAS_API_KEY: ${{ secrets.CAS_TOKEN }} @@ -338,34 +324,28 @@ jobs: if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - registry: - - "ghcr.io/home-assistant" - - "homeassistant" steps: - name: Checkout the repository uses: actions/checkout@v3.5.3 + - name: Install Cosign + uses: sigstore/cosign-installer@v3.0.5 + with: + cosign-release: "v2.0.2" + - name: Login to DockerHub - if: matrix.registry == 'homeassistant' uses: docker/login-action@v2.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - if: matrix.registry == 'ghcr.io/home-assistant' uses: docker/login-action@v2.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Install CAS tools - uses: home-assistant/actions/helpers/cas@master - - name: Build Meta Image shell: bash run: | @@ -375,55 +355,78 @@ jobs: local tag_l=${1} local tag_r=${2} - docker manifest create "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/i386-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/aarch64-homeassistant:${tag_r}" + for registry in "ghcr.io/home-assistant" "docker.io/homeassistant" + do - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \ - --os linux --arch amd64 + docker manifest create "${registry}/home-assistant:${tag_l}" \ + "${registry}/amd64-homeassistant:${tag_r}" \ + "${registry}/i386-homeassistant:${tag_r}" \ + "${registry}/armhf-homeassistant:${tag_r}" \ + "${registry}/armv7-homeassistant:${tag_r}" \ + "${registry}/aarch64-homeassistant:${tag_r}" - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/i386-homeassistant:${tag_r}" \ - --os linux --arch 386 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/amd64-homeassistant:${tag_r}" \ + --os linux --arch amd64 - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \ - --os linux --arch arm --variant=v6 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/i386-homeassistant:${tag_r}" \ + --os linux --arch 386 - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \ - --os linux --arch arm --variant=v7 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/armhf-homeassistant:${tag_r}" \ + --os linux --arch arm --variant=v6 - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/aarch64-homeassistant:${tag_r}" \ - --os linux --arch arm64 --variant=v8 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/armv7-homeassistant:${tag_r}" \ + --os linux --arch arm --variant=v7 - docker manifest push --purge "${{ matrix.registry }}/home-assistant:${tag_l}" + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/aarch64-homeassistant:${tag_r}" \ + --os linux --arch arm64 --variant=v8 + + docker manifest push --purge "${registry}/home-assistant:${tag_l}" + cosign sign --yes "${registry}/home-assistant:${tag_l}" + + done } function validate_image() { local image=${1} - if ! cas authenticate --signerID notary@home-assistant.io "docker://${image}"; then + if ! cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp https://github.com/home-assistant/core/.* "${image}"; then echo "Invalid signature!" exit 1 fi } - docker pull "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}" + function push_dockerhub() { + local image=${1} + local tag=${2} - validate_image "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}" + docker tag "ghcr.io/home-assistant/${image}:${tag}" "docker.io/homeassistant/${image}:${tag}" + docker push "docker.io/homeassistant/${image}:${tag}" + cosign sign --yes "docker.io/homeassistant/${image}:${tag}" + } + + # Pull images from github container registry and verify signature + docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" + + validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" + + # Upload images to dockerhub + push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "i386-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "armhf-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "armv7-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}" # Create version tag create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}" diff --git a/build.yaml b/build.yaml index b32aa38dff6..a181e9d1548 100644 --- a/build.yaml +++ b/build.yaml @@ -1,14 +1,16 @@ -image: homeassistant/{arch}-homeassistant -shadow_repository: ghcr.io/home-assistant +image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.06.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.06.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.06.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.06.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.06.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.06.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.06.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.06.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.06.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.06.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io +cosign: + base_identity: https://github.com/home-assistant/docker/.* + identity: https://github.com/home-assistant/core/.* labels: io.hass.type: core org.opencontainers.image.title: Home Assistant diff --git a/machine/build.yaml b/machine/build.yaml index 340b8079b9f..2f8aa3fe5c3 100644 --- a/machine/build.yaml +++ b/machine/build.yaml @@ -1,5 +1,4 @@ -image: homeassistant/{machine}-homeassistant -shadow_repository: ghcr.io/home-assistant +image: ghcr.io/home-assistant/{machine}-homeassistant build_from: aarch64: "ghcr.io/home-assistant/aarch64-homeassistant:" armv7: "ghcr.io/home-assistant/armv7-homeassistant:" @@ -9,6 +8,9 @@ build_from: codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io +cosign: + base_identity: https://github.com/home-assistant/core/.* + identity: https://github.com/home-assistant/core/.* labels: io.hass.type: core org.opencontainers.image.source: https://github.com/home-assistant/core From 7dae17a4046adb8200a9f14a7dcaa417b6bb2f7e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 14:37:04 +0200 Subject: [PATCH 542/857] Add entity translations to Dremel 3D printer (#95234) --- .../dremel_3d_printer/binary_sensor.py | 2 - .../components/dremel_3d_printer/sensor.py | 44 ++++++------ .../components/dremel_3d_printer/strings.json | 67 +++++++++++++++++++ 3 files changed, 89 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py index 1832e10d3c9..3a92bfe5510 100644 --- a/homeassistant/components/dremel_3d_printer/binary_sensor.py +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -36,13 +36,11 @@ class Dremel3DPrinterBinarySensorEntityDescription( BINARY_SENSOR_TYPES: tuple[Dremel3DPrinterBinarySensorEntityDescription, ...] = ( Dremel3DPrinterBinarySensorEntityDescription( key="door", - name="Door", device_class=BinarySensorDeviceClass.DOOR, value_fn=lambda api: api.is_door_open(), ), Dremel3DPrinterBinarySensorEntityDescription( key="running", - name="Running", device_class=BinarySensorDeviceClass.RUNNING, value_fn=lambda api: api.is_running(), ), diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index 00002e44c4e..71e60dc04fc 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -50,13 +50,13 @@ class Dremel3DPrinterSensorEntityDescription( SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( Dremel3DPrinterSensorEntityDescription( key="job_phase", - name="Job phase", + translation_key="job_phase", icon="mdi:printer-3d", value_fn=lambda api, _: api.get_printing_status(), ), Dremel3DPrinterSensorEntityDescription( key="remaining_time", - name="Remaining time", + translation_key="remaining_time", device_class=SensorDeviceClass.TIMESTAMP, available_fn=lambda api, key: api.get_job_status()[key] > 0, value_fn=ignore_variance( @@ -66,7 +66,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="progress", - name="Progress", + translation_key="progress", icon="mdi:printer-3d-nozzle", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -76,7 +76,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="chamber", - name="Chamber", + translation_key="chamber", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -86,7 +86,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="platform_temperature", - name="Platform temperature", + translation_key="platform_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="target_platform_temperature", - name="Target platform temperature", + translation_key="target_platform_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -108,7 +108,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="max_platform_temperature", - name="Max platform temperature", + translation_key="max_platform_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key=ATTR_EXTRUDER, - name="Extruder", + translation_key="extruder", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -130,7 +130,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="target_extruder_temperature", - name="Target extruder temperature", + translation_key="target_extruder_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -142,7 +142,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="max_extruder_temperature", - name="Max extruder temperature", + translation_key="max_extruder_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -154,14 +154,14 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="network_build", - name="Network build", + translation_key="network_build", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, key: api.get_job_status()[key], ), Dremel3DPrinterSensorEntityDescription( key="filament", - name="Filament", + translation_key="filament", icon="mdi:printer-3d-nozzle", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -169,7 +169,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="elapsed_time", - name="Elapsed time", + translation_key="elapsed_time", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -181,7 +181,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="estimated_total_time", - name="Estimated total time", + translation_key="estimated_total_time", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -193,7 +193,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="job_status", - name="Job status", + translation_key="job_status", icon="mdi:printer-3d", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -201,7 +201,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="job_name", - name="Job name", + translation_key="job_name", icon="mdi:file", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -209,7 +209,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="api_version", - name="API version", + translation_key="api_version", icon="mdi:api", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -217,7 +217,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="host", - name="Host", + translation_key="host", icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -225,7 +225,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="connection_type", - name="Connection type", + translation_key="connection_type", icon="mdi:network", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -233,7 +233,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="available_storage", - name="Available storage", + translation_key="available_storage", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -242,7 +242,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="hours_used", - name="Hours used", + translation_key="hours_used", icon="mdi:clock", native_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, @@ -267,7 +267,7 @@ async def async_setup_entry( class Dremel3DPrinterSensor(Dremel3DPrinterEntity, SensorEntity): - """Representation of an Dremel 3D Printer sensor.""" + """Representation of a Dremel 3D Printer sensor.""" entity_description: Dremel3DPrinterSensorEntityDescription diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 64b95cbfd05..08d2a001d2d 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -14,5 +14,72 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "job_phase": { + "name": "Job phase" + }, + "remaining_time": { + "name": "Remaining time" + }, + "progress": { + "name": "Progress" + }, + "chamber": { + "name": "Chamber" + }, + "platform_temperature": { + "name": "Platform temperature" + }, + "target_platform_temperature": { + "name": "Target platform temperature" + }, + "max_platform_temperature": { + "name": "Max platform temperature" + }, + "extruder": { + "name": "Extruder" + }, + "target_extruder_temperature": { + "name": "Target extruder temperature" + }, + "max_extruder_temperature": { + "name": "Max extruder temperature" + }, + "network_build": { + "name": "Network build" + }, + "filament": { + "name": "Filament" + }, + "elapsed_time": { + "name": "Elapsed time" + }, + "estimated_total_time": { + "name": "Estimated total time" + }, + "job_status": { + "name": "Job status" + }, + "job_name": { + "name": "Job name" + }, + "api_version": { + "name": "API version" + }, + "host": { + "name": "[%key:common::config_flow::data::host%]" + }, + "connection_type": { + "name": "Connection type" + }, + "available_storage": { + "name": "Available storage" + }, + "hours_used": { + "name": "Hours used" + } + } } } From 1029bcbbd3092002460ce1ac149c321c55243175 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 26 Jun 2023 14:42:24 +0200 Subject: [PATCH 543/857] Add mqtt image platform (#94769) * Add mqtt image platform * Follow up comments * Use separate topics * Set last_ image to `None` on error * Fix encoding and schema validation * Assing None to last_image when get image fails * Follow up comment * Remove content_type validation * Add validation * Rename options according suggestions * Remove url_topic / template feature from PR * Always set content_type --- .../components/mqtt/abbreviations.py | 2 + .../components/mqtt/config_integration.py | 5 + homeassistant/components/mqtt/const.py | 2 + homeassistant/components/mqtt/discovery.py | 1 + homeassistant/components/mqtt/image.py | 155 ++++++ tests/components/mqtt/test_image.py | 521 ++++++++++++++++++ 6 files changed, 686 insertions(+) create mode 100644 homeassistant/components/mqtt/image.py create mode 100644 tests/components/mqtt/test_image.py diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 257e0fe95ae..8bc318e4897 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -41,6 +41,7 @@ ABBREVIATIONS = { "cod_dis_req": "code_disarm_required", "cod_form": "code_format", "cod_trig_req": "code_trigger_required", + "cont_type": "content_type", "curr_hum_t": "current_humidity_topic", "curr_hum_tpl": "current_humidity_template", "curr_temp_t": "current_temperature_topic", @@ -83,6 +84,7 @@ ABBREVIATIONS = { "hs_val_tpl": "hs_value_template", "ic": "icon", "img_e": "image_encoding", + "img_t": "image_topic", "init": "initial", "hum_cmd_t": "target_humidity_command_topic", "hum_cmd_tpl": "target_humidity_command_template", diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index fdc32a601e0..ba2e0427ba7 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -24,6 +24,7 @@ from . import ( device_tracker as device_tracker_platform, fan as fan_platform, humidifier as humidifier_platform, + image as image_platform, light as light_platform, lock as lock_platform, number as number_platform, @@ -89,6 +90,10 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [humidifier_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.IMAGE.value: vol.All( + cv.ensure_list, + [image_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] + ), Platform.LOCK.value: vol.All( cv.ensure_list, [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index fd259965d20..bf01bb13483 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ PLATFORMS = [ Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -137,6 +138,7 @@ RELOADABLE_PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0411a1f679c..70e5ac9e535 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -54,6 +54,7 @@ SUPPORTED_COMPONENTS = [ "device_tracker", "fan", "humidifier", + "image", "light", "lock", "number", diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py new file mode 100644 index 00000000000..80b0a6be1f6 --- /dev/null +++ b/homeassistant/components/mqtt/image.py @@ -0,0 +1,155 @@ +"""Support for MQTT images.""" +from __future__ import annotations + +from base64 import b64decode +import binascii +from collections.abc import Callable +import functools +import logging +from typing import Any + +import httpx +import voluptuous as vol + +from homeassistant.components import image +from homeassistant.components.image import ( + DEFAULT_CONTENT_TYPE, + ImageEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util + +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import CONF_QOS +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .models import ReceiveMessage +from .util import get_mqtt_data, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_CONTENT_TYPE = "content_type" +CONF_IMAGE_ENCODING = "image_encoding" +CONF_IMAGE_TOPIC = "image_topic" + +DEFAULT_NAME = "MQTT Image" + + +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_IMAGE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_IMAGE_ENCODING): "b64", + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT image through YAML and through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, image.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up the MQTT Image.""" + async_add_entities([MqttImage(hass, config, config_entry, discovery_data)]) + + +class MqttImage(MqttEntity, ImageEntity): + """representation of a MQTT image.""" + + _entity_id_format: str = image.ENTITY_ID_FORMAT + _last_image: bytes | None = None + _client: httpx.AsyncClient + _url_template: Callable[[ReceivePayloadType], ReceivePayloadType] + _topic: dict[str, Any] + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the MQTT Image.""" + self._client = get_async_client(hass) + ImageEntity.__init__(self) + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._topic = {key: config.get(key) for key in (CONF_IMAGE_TOPIC,)} + self._attr_content_type = config[CONF_CONTENT_TYPE] + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + topics: dict[str, Any] = {} + + @callback + @log_messages(self.hass, self.entity_id) + def image_data_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + try: + if CONF_IMAGE_ENCODING in self._config: + self._last_image = b64decode(msg.payload) + else: + assert isinstance(msg.payload, bytes) + self._last_image = msg.payload + except (binascii.Error, ValueError, AssertionError) as err: + _LOGGER.error( + "Error processing image data received at topic %s: %s", + msg.topic, + err, + ) + self._last_image = None + self._attr_image_last_updated = dt_util.utcnow() + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + topics[self._config[CONF_IMAGE_TOPIC]] = { + "topic": self._config[CONF_IMAGE_TOPIC], + "msg_callback": image_data_received, + "qos": self._config[CONF_QOS], + "encoding": None, + } + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return self._last_image diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py new file mode 100644 index 00000000000..8cb7739cd7e --- /dev/null +++ b/tests/components/mqtt/test_image.py @@ -0,0 +1,521 @@ +"""The tests for mqtt image component.""" +from base64 import b64encode +from contextlib import suppress +from http import HTTPStatus +import json +from unittest.mock import patch + +import pytest +import respx + +from homeassistant.components import image, mqtt +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import ( + ClientSessionGenerator, + MqttMockHAClientGenerator, + MqttMockPahoClient, +) + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {image.DOMAIN: {"name": "test", "image_topic": "test_topic"}} +} + + +@pytest.fixture(autouse=True) +def image_platform_only(): + """Only setup the image platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.IMAGE]): + yield + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [{mqtt.DOMAIN: {image.DOMAIN: {"image_topic": "test/image", "name": "Test"}}}], +) +async def test_run_image_setup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that it fetches the given payload.""" + topic = "test/image" + await mqtt_mock_entry() + + state = hass.states.get("image.test") + assert state.state == STATE_UNKNOWN + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + async_fire_mqtt_message(hass, topic, b"grass") + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"grass" + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + image.DOMAIN: { + "image_topic": "test/image", + "name": "Test", + "image_encoding": "b64", + "content_type": "image/png", + } + } + } + ], +) +async def test_run_image_b64_encoded( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that it fetches the given encoded payload.""" + topic = "test/image" + await mqtt_mock_entry() + + state = hass.states.get("image.test") + assert state.state == STATE_UNKNOWN + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + # Fire incorrect encoded message (utf-8 encoded string) + async_fire_mqtt_message(hass, topic, "grass") + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error processing image data received at topic test/image" in caplog.text + + # Fire correctly encoded message (b64 encoded payload) + async_fire_mqtt_message(hass, topic, b64encode(b"grass")) + client = await hass_client_no_auth() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"grass" + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "image": { + "image_topic": "test/image", + "name": "Test", + "encoding": "utf-8", + "image_encoding": "b64", + "availability": {"topic": "test/image_availability"}, + } + } + } + ], +) +async def test_image_b64_encoded_with_availability( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test availability works if b64 encoding is turned on.""" + topic = "test/image" + topic_availability = "test/image_availability" + await mqtt_mock_entry() + + state = hass.states.get("image.test") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Make sure we are available + async_fire_mqtt_message(hass, topic_availability, "online") + + state = hass.states.get("image.test") + assert state is not None + assert state.state == STATE_UNKNOWN + + url = hass.states.get("image.test").attributes["entity_picture"] + + async_fire_mqtt_message(hass, topic, b64encode(b"grass")) + + client = await hass_client_no_auth() + resp = await client.get(url) + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == "grass" + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +@pytest.mark.parametrize( + ("hass_config", "error_msg"), + [ + ( + { + mqtt.DOMAIN: { + "image": { + "name": "Test", + "encoding": "utf-8", + } + } + }, + "Invalid config for [mqtt]: required key not provided @ data['mqtt']['image'][0]['image_topic']. Got None.", + ), + ], +) +async def test_image_config_fails( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + error_msg: str, +) -> None: + """Test setup with minimum configuration.""" + with suppress(AssertionError): + await mqtt_mock_entry() + assert error_msg in caplog.text + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, image.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + image.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + image.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + image.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + image.DOMAIN: [ + { + "name": "Test 1", + "image_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "image_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one image per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, image.DOMAIN) + + +async def test_discovery_removal_image( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered image.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][image.DOMAIN]) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, image.DOMAIN, data) + + +async def test_discovery_update_image( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered image.""" + config1 = {"name": "Beer", "image_topic": "test_topic"} + config2 = {"name": "Milk", "image_topic": "test_topic"} + + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, image.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_image( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered image.""" + data1 = '{ "name": "Beer", "image_topic": "test_topic"}' + with patch( + "homeassistant.components.mqtt.image.MqttImage.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + image.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "image_topic": "test_topic"}' + + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, image.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT image device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT image device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, + mqtt_mock_entry, + image.DOMAIN, + DEFAULT_CONFIG, + ["test_topic"], + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, image.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + image.DOMAIN, + DEFAULT_CONFIG, + None, + state_topic="test_topic", + state_payload=b"ON", + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = image.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = image.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = image.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) From 5bd5ca843366e28bd8d699121508551ea52391dd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 26 Jun 2023 15:46:37 +0200 Subject: [PATCH 544/857] Add identify device class to button (#95244) --- homeassistant/components/button/__init__.py | 1 + homeassistant/components/button/strings.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 44fa72c1a67..5dd827053a2 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -35,6 +35,7 @@ _LOGGER = logging.getLogger(__name__) class ButtonDeviceClass(StrEnum): """Device class for buttons.""" + IDENTIFY = "identify" RESTART = "restart" UPDATE = "update" diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index efad77f5c6d..006959d1b4c 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -12,6 +12,9 @@ "_": { "name": "[%key:component::button::title%]" }, + "identify": { + "name": "Identify" + }, "restart": { "name": "Restart" }, From f08f0fbb8bd1604812a9f49822e67ad0e52ae73a Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Mon, 26 Jun 2023 08:49:44 -0500 Subject: [PATCH 545/857] Fix control4 light switches on OS 3.3+ (#95196) --- homeassistant/components/control4/light.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index fde9b00aba2..a2d1308be98 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) CONTROL4_CATEGORY = "lights" CONTROL4_NON_DIMMER_VAR = "LIGHT_STATE" -CONTROL4_DIMMER_VAR = "LIGHT_LEVEL" +CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"] async def async_setup_entry( @@ -57,7 +57,7 @@ async def async_setup_entry( """Fetch data from Control4 director for dimmer lights.""" try: return await update_variables_for_config_entry( - hass, entry, {CONTROL4_DIMMER_VAR} + hass, entry, {*CONTROL4_DIMMER_VARS} ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -190,14 +190,19 @@ class Control4Light(Control4Entity, LightEntity): def is_on(self): """Return whether this light is on or off.""" if self._is_dimmer: - return self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] > 0 + for var in CONTROL4_DIMMER_VARS: + if var in self.coordinator.data[self._idx]: + return self.coordinator.data[self._idx][var] > 0 + raise RuntimeError("Dimmer Variable Not Found") return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property def brightness(self): """Return the brightness of this light between 0..255.""" if self._is_dimmer: - return round(self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] * 2.55) + for var in CONTROL4_DIMMER_VARS: + if var in self.coordinator.data[self._idx]: + return round(self.coordinator.data[self._idx][var] * 2.55) return None @property From 8fda56d2c9f34a656e6df341795dfb09f31e316a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 15:54:35 +0200 Subject: [PATCH 546/857] Stable entity registry id when a deleted entity is restored (#77710) * Stable entity_id and registry id when a deleted entity is restored * Don't restore area_id * Don't restore entity_id * Address review comments --- homeassistant/helpers/entity_registry.py | 149 +++++++++++++++++++++-- tests/common.py | 1 + tests/helpers/test_entity_registry.py | 142 ++++++++++++++++++++- 3 files changed, 283 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 29a9def5673..cabac2617c2 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -11,7 +11,9 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView +from datetime import datetime, timedelta import logging +import time from typing import TYPE_CHECKING, Any, TypeVar, cast import attr @@ -26,6 +28,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, MAX_LENGTH_STATE_DOMAIN, MAX_LENGTH_STATE_ENTITY_ID, STATE_UNAVAILABLE, @@ -61,9 +64,12 @@ SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 10 +STORAGE_VERSION_MINOR = 11 STORAGE_KEY = "core.entity_registry" +CLEANUP_INTERVAL = 3600 * 24 +ORPHANED_ENTITY_KEEP_SECONDS = 3600 * 24 * 30 + ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = { # mypy does not understand strenum val: idx # type: ignore[misc] @@ -138,7 +144,10 @@ class RegistryEntry: entity_category: EntityCategory | None = attr.ib(default=None) hidden_by: RegistryEntryHider | None = attr.ib(default=None) icon: str | None = attr.ib(default=None) - id: str = attr.ib(factory=uuid_util.random_uuid_hex) + id: str = attr.ib( + default=None, + converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex), # type: ignore[misc] + ) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) options: ReadOnlyEntityOptionsType = attr.ib( @@ -297,6 +306,24 @@ class RegistryEntry: hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) +@attr.s(slots=True, frozen=True) +class DeletedRegistryEntry: + """Deleted Entity Registry Entry.""" + + entity_id: str = attr.ib() + unique_id: str = attr.ib() + platform: str = attr.ib() + config_entry_id: str | None = attr.ib() + domain: str = attr.ib(init=False, repr=False) + id: str = attr.ib() + orphaned_timestamp: float | None = attr.ib() + + @domain.default + def _domain_default(self) -> str: + """Compute domain value.""" + return split_entity_id(self.entity_id)[0] + + class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" @@ -372,6 +399,10 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entity in data["entities"]: entity["aliases"] = [] + if old_major_version == 1 and old_minor_version < 11: + # Version 1.11 adds deleted_entities + data["deleted_entities"] = data.get("deleted_entities", []) + if old_major_version > 1: raise NotImplementedError return data @@ -424,6 +455,7 @@ class EntityRegistryItems(UserDict[str, "RegistryEntry"]): class EntityRegistry: """Class to hold a registry of entities.""" + deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] entities: EntityRegistryItems _entities_data: dict[str, RegistryEntry] @@ -496,6 +528,9 @@ class EntityRegistry: - It's not registered - It's not known by the entity component adding the entity - It's not in the state machine + + Note that an entity_id which belongs to a deleted entity is considered + available. """ if known_object_ids is None: known_object_ids = {} @@ -591,8 +626,16 @@ class EntityRegistry: unit_of_measurement=unit_of_measurement, ) + entity_registry_id: str | None = None + deleted_entity = self.deleted_entities.pop((domain, platform, unique_id), None) + if deleted_entity is not None: + # Restore id + entity_registry_id = deleted_entity.id + entity_id = self.async_generate_entity_id( - domain, suggested_object_id or f"{platform}_{unique_id}", known_object_ids + domain, + suggested_object_id or f"{platform}_{unique_id}", + known_object_ids, ) if disabled_by and not isinstance(disabled_by, RegistryEntryDisabler): @@ -630,6 +673,7 @@ class EntityRegistry: entity_id=entity_id, hidden_by=hidden_by, has_entity_name=none_if_undefined(has_entity_name) or False, + id=entity_registry_id, options=initial_options, original_device_class=none_if_undefined(original_device_class), original_icon=none_if_undefined(original_icon), @@ -653,7 +697,19 @@ class EntityRegistry: @callback def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" - self.entities.pop(entity_id) + entity = self.entities.pop(entity_id) + config_entry_id = entity.config_entry_id + key = (entity.domain, entity.platform, entity.unique_id) + # If the entity does not belong to a config entry, mark it as orphaned + orphaned_timestamp = None if config_entry_id else time.time() + self.deleted_entities[key] = DeletedRegistryEntry( + config_entry_id=config_entry_id, + entity_id=entity_id, + id=entity.id, + orphaned_timestamp=orphaned_timestamp, + platform=entity.platform, + unique_id=entity.unique_id, + ) self.hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, {"action": "remove", "entity_id": entity_id} ) @@ -954,10 +1010,12 @@ class EntityRegistry: async def async_load(self) -> None: """Load the entity registry.""" - async_setup_entity_restore(self.hass, self) + _async_setup_cleanup(self.hass, self) + _async_setup_entity_restore(self.hass, self) data = await self._store.async_load() entities = EntityRegistryItems() + deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {} if data is not None: for entity in data["entities"]: @@ -996,7 +1054,22 @@ class EntityRegistry: unique_id=entity["unique_id"], unit_of_measurement=entity["unit_of_measurement"], ) + for entity in data["deleted_entities"]: + key = ( + split_entity_id(entity["entity_id"])[0], + entity["platform"], + entity["unique_id"], + ) + deleted_entities[key] = DeletedRegistryEntry( + config_entry_id=entity["config_entry_id"], + entity_id=entity["entity_id"], + id=entity["id"], + orphaned_timestamp=entity["orphaned_timestamp"], + platform=entity["platform"], + unique_id=entity["unique_id"], + ) + self.deleted_entities = deleted_entities self.entities = entities self._entities_data = entities.data @@ -1038,18 +1111,54 @@ class EntityRegistry: } for entry in self.entities.values() ] + data["deleted_entities"] = [ + { + "config_entry_id": entry.config_entry_id, + "entity_id": entry.entity_id, + "id": entry.id, + "orphaned_timestamp": entry.orphaned_timestamp, + "platform": entry.platform, + "unique_id": entry.unique_id, + } + for entry in self.deleted_entities.values() + ] return data @callback - def async_clear_config_entry(self, config_entry: str) -> None: + def async_clear_config_entry(self, config_entry_id: str) -> None: """Clear config entry from registry entries.""" + now_time = time.time() for entity_id in [ entity_id for entity_id, entry in self.entities.items() - if config_entry == entry.config_entry_id + if config_entry_id == entry.config_entry_id ]: self.async_remove(entity_id) + for key, deleted_entity in list(self.deleted_entities.items()): + if config_entry_id != deleted_entity.config_entry_id: + continue + # Add a time stamp when the deleted entity became orphaned + self.deleted_entities[key] = attr.evolve( + deleted_entity, orphaned_timestamp=now_time, config_entry_id=None + ) + self.async_schedule_save() + + @callback + def async_purge_expired_orphaned_entities(self) -> None: + """Purge expired orphaned entities from the registry. + + We need to purge these periodically to avoid the database + growing without bound. + """ + now_time = time.time() + for key, deleted_entity in list(self.deleted_entities.items()): + if (orphaned_timestamp := deleted_entity.orphaned_timestamp) is None: + continue + + if orphaned_timestamp + ORPHANED_ENTITY_KEEP_SECONDS < now_time: + self.deleted_entities.pop(key) + self.async_schedule_save() @callback def async_clear_area_id(self, area_id: str) -> None: @@ -1136,7 +1245,31 @@ def async_config_entry_disabled_by_changed( @callback -def async_setup_entity_restore(hass: HomeAssistant, registry: EntityRegistry) -> None: +def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None: + """Clean up device registry when entities removed.""" + from . import event # pylint: disable=import-outside-toplevel + + @callback + def cleanup(_: datetime) -> None: + """Clean up entity registry.""" + # Periodic purge of orphaned entities to avoid the registry + # growing without bounds when there are lots of deleted entities + registry.async_purge_expired_orphaned_entities() + + cancel = event.async_track_time_interval( + hass, cleanup, timedelta(seconds=CLEANUP_INTERVAL) + ) + + @callback + def _on_homeassistant_stop(event: Event) -> None: + """Cancel cleanup.""" + cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop) + + +@callback +def _async_setup_entity_restore(hass: HomeAssistant, registry: EntityRegistry) -> None: """Set up the entity restore mechanism.""" @callback diff --git a/tests/common.py b/tests/common.py index ab5c39f5cd1..ae64e8d4aec 100644 --- a/tests/common.py +++ b/tests/common.py @@ -511,6 +511,7 @@ def mock_registry( registry = er.EntityRegistry(hass) if mock_entries is None: mock_entries = {} + registry.deleted_entities = {} registry.entities = er.EntityRegistryItems() registry._entities_data = registry.entities.data for key, entry in mock_entries.items(): diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index f1801f181cf..57622d330d9 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,7 +1,9 @@ """Tests for the Entity Registry.""" +from datetime import timedelta from typing import Any from unittest.mock import patch +import attr import pytest import voluptuous as vol @@ -15,7 +17,7 @@ from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, flush_store +from tests.common import MockConfigEntry, async_fire_time_changed, flush_store YAML__OPEN_PATH = "homeassistant.util.yaml.loader.open" @@ -276,8 +278,13 @@ async def test_loading_saving_data( orig_entry2.entity_id, "light", {"minimum_brightness": 20} ) orig_entry2 = entity_registry.async_get(orig_entry2.entity_id) + orig_entry3 = entity_registry.async_get_or_create("light", "hue", "ABCD") + orig_entry4 = entity_registry.async_get_or_create("light", "hue", "EFGH") + entity_registry.async_remove(orig_entry3.entity_id) + entity_registry.async_remove(orig_entry4.entity_id) assert len(entity_registry.entities) == 2 + assert len(entity_registry.deleted_entities) == 2 # Now load written data in new registry registry2 = er.EntityRegistry(hass) @@ -286,11 +293,16 @@ async def test_loading_saving_data( # Ensure same order assert list(entity_registry.entities) == list(registry2.entities) + assert list(entity_registry.deleted_entities) == list(registry2.deleted_entities) new_entry1 = entity_registry.async_get_or_create("light", "hue", "1234") new_entry2 = entity_registry.async_get_or_create("light", "hue", "5678") + new_entry3 = entity_registry.async_get_or_create("light", "hue", "ABCD") + new_entry4 = entity_registry.async_get_or_create("light", "hue", "EFGH") assert orig_entry1 == new_entry1 assert orig_entry2 == new_entry2 + assert orig_entry3 == new_entry3 + assert orig_entry4 == new_entry4 assert new_entry2.area_id == "mock-area-id" assert new_entry2.capabilities == {"max": 100} @@ -485,6 +497,42 @@ async def test_removing_config_entry_id( assert update_events[1]["entity_id"] == entry.entity_id +async def test_deleted_entity_removing_config_entry_id( + hass, entity_registry: er.EntityRegistry +): + """Test that we update config entry id in registry on deleted entity.""" + mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + + entry = entity_registry.async_get_or_create( + "light", "hue", "5678", config_entry=mock_config + ) + assert entry.config_entry_id == "mock-id-1" + entity_registry.async_remove(entry.entity_id) + + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + assert ( + entity_registry.deleted_entities[("light", "hue", "5678")].config_entry_id + == "mock-id-1" + ) + assert ( + entity_registry.deleted_entities[("light", "hue", "5678")].orphaned_timestamp + is None + ) + + entity_registry.async_clear_config_entry("mock-id-1") + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + assert ( + entity_registry.deleted_entities[("light", "hue", "5678")].config_entry_id + is None + ) + assert ( + entity_registry.deleted_entities[("light", "hue", "5678")].orphaned_timestamp + is not None + ) + + async def test_removing_area_id(entity_registry: er.EntityRegistry) -> None: """Make sure we can clear area id.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") @@ -1537,3 +1585,95 @@ def test_migrate_entity_to_new_platform( new_unique_id=new_unique_id, new_config_entry_id=new_config_entry.entry_id, ) + + +async def test_restore_entity(hass, update_events, freezer): + """Make sure entity registry id is stable and entity_id is reused if possible.""" + registry = er.async_get(hass) # We need the real entity registry for this test + config_entry = MockConfigEntry(domain="light") + entry1 = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry + ) + entry2 = registry.async_get_or_create( + "light", "hue", "5678", config_entry=config_entry + ) + + entry1 = registry.async_update_entity( + entry1.entity_id, new_entity_id="light.custom_1" + ) + + registry.async_remove(entry1.entity_id) + registry.async_remove(entry2.entity_id) + assert len(registry.entities) == 0 + assert len(registry.deleted_entities) == 2 + + # Re-add entities + entry1_restored = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry + ) + entry2_restored = registry.async_get_or_create("light", "hue", "5678") + + assert len(registry.entities) == 2 + assert len(registry.deleted_entities) == 0 + assert entry1 != entry1_restored + # entity_id is not restored + assert attr.evolve(entry1, entity_id="light.hue_1234") == entry1_restored + assert entry2 != entry2_restored + # Config entry is not restored + assert attr.evolve(entry2, config_entry_id=None) == entry2_restored + + # Remove two of the entities again, then bump time + registry.async_remove(entry1_restored.entity_id) + registry.async_remove(entry2.entity_id) + assert len(registry.entities) == 0 + assert len(registry.deleted_entities) == 2 + freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Re-add two entities, expect to get a new id after the purge for entity w/o config entry + entry1_restored = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry + ) + entry2_restored = registry.async_get_or_create("light", "hue", "5678") + assert len(registry.entities) == 2 + assert len(registry.deleted_entities) == 0 + assert entry1.id == entry1_restored.id + assert entry2.id != entry2_restored.id + + # Remove the first entity, then its config entry, finally bump time + registry.async_remove(entry1_restored.entity_id) + assert len(registry.entities) == 1 + assert len(registry.deleted_entities) == 1 + registry.async_clear_config_entry(config_entry.entry_id) + freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Re-add the entity, expect to get a new id after the purge + entry1_restored = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry + ) + assert len(registry.entities) == 2 + assert len(registry.deleted_entities) == 0 + assert entry1.id != entry1_restored.id + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 13 + assert update_events[0] == {"action": "create", "entity_id": "light.hue_1234"} + assert update_events[1] == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[2]["action"] == "update" + assert update_events[3] == {"action": "remove", "entity_id": "light.custom_1"} + assert update_events[4] == {"action": "remove", "entity_id": "light.hue_5678"} + # Restore entities the 1st time + assert update_events[5] == {"action": "create", "entity_id": "light.hue_1234"} + assert update_events[6] == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[7] == {"action": "remove", "entity_id": "light.hue_1234"} + assert update_events[8] == {"action": "remove", "entity_id": "light.hue_5678"} + # Restore entities the 2nd time + assert update_events[9] == {"action": "create", "entity_id": "light.hue_1234"} + assert update_events[10] == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[11] == {"action": "remove", "entity_id": "light.hue_1234"} + # Restore entities the 3rd time + assert update_events[12] == {"action": "create", "entity_id": "light.hue_1234"} From 8e2ba819952a533bcaa5dfa56574fe8e3bd41bb7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 15:54:51 +0200 Subject: [PATCH 547/857] Add image platform to the template integration (#94928) * Add image platform to the template integration * Set a default name * Fix typo * Add tests * Improve test coverage * Derive content-type from fetched image --- homeassistant/components/image/__init__.py | 2 +- homeassistant/components/template/config.py | 5 + homeassistant/components/template/const.py | 1 + homeassistant/components/template/image.py | 198 ++++++ .../components/template/template_entity.py | 12 + homeassistant/helpers/template_entity.py | 13 + tests/components/template/test_image.py | 579 ++++++++++++++++++ 7 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/template/image.py create mode 100644 tests/components/template/test_image.py diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index bff9e8cc4c6..a0263587048 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -129,7 +129,7 @@ class ImageEntity(Entity): return self._attr_content_type @property - def entity_picture(self) -> str: + def entity_picture(self) -> str | None: """Return a link to the image as entity picture.""" if self._attr_entity_picture is not None: return self._attr_entity_picture diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 77eaec13da5..2261bde2659 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -16,6 +17,7 @@ from homeassistant.helpers.trigger import async_validate_trigger_config from . import ( binary_sensor as binary_sensor_platform, button as button_platform, + image as image_platform, number as number_platform, select as select_platform, sensor as sensor_platform, @@ -49,6 +51,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(BUTTON_DOMAIN): vol.All( cv.ensure_list, [button_platform.BUTTON_SCHEMA] ), + vol.Optional(IMAGE_DOMAIN): vol.All( + cv.ensure_list, [image_platform.IMAGE_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 15a4b430190..9b371125750 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -16,6 +16,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.COVER, Platform.FAN, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py new file mode 100644 index 00000000000..751c91c755b --- /dev/null +++ b/homeassistant/components/template/image.py @@ -0,0 +1,198 @@ +"""Support for image which integrates with other components.""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx +import voluptuous as vol + +from homeassistant.components.image import ( + DOMAIN as IMAGE_DOMAIN, + ImageEntity, +) +from homeassistant.const import CONF_UNIQUE_ID, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util + +from . import TriggerUpdateCoordinator +from .const import CONF_PICTURE +from .template_entity import ( + TemplateEntity, + make_template_entity_common_schema, +) +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Template Image" + +GET_IMAGE_TIMEOUT = 10 + +IMAGE_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): cv.template, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +).extend(make_template_entity_common_schema(DEFAULT_NAME).schema) + + +async def _async_create_entities( + hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None +) -> list[StateImageEntity]: + """Create the template image.""" + entities = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append(StateImageEntity(hass, definition, unique_id)) + return entities + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the template image.""" + if discovery_info is None: + _LOGGER.warning( + "Template image entities can only be configured under template:" + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerImageEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + async_add_entities( + await _async_create_entities( + hass, discovery_info["entities"], discovery_info["unique_id"] + ) + ) + + +class TemplateImage(ImageEntity): + """Base class for templated image.""" + + _last_image: bytes | None = None + _url: str | None = None + _verify_ssl: bool + + def __init__(self, verify_ssl: bool) -> None: + """Initialize the image.""" + super().__init__() + self._verify_ssl = verify_ssl + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + if self._last_image: + return self._last_image + + if not (url := self._url): + return None + + try: + async_client = get_async_client(self.hass, verify_ssl=self._verify_ssl) + response = await async_client.get( + url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True + ) + response.raise_for_status() + self._attr_content_type = response.headers["content-type"] + self._last_image = response.content + return self._last_image + except httpx.TimeoutException: + _LOGGER.error("%s: Timeout getting image from %s", self.entity_id, url) + return None + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error( + "%s: Error getting new image from %s: %s", + self.entity_id, + url, + err, + ) + return None + + +class StateImageEntity(TemplateEntity, TemplateImage): + """Representation of a template image.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: + """Initialize the image.""" + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateImage.__init__(self, config[CONF_VERIFY_SSL]) + self._url_template = config[CONF_URL] + + @property + def entity_picture(self) -> str | None: + """Return entity picture.""" + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + if self._entity_picture_template: + return TemplateEntity.entity_picture.fget(self) # type: ignore[attr-defined] + return ImageEntity.entity_picture.fget(self) # type: ignore[attr-defined] + + @callback + def _update_url(self, result): + if isinstance(result, TemplateError): + self._url = None + return + self._attr_image_last_updated = dt_util.utcnow() + self._last_image = None + self._url = result + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.add_template_attribute("_url", self._url_template, None, self._update_url) + await super().async_added_to_hass() + + +class TriggerImageEntity(TriggerEntity, TemplateImage): + """Image entity based on trigger data.""" + + _last_image: bytes | None = None + + domain = IMAGE_DOMAIN + extra_template_keys = (CONF_URL,) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: dict, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + TemplateImage.__init__(self, config[CONF_VERIFY_SSL]) + + @property + def entity_picture(self) -> str | None: + """Return entity picture.""" + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + if CONF_PICTURE in self._config: + return TriggerEntity.entity_picture.fget(self) # type: ignore[attr-defined] + return ImageEntity.entity_picture.fget(self) # type: ignore[attr-defined] + + @callback + def _process_data(self) -> None: + """Process new data.""" + super()._process_data() + self._attr_image_last_updated = dt_util.utcnow() + self._last_image = None + self._url = self._rendered.get(CONF_URL) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 87c8ec651d2..0d6d5a99748 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -18,6 +18,7 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ( # noqa: F401 pylint: disable=unused-import TEMPLATE_ENTITY_BASE_SCHEMA, TemplateEntity, + make_template_entity_base_schema, ) from .const import ( @@ -47,6 +48,17 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = vol.Schema( } ).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) + +def make_template_entity_common_schema(default_name: str) -> vol.Schema: + """Return a schema with default name.""" + return vol.Schema( + { + vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), + vol.Optional(CONF_AVAILABILITY): cv.template, + } + ).extend(make_template_entity_base_schema(default_name).schema) + + TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY = vol.Schema( { vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema( diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index c4eb8a1343d..92ea55eb642 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -65,6 +65,19 @@ TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( } ) + +def make_template_entity_base_schema(default_name: str) -> vol.Schema: + """Return a schema with default name.""" + return vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME, default=default_name): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + + TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, diff --git a/tests/components/template/test_image.py b/tests/components/template/test_image.py new file mode 100644 index 00000000000..2bf75763438 --- /dev/null +++ b/tests/components/template/test_image.py @@ -0,0 +1,579 @@ +"""The tests for the Template image platform.""" +from http import HTTPStatus +from io import BytesIO +from typing import Any + +import httpx +from PIL import Image +import pytest +import respx + +from homeassistant import setup +from homeassistant.components.input_text import ( + ATTR_VALUE as INPUT_TEXT_ATTR_VALUE, + DOMAIN as INPUT_TEXT_DOMAIN, + SERVICE_SET_VALUE as INPUT_TEXT_SERVICE_SET_VALUE, +) +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + CONF_ENTITY_ID, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import dt as dt_util + +from tests.common import assert_setup_component +from tests.typing import ClientSessionGenerator + +_DEFAULT = object() +_TEST_IMAGE = "image.template_image" +_URL_INPUT_TEXT = "input_text.url" + + +@pytest.fixture +def imgbytes_jpg(): + """Image in RAM for testing.""" + buf = BytesIO() # fake image in ram for testing. + Image.new("RGB", (1, 1)).save(buf, format="jpeg") + return bytes(buf.getbuffer()) + + +@pytest.fixture +def imgbytes2_jpg(): + """Image in RAM for testing.""" + buf = BytesIO() # fake image in ram for testing. + Image.new("RGB", (1, 1), 100).save(buf, format="jpeg") + return bytes(buf.getbuffer()) + + +async def _assert_state( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + expected_state: str, + expected_image: bytes | None, + entity_id: str = _TEST_IMAGE, + expected_content_type: str = "image/jpeg", + expected_entity_picture: Any = _DEFAULT, + expected_status: HTTPStatus = HTTPStatus.OK, +): + """Verify image's state.""" + state = hass.states.get(entity_id) + attributes = state.attributes + assert state.state == expected_state + if expected_entity_picture is _DEFAULT: + expected_entity_picture = ( + f"/api/image_proxy/{entity_id}?token={attributes['access_token']}" + ) + + assert attributes.get(ATTR_ENTITY_PICTURE) == expected_entity_picture + + client = await hass_client() + + resp = await client.get(f"/api/image_proxy/{entity_id}") + assert resp.content_type == expected_content_type + assert resp.status == expected_status + body = await resp.read() + assert body == expected_image + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_platform_config( + hass: HomeAssistant, hass_client: ClientSessionGenerator, imgbytes_jpg +) -> None: + """Test configuring under the platform key does not work.""" + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + + with assert_setup_component(1, "image"): + assert await setup.async_setup_component( + hass, + "image", + { + "image": { + "platform": "template", + "url": "{{ 'http://example.com' }}", + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_missing_optional_config( + hass: HomeAssistant, hass_client: ClientSessionGenerator, imgbytes_jpg +) -> None: + """Test: missing optional template is ok.""" + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": { + "url": "{{ 'http://example.com' }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + expected_state = dt_util.utcnow().isoformat() + await _assert_state(hass, hass_client, expected_state, imgbytes_jpg) + assert respx.get("http://example.com").call_count == 1 + + # Check the image is not refetched + await _assert_state(hass, hass_client, expected_state, imgbytes_jpg) + assert respx.get("http://example.com").call_count == 1 + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_multiple_configs( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + imgbytes_jpg, + imgbytes2_jpg, +) -> None: + """Test: multiple image entities get created.""" + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + respx.get("http://example2.com").respond( + stream=imgbytes2_jpg, content_type="image/png" + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": [ + { + "url": "{{ 'http://example.com' }}", + }, + { + "url": "{{ 'http://example2.com' }}", + }, + ] + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + expected_state = dt_util.utcnow().isoformat() + await _assert_state(hass, hass_client, expected_state, imgbytes_jpg) + await _assert_state( + hass, + hass_client, + expected_state, + imgbytes2_jpg, + f"{_TEST_IMAGE}_2", + expected_content_type="image/png", + ) + + +async def test_missing_required_keys(hass: HomeAssistant) -> None: + """Test: missing required fields will fail.""" + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": { + "name": "a name", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all("image") == [] + + +async def test_unique_id(hass: HomeAssistant) -> None: + """Test unique_id configuration.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "b", + "image": { + "url": "http://example.com", + "unique_id": "a", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + ent_reg = async_get(hass) + entry = ent_reg.async_get(_TEST_IMAGE) + assert entry + assert entry.unique_id == "b-a" + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_custom_entity_picture( + hass: HomeAssistant, hass_client: ClientSessionGenerator, imgbytes_jpg +) -> None: + """Test custom entity picture.""" + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": { + "url": "http://example.com", + "picture": "http://example2.com", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + expected_state = dt_util.utcnow().isoformat() + await _assert_state( + hass, + hass_client, + expected_state, + imgbytes_jpg, + expected_entity_picture="http://example2.com", + ) + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_http_error( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test handling http error.""" + respx.get("http://example.com").respond(HTTPStatus.NOT_FOUND) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": { + "url": "http://example.com", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + expected_state = dt_util.utcnow().isoformat() + await _assert_state( + hass, + hass_client, + expected_state, + b"500: Internal Server Error", + expected_status=HTTPStatus.INTERNAL_SERVER_ERROR, + expected_content_type="text/plain", + ) + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_http_timeout( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test handling http timeout.""" + respx.get("http://example.com").side_effect = httpx.TimeoutException + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": { + "url": "http://example.com", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + expected_state = dt_util.utcnow().isoformat() + await _assert_state( + hass, + hass_client, + expected_state, + b"500: Internal Server Error", + expected_status=HTTPStatus.INTERNAL_SERVER_ERROR, + expected_content_type="text/plain", + ) + + +@respx.mock +async def test_template_error( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test handling template error.""" + respx.get("http://example.com").side_effect = httpx.TimeoutException + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": { + "url": "{{ no_such_variable.url }}", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await _assert_state( + hass, + hass_client, + STATE_UNKNOWN, + b"500: Internal Server Error", + expected_status=HTTPStatus.INTERNAL_SERVER_ERROR, + expected_content_type="text/plain", + ) + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_templates_with_entities( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + imgbytes_jpg, + imgbytes2_jpg, +) -> None: + """Test templates with values from other entities.""" + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + respx.get("http://example2.com").respond( + stream=imgbytes2_jpg, content_type="image/png" + ) + + with assert_setup_component(1, "input_text"): + assert await setup.async_setup_component( + hass, + "input_text", + { + "input_text": { + "url": { + "initial": "http://example.com", + "name": "url", + }, + } + }, + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "image": { + "url": f"{{{{ states('{_URL_INPUT_TEXT}') }}}}", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + expected_state = dt_util.utcnow().isoformat() + await _assert_state(hass, hass_client, expected_state, imgbytes_jpg) + assert respx.get("http://example.com").call_count == 1 + + # Check the image is not refetched + await _assert_state(hass, hass_client, expected_state, imgbytes_jpg) + assert respx.get("http://example.com").call_count == 1 + + await hass.services.async_call( + INPUT_TEXT_DOMAIN, + INPUT_TEXT_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _URL_INPUT_TEXT, INPUT_TEXT_ATTR_VALUE: "http://example2.com"}, + blocking=True, + ) + await hass.async_block_till_done() + await _assert_state( + hass, + hass_client, + expected_state, + imgbytes2_jpg, + expected_content_type="image/png", + ) + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_trigger_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + imgbytes_jpg, + imgbytes2_jpg, +) -> None: + """Test trigger based template image.""" + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + respx.get("http://example2.com").respond( + stream=imgbytes2_jpg, content_type="image/png" + ) + + assert await setup.async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "image": [ + { + "url": "{{ trigger.event.data.url }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No image is loaded, expect error + await _assert_state( + hass, + hass_client, + "unknown", + b"500: Internal Server Error", + expected_status=HTTPStatus.INTERNAL_SERVER_ERROR, + expected_content_type="text/plain", + ) + + hass.bus.async_fire("test_event", {"url": "http://example.com"}) + await hass.async_block_till_done() + expected_state = dt_util.utcnow().isoformat() + await _assert_state(hass, hass_client, expected_state, imgbytes_jpg) + assert respx.get("http://example.com").call_count == 1 + + # Check the image is not refetched + await _assert_state(hass, hass_client, expected_state, imgbytes_jpg) + assert respx.get("http://example.com").call_count == 1 + + hass.bus.async_fire("test_event", {"url": "http://example2.com"}) + await hass.async_block_till_done() + await _assert_state( + hass, + hass_client, + expected_state, + imgbytes2_jpg, + expected_content_type="image/png", + ) + + +@respx.mock +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_trigger_image_custom_entity_picture( + hass: HomeAssistant, hass_client: ClientSessionGenerator, imgbytes_jpg +) -> None: + """Test trigger based template image with custom entity picture.""" + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + + assert await setup.async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "image": [ + { + "url": "{{ trigger.event.data.url }}", + "picture": "http://example2.com", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No image is loaded, expect error + await _assert_state( + hass, + hass_client, + "unknown", + b"500: Internal Server Error", + expected_status=HTTPStatus.INTERNAL_SERVER_ERROR, + expected_entity_picture="http://example2.com", + expected_content_type="text/plain", + ) + + hass.bus.async_fire("test_event", {"url": "http://example.com"}) + await hass.async_block_till_done() + expected_state = dt_util.utcnow().isoformat() + await _assert_state( + hass, + hass_client, + expected_state, + imgbytes_jpg, + expected_entity_picture="http://example2.com", + ) From 89c9e72768cacd8ef1dfd094aa1b5cab5d17aff0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 16:59:43 +0200 Subject: [PATCH 548/857] Use entity registry id in alarm_control_panel device actions (#95241) --- .../alarm_control_panel/device_action.py | 18 +- .../components/device_automation/helpers.py | 15 +- .../alarm_control_panel/test_device_action.py | 222 ++++++++++++++---- 3 files changed, 205 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index de4f3df257a..e453be88934 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -5,6 +5,7 @@ from typing import Final import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -44,15 +45,22 @@ ACTION_TYPES: Final[set[str]] = { "trigger", } -ACTION_SCHEMA: Final = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA: Final = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional(CONF_CODE): cv.string, } ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -70,7 +78,7 @@ async def async_get_actions( base_action: dict = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } # Add actions for each entity that belongs to this integration @@ -124,7 +132,9 @@ async def async_get_action_capabilities( """List action capabilities.""" # We need to refer to the state directly because ATTR_CODE_ARM_REQUIRED is not a # capability attribute - state = hass.states.get(config[CONF_ENTITY_ID]) + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) + state = hass.states.get(entity_id) if entity_id else None code_required = state.attributes.get(ATTR_CODE_ARM_REQUIRED) if state else False if config[CONF_TYPE] == "trigger" or ( diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 8e000733536..69c8872b217 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,7 +5,7 @@ from typing import cast import voluptuous as vol -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType @@ -25,7 +25,14 @@ STATIC_VALIDATOR = { DeviceAutomationType.TRIGGER: "TRIGGER_SCHEMA", } -TOGGLE_ENTITY_DOMAINS = {"fan", "humidifier", "light", "remote", "switch"} +ENTITY_PLATFORMS = { + Platform.ALARM_CONTROL_PANEL.value, + Platform.FAN.value, + Platform.HUMIDIFIER.value, + Platform.LIGHT.value, + Platform.REMOTE.value, + Platform.SWITCH.value, +} async def async_validate_device_automation_config( @@ -45,10 +52,10 @@ async def async_validate_device_automation_config( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) - # Bypass checks for toggle entity domains + # Bypass checks for entity platforms if ( automation_type == DeviceAutomationType.ACTION - and validated_config[CONF_DOMAIN] in TOGGLE_ENTITY_DOMAINS + and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS ): return cast( ConfigType, diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 822076240c6..8ba196de545 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -102,7 +102,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -113,13 +113,12 @@ async def test_get_actions( hass.states.async_set( f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} ) - expected_actions = [] - expected_actions += [ + expected_actions = [ { "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in expected_action_types @@ -153,7 +152,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -168,7 +167,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["disarm", "arm_away"] @@ -191,7 +190,7 @@ async def test_get_actions_arm_night_only( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) hass.states.async_set( @@ -202,14 +201,14 @@ async def test_get_actions_arm_night_only( "domain": DOMAIN, "type": "arm_night", "device_id": device_entry.id, - "entity_id": "alarm_control_panel.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, { "domain": DOMAIN, "type": "disarm", "device_id": device_entry.id, - "entity_id": "alarm_control_panel.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, ] @@ -266,6 +265,54 @@ async def test_get_action_capabilities( assert capabilities == expected_capabilities[action["type"]] +async def test_get_action_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test we get the expected capabilities from a sensor trigger.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["no_arm_code"].unique_id, + device_id=device_entry.id, + ) + + expected_capabilities = { + "arm_away": {"extra_fields": []}, + "arm_home": {"extra_fields": []}, + "arm_night": {"extra_fields": []}, + "arm_vacation": {"extra_fields": []}, + "disarm": { + "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + }, + "trigger": {"extra_fields": []}, + } + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + assert len(actions) == 6 + assert {action["type"] for action in actions} == set(expected_capabilities) + for action in actions: + action["entity_id"] = entity_registry.async_get(action["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.ACTION, action + ) + assert capabilities == expected_capabilities[action["type"]] + + async def test_get_action_capabilities_arm_code( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -321,11 +368,77 @@ async def test_get_action_capabilities_arm_code( assert capabilities == expected_capabilities[action["type"]] -async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> None: +async def test_get_action_capabilities_arm_code_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test we get the expected capabilities from a sensor trigger.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["arm_code"].unique_id, + device_id=device_entry.id, + ) + + expected_capabilities = { + "arm_away": { + "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + }, + "arm_home": { + "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + }, + "arm_night": { + "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + }, + "arm_vacation": { + "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + }, + "disarm": { + "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + }, + "trigger": {"extra_fields": []}, + } + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + assert len(actions) == 6 + assert {action["type"] for action in actions} == set(expected_capabilities) + for action in actions: + action["entity_id"] = entity_registry.async_get(action["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.ACTION, action + ) + assert capabilities == expected_capabilities[action["type"]] + + +async def test_action( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: """Test for turn_on and turn_off actions.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() + entity_entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["no_arm_code"].unique_id, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -339,7 +452,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "alarm_control_panel.alarm_no_arm_code", + "entity_id": entity_entry.id, "type": "arm_away", }, }, @@ -351,7 +464,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "alarm_control_panel.alarm_no_arm_code", + "entity_id": entity_entry.id, "type": "arm_home", }, }, @@ -363,7 +476,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "alarm_control_panel.alarm_no_arm_code", + "entity_id": entity_entry.id, "type": "arm_night", }, }, @@ -375,7 +488,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "alarm_control_panel.alarm_no_arm_code", + "entity_id": entity_entry.id, "type": "arm_vacation", }, }, @@ -384,7 +497,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "alarm_control_panel.alarm_no_arm_code", + "entity_id": entity_entry.id, "type": "disarm", "code": "1234", }, @@ -397,7 +510,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "alarm_control_panel.alarm_no_arm_code", + "entity_id": entity_entry.id, "type": "trigger", }, }, @@ -407,48 +520,73 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() - assert ( - hass.states.get("alarm_control_panel.alarm_no_arm_code").state == STATE_UNKNOWN - ) + assert hass.states.get(entity_entry.entity_id).state == STATE_UNKNOWN hass.bus.async_fire("test_event_arm_away") await hass.async_block_till_done() - assert ( - hass.states.get("alarm_control_panel.alarm_no_arm_code").state - == STATE_ALARM_ARMED_AWAY - ) + assert hass.states.get(entity_entry.entity_id).state == STATE_ALARM_ARMED_AWAY hass.bus.async_fire("test_event_arm_home") await hass.async_block_till_done() - assert ( - hass.states.get("alarm_control_panel.alarm_no_arm_code").state - == STATE_ALARM_ARMED_HOME - ) + assert hass.states.get(entity_entry.entity_id).state == STATE_ALARM_ARMED_HOME hass.bus.async_fire("test_event_arm_vacation") await hass.async_block_till_done() - assert ( - hass.states.get("alarm_control_panel.alarm_no_arm_code").state - == STATE_ALARM_ARMED_VACATION - ) + assert hass.states.get(entity_entry.entity_id).state == STATE_ALARM_ARMED_VACATION hass.bus.async_fire("test_event_arm_night") await hass.async_block_till_done() - assert ( - hass.states.get("alarm_control_panel.alarm_no_arm_code").state - == STATE_ALARM_ARMED_NIGHT - ) + assert hass.states.get(entity_entry.entity_id).state == STATE_ALARM_ARMED_NIGHT hass.bus.async_fire("test_event_disarm") await hass.async_block_till_done() - assert ( - hass.states.get("alarm_control_panel.alarm_no_arm_code").state - == STATE_ALARM_DISARMED - ) + assert hass.states.get(entity_entry.entity_id).state == STATE_ALARM_DISARMED hass.bus.async_fire("test_event_trigger") await hass.async_block_till_done() - assert ( - hass.states.get("alarm_control_panel.alarm_no_arm_code").state - == STATE_ALARM_TRIGGERED + assert hass.states.get(entity_entry.entity_id).state == STATE_ALARM_TRIGGERED + + +async def test_action_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + entity_entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["no_arm_code"].unique_id, ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_arm_away", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entity_entry.entity_id, + "type": "arm_away", + }, + }, + ] + }, + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get(entity_entry.entity_id).state == STATE_UNKNOWN + + hass.bus.async_fire("test_event_arm_away") + await hass.async_block_till_done() + assert hass.states.get(entity_entry.entity_id).state == STATE_ALARM_ARMED_AWAY From 537cc9ed86d1af25221f980ef0e03b52491c8b39 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 26 Jun 2023 18:04:10 +0200 Subject: [PATCH 549/857] Use new attributes in Met (#95099) --- homeassistant/components/met/const.py | 10 ++++++++++ homeassistant/components/met/weather.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index 5b2a756847e..dcc493570ba 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -10,19 +10,24 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY, ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, ) @@ -180,6 +185,9 @@ FORECAST_MAP = { ATTR_FORECAST_TIME: "datetime", ATTR_FORECAST_WIND_BEARING: "wind_bearing", ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "wind_gust", + ATTR_FORECAST_CLOUD_COVERAGE: "cloudiness", + ATTR_FORECAST_HUMIDITY: "humidity", } ATTR_MAP = { @@ -189,4 +197,6 @@ ATTR_MAP = { ATTR_WEATHER_VISIBILITY: "visibility", ATTR_WEATHER_WIND_BEARING: "wind_bearing", ATTR_WEATHER_WIND_SPEED: "wind_speed", + ATTR_WEATHER_WIND_GUST_SPEED: "wind_gust", + ATTR_WEATHER_CLOUD_COVERAGE: "cloudiness", } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a6dcb23cc47..05642c12991 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -7,10 +7,12 @@ from typing import Any from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, + ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, Forecast, WeatherEntity, @@ -174,6 +176,20 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed in native units.""" + return self.coordinator.data.current_weather_data.get( + ATTR_MAP[ATTR_WEATHER_WIND_GUST_SPEED] + ) + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage.""" + return self.coordinator.data.current_weather_data.get( + ATTR_MAP[ATTR_WEATHER_CLOUD_COVERAGE] + ) + @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" From 26016b29f7aaa130a1e681e94ff1e4102a1ef811 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:05:11 -0300 Subject: [PATCH 550/857] Add the device of the source entity in the helper entities for Threshold (#94753) --- .../components/threshold/binary_sensor.py | 41 +++++++++++++++- .../threshold/test_binary_sensor.py | 47 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 538655ec0ce..f7b8c9c097c 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -22,7 +22,12 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,6 +78,28 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) + + source_entity = registry.async_get(entity_id) + dev_reg = dr.async_get(hass) + # Resolve source entity device + if ( + (source_entity is not None) + and (source_entity.device_id is not None) + and ( + ( + device := dev_reg.async_get( + device_id=source_entity.device_id, + ) + ) + is not None + ) + ): + device_info = DeviceInfo( + identifiers=device.identifiers, + ) + else: + device_info = None + hysteresis = config_entry.options[CONF_HYSTERESIS] lower = config_entry.options[CONF_LOWER] name = config_entry.title @@ -82,7 +109,15 @@ async def async_setup_entry( async_add_entities( [ ThresholdSensor( - hass, entity_id, name, lower, upper, hysteresis, device_class, unique_id + hass, + entity_id, + name, + lower, + upper, + hysteresis, + device_class, + unique_id, + device_info=device_info, ) ] ) @@ -138,9 +173,11 @@ class ThresholdSensor(BinarySensorEntity): hysteresis: float, device_class: BinarySensorDeviceClass | None, unique_id: str | None, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the Threshold sensor.""" self._attr_unique_id = unique_id + self._attr_device_info = device_info self._entity_id = entity_id self._name = name if lower is not None: diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index 9e11195d878..2180d0aed7f 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -2,6 +2,7 @@ import pytest +from homeassistant.components.threshold.const import DOMAIN from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, @@ -9,8 +10,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + async def test_sensor_upper(hass: HomeAssistant) -> None: """Test if source is above threshold.""" @@ -585,3 +589,46 @@ async def test_sensor_no_lower_upper( await hass.async_block_till_done() assert "Lower or Upper thresholds not provided" in caplog.text + + +async def test_device_id(hass: HomeAssistant) -> None: + """Test for source entity device for Threshold.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + source_config_entry = MockConfigEntry() + source_device_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + ) + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test_source", + "hysteresis": 0.0, + "lower": -2.0, + "name": "Threshold", + "upper": None, + }, + title="Threshold", + ) + + utility_meter_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + utility_meter_entity = entity_registry.async_get("binary_sensor.threshold") + assert utility_meter_entity is not None + assert utility_meter_entity.device_id == source_entity.device_id From 403496eb92dd4e262c7122be71983a88c4c422f8 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:06:25 -0300 Subject: [PATCH 551/857] Add the device of the source entity in the helper entities for Derivative (#94751) * Adds the device of the original entity in the helper entities for Derivative * Update * Update --- homeassistant/components/derivative/sensor.py | 31 +++++++++++- tests/components/derivative/test_sensor.py | 48 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index e1cc278137c..793e8edc769 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -19,7 +19,12 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -86,6 +91,27 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE] ) + source_entity = registry.async_get(source_entity_id) + dev_reg = dr.async_get(hass) + # Resolve source entity device + if ( + (source_entity is not None) + and (source_entity.device_id is not None) + and ( + ( + device := dev_reg.async_get( + device_id=source_entity.device_id, + ) + ) + is not None + ) + ): + device_info = DeviceInfo( + identifiers=device.identifiers, + ) + else: + device_info = None + unit_prefix = config_entry.options[CONF_UNIT_PREFIX] if unit_prefix == "none": unit_prefix = None @@ -99,6 +125,7 @@ async def async_setup_entry( unit_of_measurement=None, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], + device_info=device_info, ) async_add_entities([derivative_sensor]) @@ -142,9 +169,11 @@ class DerivativeSensor(RestoreSensor, SensorEntity): unit_prefix: str | None, unit_time: UnitOfTime, unique_id: str | None, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the derivative sensor.""" self._attr_unique_id = unique_id + self._attr_device_info = device_info self._sensor_source_id = source_entity self._round_digits = round_digits self._state: float | int | Decimal = 0 diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 8260e5a0ada..513e9597572 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -5,11 +5,15 @@ import random from freezegun import freeze_time +from homeassistant.components.derivative.const import DOMAIN from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import MockConfigEntry + async def test_state(hass: HomeAssistant) -> None: """Test derivative sensor state.""" @@ -342,3 +346,47 @@ async def test_suffix(hass: HomeAssistant) -> None: # Testing a network speed sensor at 1000 bytes/s over 10s = 10kbytes/s2 assert round(float(state.state), config["sensor"]["round"]) == 0.0 + + +async def test_device_id(hass: HomeAssistant) -> None: + """Test for source entity device for Derivative.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + source_config_entry = MockConfigEntry() + source_device_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + ) + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + derivative_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "Derivative", + "round": 1.0, + "source": "sensor.test_source", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="Derivative", + ) + + derivative_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity = entity_registry.async_get("sensor.derivative") + assert derivative_entity is not None + assert derivative_entity.device_id == source_entity.device_id From 39229ce098797257a57f7d5bf2f6af93b369480b Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:08:13 -0300 Subject: [PATCH 552/857] Add the device of the source entity in the helper entities for Utility Meter (#94734) Co-authored-by: Franck Nijhof --- .../components/utility_meter/select.py | 52 +++++++++++- .../components/utility_meter/sensor.py | 32 +++++++- tests/components/utility_meter/test_sensor.py | 79 ++++++++++++++++++- 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 55845569af0..cf0e6e91ffb 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -7,11 +7,22 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_METER, CONF_TARIFFS, DATA_UTILITY, TARIFF_ICON +from .const import ( + CONF_METER, + CONF_SOURCE_SENSOR, + CONF_TARIFFS, + DATA_UTILITY, + TARIFF_ICON, +) _LOGGER = logging.getLogger(__name__) @@ -26,7 +37,35 @@ async def async_setup_entry( tariffs: list[str] = config_entry.options[CONF_TARIFFS] unique_id = config_entry.entry_id - tariff_select = TariffSelect(name, tariffs, unique_id) + + registry = er.async_get(hass) + source_entity = registry.async_get(config_entry.options[CONF_SOURCE_SENSOR]) + dev_reg = dr.async_get(hass) + # Resolve source entity device + if ( + (source_entity is not None) + and (source_entity.device_id is not None) + and ( + ( + device := dev_reg.async_get( + device_id=source_entity.device_id, + ) + ) + is not None + ) + ): + device_info = DeviceInfo( + identifiers=device.identifiers, + ) + else: + device_info = None + + tariff_select = TariffSelect( + name, + tariffs, + unique_id, + device_info=device_info, + ) async_add_entities([tariff_select]) @@ -63,10 +102,17 @@ async def async_setup_platform( class TariffSelect(SelectEntity, RestoreEntity): """Representation of a Tariff selector.""" - def __init__(self, name, tariffs, unique_id): + def __init__( + self, + name, + tariffs, + unique_id, + device_info: DeviceInfo | None = None, + ) -> None: """Initialize a tariff selector.""" self._attr_name = name self._attr_unique_id = unique_id + self._attr_device_info = device_info self._current_tariff: str | None = None self._tariffs = tariffs self._attr_icon = TARIFF_ICON diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 7ad5afaa503..5f426fc49c5 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -28,8 +28,13 @@ from homeassistant.const import ( UnitOfEnergy, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import entity_platform, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_point_in_time, @@ -120,6 +125,27 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) + source_entity = registry.async_get(source_entity_id) + dev_reg = dr.async_get(hass) + # Resolve source entity device + if ( + (source_entity is not None) + and (source_entity.device_id is not None) + and ( + ( + device := dev_reg.async_get( + device_id=source_entity.device_id, + ) + ) + is not None + ) + ): + device_info = DeviceInfo( + identifiers=device.identifiers, + ) + else: + device_info = None + cron_pattern = None delta_values = config_entry.options[CONF_METER_DELTA_VALUES] meter_offset = timedelta(days=config_entry.options[CONF_METER_OFFSET]) @@ -149,6 +175,7 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=None, unique_id=entry_id, + device_info=device_info, ) meters.append(meter_sensor) hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) @@ -168,6 +195,7 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=tariff, unique_id=f"{entry_id}_{tariff}", + device_info=device_info, ) meters.append(meter_sensor) hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) @@ -341,9 +369,11 @@ class UtilityMeterSensor(RestoreSensor): tariff, unique_id, suggested_entity_id=None, + device_info=None, ): """Initialize the Utility Meter sensor.""" self._attr_unique_id = unique_id + self._attr_device_info = device_info self.entity_id = suggested_entity_id self._parent_meter = parent_meter self._sensor_source_id = source_entity diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 65892ae376a..1e26d5e211a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -41,7 +41,7 @@ from homeassistant.const import ( UnitOfEnergy, ) from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -1458,3 +1458,80 @@ def test_calculate_adjustment_invalid_new_state( new_state: State = State(entity_id="sensor.test", state="unknown") assert mock_sensor.calculate_adjustment(None, new_state) is None assert "Invalid state unknown" in caplog.text + + +async def test_device_id(hass: HomeAssistant) -> None: + """Test for source entity device for Utility Meter.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + source_config_entry = MockConfigEntry() + source_device_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + ) + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Energy", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test_source", + "tariffs": ["peak", "offpeak"], + }, + title="Energy", + ) + + utility_meter_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + utility_meter_entity = entity_registry.async_get("sensor.energy_peak") + assert utility_meter_entity is not None + assert utility_meter_entity.device_id == source_entity.device_id + + utility_meter_entity = entity_registry.async_get("sensor.energy_offpeak") + assert utility_meter_entity is not None + assert utility_meter_entity.device_id == source_entity.device_id + + utility_meter_no_tariffs_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Energy", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test_source", + "tariffs": [], + }, + title="Energy", + ) + + utility_meter_no_tariffs_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup( + utility_meter_no_tariffs_config_entry.entry_id + ) + await hass.async_block_till_done() + + utility_meter_no_tariffs_entity = entity_registry.async_get("sensor.energy") + assert utility_meter_no_tariffs_entity is not None + assert utility_meter_no_tariffs_entity.device_id == source_entity.device_id From de1b5626e12c16a4439e7e2f9c6aa6bb6549507e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 26 Jun 2023 18:11:57 +0200 Subject: [PATCH 553/857] Set explicit None for entity name in Overkiz when using device name (#95238) --- homeassistant/components/overkiz/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 6306a11acf0..f1e3d96a219 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -18,6 +18,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): """Representation of an Overkiz device entity.""" _attr_has_entity_name = True + _attr_name: str | None = None def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator From e77a06412a948c81de52d667dee68a45986b7258 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 18:13:48 +0200 Subject: [PATCH 554/857] Use entity registry id in humidifier device conditions (#95256) --- .../components/humidifier/device_condition.py | 23 +- .../humidifier/test_device_condition.py | 223 +++++++++++++++++- 2 files changed, 236 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index 05812e35a36..c2c0378a746 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -3,7 +3,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, + toggle_entity, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -32,7 +35,7 @@ TOGGLE_CONDITION = toggle_entity.CONDITION_SCHEMA.extend( MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "is_mode", vol.Required(ATTR_MODE): str, } @@ -61,7 +64,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "is_mode", } ) @@ -79,11 +82,15 @@ def async_condition_from_config( else: return toggle_entity.async_condition_from_config(hass, config) + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - state = hass.states.get(config[ATTR_ENTITY_ID]) return ( - state is not None and state.attributes.get(attribute) == config[attribute] + entity_id is not None + and (state := hass.states.get(entity_id)) is not None + and state.attributes.get(attribute) == config[attribute] ) return test_is_state @@ -99,9 +106,11 @@ async def async_get_condition_capabilities( if condition_type == "is_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_AVAILABLE_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] ) except HomeAssistantError: modes = [] diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index 22dfc5c31d5..bf8eb98f456 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -89,7 +89,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": entity_entry.entity_id, + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in expected_condition_types @@ -206,7 +206,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "is_mode", "mode": "away", } @@ -253,6 +253,53 @@ async def test_if_state( assert len(calls) == 3 +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_mode", + "mode": "away", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_mode - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "is_mode - event - test_event1" + + @pytest.mark.parametrize( ( "set_state", @@ -403,6 +450,176 @@ async def test_capabilities( capabilities_state, ) + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entity_entry.id, + "type": condition, + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert ( + voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) + == expected_capabilities + ) + + +@pytest.mark.parametrize( + ( + "set_state", + "capabilities_reg", + "capabilities_state", + "condition", + "expected_capabilities", + ), + [ + ( + False, + {}, + {}, + "is_mode", + [ + { + "name": "mode", + "options": [], + "required": True, + "type": "select", + } + ], + ), + ( + False, + {const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY]}, + {}, + "is_mode", + [ + { + "name": "mode", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ], + ), + ( + False, + {}, + {}, + "is_off", + [ + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + } + ], + ), + ( + False, + {}, + {}, + "is_on", + [ + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + } + ], + ), + ( + True, + {}, + {}, + "is_mode", + [ + { + "name": "mode", + "options": [], + "required": True, + "type": "select", + } + ], + ), + ( + True, + {}, + {const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY]}, + "is_mode", + [ + { + "name": "mode", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ], + ), + ( + True, + {}, + {}, + "is_off", + [ + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + } + ], + ), + ( + True, + {}, + {}, + "is_on", + [ + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + } + ], + ), + ], +) +async def test_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + set_state, + capabilities_reg, + capabilities_state, + condition, + expected_capabilities, +) -> None: + """Test getting capabilities.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + capabilities=capabilities_reg, + ) + if set_state: + hass.states.async_set( + entity_entry.entity_id, + STATE_ON, + capabilities_state, + ) + capabilities = await device_condition.async_get_condition_capabilities( hass, { @@ -441,7 +658,7 @@ async def test_capabilities_missing_entity( { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": "0123456789", "type": condition, }, ) From 02ad93db5338b68172869474e07718c96f9ef339 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 18:14:23 +0200 Subject: [PATCH 555/857] Use entity registry id in cover device conditions (#95253) --- .../components/cover/device_condition.py | 14 +- .../components/cover/test_device_condition.py | 130 +++++++++++++++--- 2 files changed, 119 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 6144bdb6dbf..2aa0a1dd2fb 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -4,7 +4,6 @@ from __future__ import annotations import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_ABOVE, CONF_BELOW, CONF_CONDITION, @@ -43,7 +42,7 @@ STATE_CONDITION_TYPES = {"is_open", "is_closed", "is_opening", "is_closing"} POSITION_CONDITION_SCHEMA = vol.All( DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(POSITION_CONDITION_TYPES), vol.Optional(CONF_ABOVE): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) @@ -58,7 +57,7 @@ POSITION_CONDITION_SCHEMA = vol.All( STATE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(STATE_CONDITION_TYPES), } ) @@ -86,7 +85,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if supports_open_close: @@ -127,6 +126,9 @@ def async_condition_from_config( hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) + if config[CONF_TYPE] in STATE_CONDITION_TYPES: if config[CONF_TYPE] == "is_open": state = STATE_OPEN @@ -139,7 +141,7 @@ def async_condition_from_config( def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state @@ -156,7 +158,7 @@ def async_condition_from_config( ) -> bool: """Return whether the criteria are met.""" return condition.async_numeric_state( - hass, config[ATTR_ENTITY_ID], max_pos, min_pos, attribute=position_attr + hass, entity_id, max_pos, min_pos, attribute=position_attr ) return check_numeric_state diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index 534c2b027a1..bfde3a0b514 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -89,7 +89,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -109,7 +109,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in expected_condition_types @@ -143,7 +143,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -158,7 +158,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_open", "is_closed", "is_opening", "is_closing"] @@ -203,6 +203,43 @@ async def test_get_condition_capabilities( assert capabilities == {"extra_fields": []} +async def test_get_condition_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test we get the expected capabilities from a cover condition.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[0] + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + assert len(conditions) == 4 + for condition in conditions: + condition["entity_id"] = entity_registry.async_get( + condition["entity_id"] + ).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.CONDITION, condition + ) + assert capabilities == {"extra_fields": []} + + async def test_get_condition_capabilities_set_pos( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -317,9 +354,13 @@ async def test_get_condition_capabilities_set_tilt_pos( assert capabilities == {"extra_fields": []} -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" - hass.states.async_set("cover.entity", STATE_OPEN) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_OPEN) assert await async_setup_component( hass, @@ -333,7 +374,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "is_open", } ], @@ -355,7 +396,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "is_closed", } ], @@ -377,7 +418,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "is_opening", } ], @@ -399,7 +440,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "is_closing", } ], @@ -423,21 +464,21 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_open - event - test_event1" - hass.states.async_set("cover.entity", STATE_CLOSED) + hass.states.async_set(entry.entity_id, STATE_CLOSED) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_closed - event - test_event2" - hass.states.async_set("cover.entity", STATE_OPENING) + hass.states.async_set(entry.entity_id, STATE_OPENING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event3") await hass.async_block_till_done() assert len(calls) == 3 assert calls[2].data["some"] == "is_opening - event - test_event3" - hass.states.async_set("cover.entity", STATE_CLOSING) + hass.states.async_set(entry.entity_id, STATE_CLOSING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event4") await hass.async_block_till_done() @@ -445,8 +486,54 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert calls[3].data["some"] == "is_closing - event - test_event4" +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_OPEN) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_open", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "is_open " + "- {{ trigger.platform }} " + "- {{ trigger.event.event_type }}" + ) + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_open - event - test_event1" + + async def test_if_position( hass: HomeAssistant, + entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, @@ -458,6 +545,8 @@ async def test_if_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + entry = entity_registry.async_get(ent.entity_id) + assert await async_setup_component( hass, automation.DOMAIN, @@ -471,7 +560,7 @@ async def test_if_position( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "is_position", "above": 45, }, @@ -505,7 +594,7 @@ async def test_if_position( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "is_position", "below": 90, } @@ -528,7 +617,7 @@ async def test_if_position( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "is_position", "above": 45, "below": 90, @@ -597,6 +686,7 @@ async def test_if_position( async def test_if_tilt_position( hass: HomeAssistant, + entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, @@ -608,6 +698,8 @@ async def test_if_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + entry = entity_registry.async_get(ent.entity_id) + assert await async_setup_component( hass, automation.DOMAIN, @@ -621,7 +713,7 @@ async def test_if_tilt_position( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "is_tilt_position", "above": 45, }, @@ -655,7 +747,7 @@ async def test_if_tilt_position( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "is_tilt_position", "below": 90, } @@ -678,7 +770,7 @@ async def test_if_tilt_position( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": ent.entity_id, + "entity_id": entry.id, "type": "is_tilt_position", "above": 45, "below": 90, From f0493b22d4027496a4df5ec5e007fd753cd3493c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 18:17:27 +0200 Subject: [PATCH 556/857] Use entity registry id in binary_sensor device conditions (#95251) --- .../binary_sensor/device_condition.py | 4 +- .../binary_sensor/test_device_condition.py | 130 +++++++++++++++--- 2 files changed, 113 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index 4da9bd45670..81d2ebf26a2 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -256,7 +256,7 @@ ENTITY_CONDITIONS = { CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(IS_OFF + IS_ON), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -287,7 +287,7 @@ async def async_get_conditions( **template, "condition": "device", "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": DOMAIN, } for template in templates diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index d19a761ef35..b25ab787791 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -45,6 +45,7 @@ async def test_get_conditions( platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + binary_sensor_entries = {} config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -53,7 +54,7 @@ async def test_get_conditions( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) for device_class in BinarySensorDeviceClass: - entity_registry.async_get_or_create( + binary_sensor_entries[device_class] = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES[device_class].unique_id, @@ -66,7 +67,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition["type"], "device_id": device_entry.id, - "entity_id": platform.ENTITIES[device_class].entity_id, + "entity_id": binary_sensor_entries[device_class].id, "metadata": {"secondary": False}, } for device_class in BinarySensorDeviceClass @@ -101,7 +102,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -115,7 +116,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_on", "is_off"] @@ -138,15 +139,15 @@ async def test_get_conditions_no_state( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_ids = {} + binary_sensor_entries = {} for device_class in BinarySensorDeviceClass: - entity_ids[device_class] = entity_registry.async_get_or_create( + binary_sensor_entries[device_class] = entity_registry.async_get_or_create( DOMAIN, "test", f"5678_{device_class}", device_id=device_entry.id, original_device_class=device_class, - ).entity_id + ) await hass.async_block_till_done() @@ -156,7 +157,7 @@ async def test_get_conditions_no_state( "domain": DOMAIN, "type": condition["type"], "device_id": device_entry.id, - "entity_id": entity_ids[device_class], + "entity_id": binary_sensor_entries[device_class].id, "metadata": {"secondary": False}, } for device_class in BinarySensorDeviceClass @@ -198,8 +199,44 @@ async def test_get_condition_capabilities( assert capabilities == expected_capabilities +async def test_get_condition_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we get the expected capabilities from a binary_sensor condition.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + for condition in conditions: + condition["entity_id"] = entity_registry.async_get( + condition["entity_id"] + ).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.CONDITION, condition + ) + assert capabilities == expected_capabilities + + async def test_if_state( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -208,7 +245,7 @@ async def test_if_state( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() - sensor1 = platform.ENTITIES["battery"] + entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) assert await async_setup_component( hass, @@ -222,7 +259,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "is_bat_low", } ], @@ -241,7 +278,7 @@ async def test_if_state( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "is_not_bat_low", } ], @@ -257,7 +294,7 @@ async def test_if_state( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -265,7 +302,7 @@ async def test_if_state( assert len(calls) == 1 assert calls[0].data["some"] == "is_on event - test_event1" - hass.states.async_set(sensor1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -273,8 +310,63 @@ async def test_if_state( assert calls[1].data["some"] == "is_off event - test_event2" +async def test_if_state_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off conditions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_bat_low", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(entry.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + async def test_if_fires_on_for_condition( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for firing if condition is on with delay.""" point1 = dt_util.utcnow() @@ -287,7 +379,7 @@ async def test_if_fires_on_for_condition( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() - sensor1 = platform.ENTITIES["battery"] + entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) with freeze_time(point1) as time_freeze: assert await async_setup_component( @@ -301,7 +393,7 @@ async def test_if_fires_on_for_condition( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "is_not_bat_low", "for": {"seconds": 5}, }, @@ -319,7 +411,7 @@ async def test_if_fires_on_for_condition( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -332,7 +424,7 @@ async def test_if_fires_on_for_condition( await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 From 91e6e918c35d0213c6a885773b5dd304f1255b52 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 18:22:44 +0200 Subject: [PATCH 557/857] Code tidyness for Dexcom (#95232) --- homeassistant/components/dexcom/sensor.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index bf53022b43b..2564b5d1e84 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -20,10 +20,13 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] username = config_entry.data[CONF_USERNAME] unit_of_measurement = config_entry.options[CONF_UNIT_OF_MEASUREMENT] - sensors: list[SensorEntity] = [] - sensors.append(DexcomGlucoseTrendSensor(coordinator, username)) - sensors.append(DexcomGlucoseValueSensor(coordinator, username, unit_of_measurement)) - async_add_entities(sensors, False) + async_add_entities( + [ + DexcomGlucoseTrendSensor(coordinator, username), + DexcomGlucoseValueSensor(coordinator, username, unit_of_measurement), + ], + False, + ) class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): From 9e3706e3b9746bedcf27b8b37cf68fdd98a84e9a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 18:24:14 +0200 Subject: [PATCH 558/857] Move Aurora entity to separate file (#95245) --- .coveragerc | 1 + homeassistant/components/aurora/__init__.py | 37 -------------- .../components/aurora/binary_sensor.py | 2 +- homeassistant/components/aurora/entity.py | 48 +++++++++++++++++++ homeassistant/components/aurora/sensor.py | 2 +- 5 files changed, 51 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/aurora/entity.py diff --git a/.coveragerc b/.coveragerc index 44e77956aef..1dc9dd79904 100644 --- a/.coveragerc +++ b/.coveragerc @@ -91,6 +91,7 @@ omit = homeassistant/components/aurora/__init__.py homeassistant/components/aurora/binary_sensor.py homeassistant/components/aurora/coordinator.py + homeassistant/components/aurora/entity.py homeassistant/components/aurora/sensor.py homeassistant/components/avea/light.py homeassistant/components/avion/light.py diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 50aff860e9f..db054910d9a 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -8,14 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, -) from .const import ( - ATTRIBUTION, AURORA_API, CONF_THRESHOLD, COORDINATOR, @@ -76,34 +70,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): - """Implementation of the base Aurora Entity.""" - - _attr_attribution = ATTRIBUTION - - def __init__( - self, - coordinator: AuroraDataUpdateCoordinator, - name: str, - icon: str, - ) -> None: - """Initialize the Aurora Entity.""" - - super().__init__(coordinator=coordinator) - - self._attr_name = name - self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" - self._attr_icon = icon - - @property - def device_info(self) -> DeviceInfo: - """Define the device based on name.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(self.unique_id))}, - manufacturer="NOAA", - model="Aurora Visibility Sensor", - name=self.coordinator.name, - ) diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index ee2fc53691e..a0e09685a0f 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -4,8 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraEntity from .const import COORDINATOR, DOMAIN +from .entity import AuroraEntity async def async_setup_entry( diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py new file mode 100644 index 00000000000..8948ff9c43c --- /dev/null +++ b/homeassistant/components/aurora/entity.py @@ -0,0 +1,48 @@ +"""The aurora component.""" + +import logging + +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) + +from .const import ( + ATTRIBUTION, + DOMAIN, +) +from .coordinator import AuroraDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): + """Implementation of the base Aurora Entity.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: AuroraDataUpdateCoordinator, + name: str, + icon: str, + ) -> None: + """Initialize the Aurora Entity.""" + + super().__init__(coordinator=coordinator) + + self._attr_name = name + self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" + self._attr_icon = icon + + @property + def device_info(self) -> DeviceInfo: + """Define the device based on name.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(self.unique_id))}, + manufacturer="NOAA", + model="Aurora Visibility Sensor", + name=self.coordinator.name, + ) diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index de5e566e268..a5436e1e219 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -5,8 +5,8 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraEntity from .const import COORDINATOR, DOMAIN +from .entity import AuroraEntity async def async_setup_entry( From a64940cf42ef877858c1ea0f5e9c6d14223c040e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 18:25:39 +0200 Subject: [PATCH 559/857] Use shorthand attribute for EAFM (#95233) --- homeassistant/components/eafm/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 2650aa35489..ce3ee2bfbec 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -100,6 +100,7 @@ class Measurement(CoordinatorEntity, SensorEntity): """Initialise the gauge with a data instance and station.""" super().__init__(coordinator) self.key = key + self._attr_unique_id = key @property def station_name(self): @@ -126,11 +127,6 @@ class Measurement(CoordinatorEntity, SensorEntity): """Return the name of the gauge.""" return f"{self.station_name} {self.parameter_name} {self.qualifier}" - @property - def unique_id(self): - """Return the unique id of the gauge.""" - return self.key - @property def device_info(self): """Return the device info.""" From 07936884a31dc74f0caea242bfb243cf8b124f5b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 18:26:21 +0200 Subject: [PATCH 560/857] Use entity registry id in alarm_control_panel device conditions (#95250) --- .../alarm_control_panel/device_condition.py | 9 +- .../test_device_condition.py | 87 ++++++++++++++----- 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index a097aa98535..ee8cb57f568 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -58,7 +58,7 @@ CONDITION_TYPES: Final[set[str]] = { CONDITION_SCHEMA: Final = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -83,7 +83,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [ @@ -126,8 +126,11 @@ def async_condition_from_config( elif config[CONF_TYPE] == CONDITION_ARMED_CUSTOM_BYPASS: state = STATE_ALARM_ARMED_CUSTOM_BYPASS + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index e1ff9cd90e3..f1719b83d38 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -93,7 +93,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -114,7 +114,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in basic_condition_types @@ -125,7 +125,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in expected_condition_types @@ -159,7 +159,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -173,7 +173,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_disarmed", "is_triggered"] @@ -184,8 +184,12 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +) -> None: """Test for all conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -198,7 +202,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "is_triggered", } ], @@ -220,7 +224,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "is_disarmed", } ], @@ -242,7 +246,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "is_armed_home", } ], @@ -264,7 +268,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "is_armed_away", } ], @@ -286,7 +290,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "is_armed_night", } ], @@ -308,7 +312,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "is_armed_vacation", } ], @@ -330,7 +334,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "alarm_control_panel.entity", + "entity_id": entry.id, "type": "is_armed_custom_bypass", } ], @@ -348,7 +352,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: ] }, ) - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_TRIGGERED) + hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -360,7 +364,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_triggered - event - test_event1" - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_DISARMED) + hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -372,7 +376,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: assert len(calls) == 2 assert calls[1].data["some"] == "is_disarmed - event - test_event2" - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_HOME) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_HOME) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -384,7 +388,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: assert len(calls) == 3 assert calls[2].data["some"] == "is_armed_home - event - test_event3" - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_AWAY) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_AWAY) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -396,7 +400,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: assert len(calls) == 4 assert calls[3].data["some"] == "is_armed_away - event - test_event4" - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_NIGHT) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_NIGHT) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -408,7 +412,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: assert len(calls) == 5 assert calls[4].data["some"] == "is_armed_night - event - test_event5" - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_VACATION) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_VACATION) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -420,7 +424,7 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: assert len(calls) == 6 assert calls[5].data["some"] == "is_armed_vacation - event - test_event6" - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_CUSTOM_BYPASS) + hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_CUSTOM_BYPASS) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -431,3 +435,46 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: await hass.async_block_till_done() assert len(calls) == 7 assert calls[6].data["some"] == "is_armed_custom_bypass - event - test_event7" + + +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +) -> None: + """Test for all conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_triggered", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "is_triggered " + "- {{ trigger.platform }} " + "- {{ trigger.event.event_type }}" + ) + }, + }, + }, + ] + }, + ) + hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_triggered - event - test_event1" From 36ded01264c87ec411e5a378a33816ab39cc4a6a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 18:29:33 +0200 Subject: [PATCH 561/857] Add entity translations to Blink (#95138) --- .../components/blink/alarm_control_panel.py | 6 ++++-- homeassistant/components/blink/binary_sensor.py | 4 +--- homeassistant/components/blink/camera.py | 3 ++- homeassistant/components/blink/sensor.py | 8 ++++---- homeassistant/components/blink/strings.json | 12 ++++++++++++ 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 64463df723a..5d0ea67f31d 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -41,6 +41,7 @@ class BlinkSyncModule(AlarmControlPanelEntity): _attr_icon = ICON _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_name = None def __init__(self, data, name, sync): """Initialize the alarm control panel.""" @@ -48,9 +49,10 @@ class BlinkSyncModule(AlarmControlPanelEntity): self.sync = sync self._name = name self._attr_unique_id = sync.serial - self._attr_name = f"{DOMAIN} {name}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, sync.serial)}, name=name, manufacturer=DEFAULT_BRAND + identifiers={(DOMAIN, sync.serial)}, + name=f"{DOMAIN} {name}", + manufacturer=DEFAULT_BRAND, ) def update(self) -> None: diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 5a50d3f8c93..c7daf0ec1e1 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -27,17 +27,15 @@ _LOGGER = logging.getLogger(__name__) BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=TYPE_BATTERY, - name="Battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key=TYPE_CAMERA_ARMED, - name="Camera Armed", + translation_key="camera_armed", ), BinarySensorEntityDescription( key=TYPE_MOTION_DETECTED, - name="Motion Detected", device_class=BinarySensorDeviceClass.MOTION, ), ) diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index e500eb79e42..e74555f8db9 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -38,11 +38,12 @@ async def async_setup_entry( class BlinkCamera(Camera): """An implementation of a Blink Camera.""" + _attr_name = None + def __init__(self, data, name, camera): """Initialize a camera.""" super().__init__() self.data = data - self._attr_name = f"{DOMAIN} {name}" self._camera = camera self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index eae45394534..c996a90e54d 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -25,14 +25,13 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=TYPE_TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key=TYPE_WIFI_STRENGTH, - name="Wifi Signal", + translation_key="wifi_rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, @@ -57,10 +56,11 @@ async def async_setup_entry( class BlinkSensor(SensorEntity): """A Blink camera sensor.""" + _attr_has_entity_name = True + def __init__(self, data, camera, description: SensorEntityDescription) -> None: """Initialize sensors from Blink camera.""" self.entity_description = description - self._attr_name = f"{DOMAIN} {camera} {description.name}" self.data = data self._camera = data.cameras[camera] self._attr_unique_id = f"{self._camera.serial}-{description.key}" @@ -71,7 +71,7 @@ class BlinkSensor(SensorEntity): ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._camera.serial)}, - name=camera, + name=f"{DOMAIN} {camera}", manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index ae04f37714b..61c9a21af37 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -34,5 +34,17 @@ "description": "Configure Blink integration" } } + }, + "entity": { + "sensor": { + "wifi_rssi": { + "name": "Wi-Fi RSSI" + } + }, + "binary_sensor": { + "camera_armed": { + "name": "Camera armed" + } + } } } From fa03324bbd386035d084cf2944b3472a7bc9af89 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 26 Jun 2023 18:49:01 +0200 Subject: [PATCH 562/857] Remove stale dep from google translate (#95247) --- homeassistant/components/google_translate/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index c9b955543db..7074d0ed444 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -3,7 +3,6 @@ "name": "Google Translate text-to-speech", "codeowners": [], "config_flow": true, - "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", "loggers": ["gtts"], From d95c241a1adeadf37813fda255a585b0076508af Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Jun 2023 12:57:43 -0400 Subject: [PATCH 563/857] Add service response values to service descriptions (#95262) --- homeassistant/helpers/service.py | 8 ++++++++ tests/helpers/test_service.py | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3eacc8d6629..fa0e57d501c 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -31,6 +31,7 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, + SupportsResponse, callback, ) from homeassistant.exceptions import ( @@ -635,6 +636,13 @@ async def async_get_all_descriptions( if "target" in yaml_description: description["target"] = yaml_description["target"] + if ( + response := hass.services.supports_response(domain, service) + ) != SupportsResponse.NONE: + description["response"] = { + "optional": response == SupportsResponse.OPTIONAL, + } + descriptions_cache[cache_key] = description descriptions[domain][service] = description diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 845b5bacd1a..f6299312b53 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.core import Context, HomeAssistant, ServiceCall, SupportsResponse from homeassistant.helpers import ( device_registry as dr, entity_registry as er, @@ -575,8 +575,31 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: hass.services.async_register( logger.DOMAIN, "another_new_service", lambda x: None, None ) + hass.services.async_register( + logger.DOMAIN, + "service_with_optional_response", + lambda x: None, + None, + SupportsResponse.OPTIONAL, + ) + hass.services.async_register( + logger.DOMAIN, + "service_with_only_response", + lambda x: None, + None, + SupportsResponse.ONLY, + ) + descriptions = await service.async_get_all_descriptions(hass) assert "another_new_service" in descriptions[logger.DOMAIN] + assert "service_with_optional_response" in descriptions[logger.DOMAIN] + assert descriptions[logger.DOMAIN]["service_with_optional_response"][ + "response" + ] == {"optional": True} + assert "service_with_only_response" in descriptions[logger.DOMAIN] + assert descriptions[logger.DOMAIN]["service_with_only_response"]["response"] == { + "optional": False + } # Verify the cache returns the same object assert await service.async_get_all_descriptions(hass) is descriptions From 844a1ebbc6c16b612bb3ebb4ab388ed90577864f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 18:57:56 +0200 Subject: [PATCH 564/857] Add entity translations to BMW Connected Drive (#95142) --- .../bmw_connected_drive/binary_sensor.py | 16 +-- .../components/bmw_connected_drive/button.py | 10 +- .../components/bmw_connected_drive/lock.py | 2 +- .../components/bmw_connected_drive/number.py | 2 +- .../components/bmw_connected_drive/select.py | 4 +- .../components/bmw_connected_drive/sensor.py | 24 ++-- .../bmw_connected_drive/strings.json | 109 ++++++++++++++++++ .../components/bmw_connected_drive/switch.py | 4 +- 8 files changed, 140 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 6fd5f3e7693..c3be7ae189b 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -123,7 +123,7 @@ class BMWBinarySensorEntityDescription( SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( BMWBinarySensorEntityDescription( key="lids", - name="Lids", + translation_key="lids", device_class=BinarySensorDeviceClass.OPENING, icon="mdi:car-door-lock", # device class opening: On means open, Off means closed @@ -134,7 +134,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="windows", - name="Windows", + translation_key="windows", device_class=BinarySensorDeviceClass.OPENING, icon="mdi:car-door", # device class opening: On means open, Off means closed @@ -145,7 +145,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="door_lock_state", - name="Door lock state", + translation_key="door_lock_state", device_class=BinarySensorDeviceClass.LOCK, icon="mdi:car-key", # device class lock: On means unlocked, Off means locked @@ -158,7 +158,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="condition_based_services", - name="Condition based services", + translation_key="condition_based_services", device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:wrench", # device class problem: On means problem detected, Off means no problem @@ -167,7 +167,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="check_control_messages", - name="Check control messages", + translation_key="check_control_messages", device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:car-tire-alert", # device class problem: On means problem detected, Off means no problem @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( # electric BMWBinarySensorEntityDescription( key="charging_status", - name="Charging status", + translation_key="charging_status", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, icon="mdi:ev-station", # device class power: On means power detected, Off means no power @@ -185,14 +185,14 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="connection_status", - name="Connection status", + translation_key="connection_status", device_class=BinarySensorDeviceClass.PLUG, icon="mdi:car-electric", value_fn=lambda v: v.fuel_and_battery.is_charger_connected, ), BMWBinarySensorEntityDescription( key="is_pre_entry_climatization_enabled", - name="Pre entry climatization", + translation_key="is_pre_entry_climatization_enabled", icon="mdi:car-seat-heater", value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled if v.charging_profile diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 5285820b32d..d221c011445 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -37,32 +37,32 @@ class BMWButtonEntityDescription(ButtonEntityDescription): BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( BMWButtonEntityDescription( key="light_flash", + translation_key="light_flash", icon="mdi:car-light-alert", - name="Flash lights", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_light_flash(), ), BMWButtonEntityDescription( key="sound_horn", + translation_key="sound_horn", icon="mdi:bullhorn", - name="Sound horn", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(), ), BMWButtonEntityDescription( key="activate_air_conditioning", + translation_key="activate_air_conditioning", icon="mdi:hvac", - name="Activate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), BMWButtonEntityDescription( key="find_vehicle", + translation_key="find_vehicle", icon="mdi:crosshairs-question", - name="Find vehicle", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), ), BMWButtonEntityDescription( key="refresh", + translation_key="refresh", icon="mdi:refresh", - name="Refresh from cloud", account_function=lambda coordinator: coordinator.async_request_refresh(), enabled_when_read_only=True, ), diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index d20ccd1fbb4..c7495e3145a 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -44,7 +44,7 @@ async def async_setup_entry( class BMWLock(BMWBaseEntity, LockEntity): """Representation of a MyBMW vehicle lock.""" - _attr_name = "Lock" + _attr_translation_key = "lock" def __init__( self, diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index c8f72b272c1..f37f7627140 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -45,7 +45,7 @@ class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): NUMBER_TYPES: list[BMWNumberEntityDescription] = [ BMWNumberEntityDescription( key="target_soc", - name="Target SoC", + translation_key="target_soc", device_class=NumberDeviceClass.BATTERY, is_available=lambda v: v.is_remote_set_target_soc_enabled, native_max_value=100.0, diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 0b20ed90873..7c2ef4fed32 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -39,7 +39,7 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { "ac_limit": BMWSelectEntityDescription( key="ac_limit", - name="AC Charging Limit", + translation_key="ac_limit", is_available=lambda v: v.is_remote_set_ac_limit_enabled, dynamic_options=lambda v: [ str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] @@ -53,7 +53,7 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { ), "charging_mode": BMWSelectEntityDescription( key="charging_mode", - name="Charging Mode", + translation_key="charging_mode", is_available=lambda v: v.is_charging_plan_supported, options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 314ff47c14c..8f5b4fb8608 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -55,7 +55,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { # --- Generic --- "ac_current_limit": BMWSensorEntityDescription( key="ac_current_limit", - name="AC current limit", + translation_key="ac_current_limit", key_class="charging_profile", unit_type=UnitOfElectricCurrent.AMPERE, icon="mdi:current-ac", @@ -63,34 +63,34 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "charging_start_time": BMWSensorEntityDescription( key="charging_start_time", - name="Charging start time", + translation_key="charging_start_time", key_class="fuel_and_battery", device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, ), "charging_end_time": BMWSensorEntityDescription( key="charging_end_time", - name="Charging end time", + translation_key="charging_end_time", key_class="fuel_and_battery", device_class=SensorDeviceClass.TIMESTAMP, ), "charging_status": BMWSensorEntityDescription( key="charging_status", - name="Charging status", + translation_key="charging_status", key_class="fuel_and_battery", icon="mdi:ev-station", value=lambda x, y: x.value, ), "charging_target": BMWSensorEntityDescription( key="charging_target", - name="Charging target", + translation_key="charging_target", key_class="fuel_and_battery", icon="mdi:battery-charging-high", unit_type=PERCENTAGE, ), "remaining_battery_percent": BMWSensorEntityDescription( key="remaining_battery_percent", - name="Remaining battery percent", + translation_key="remaining_battery_percent", key_class="fuel_and_battery", unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -98,14 +98,14 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { # --- Specific --- "mileage": BMWSensorEntityDescription( key="mileage", - name="Mileage", + translation_key="mileage", icon="mdi:speedometer", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", - name="Remaining range total", + translation_key="remaining_range_total", key_class="fuel_and_battery", icon="mdi:map-marker-distance", unit_type=LENGTH, @@ -113,7 +113,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", - name="Remaining range electric", + translation_key="remaining_range_electric", key_class="fuel_and_battery", icon="mdi:map-marker-distance", unit_type=LENGTH, @@ -121,7 +121,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", - name="Remaining range fuel", + translation_key="remaining_range_fuel", key_class="fuel_and_battery", icon="mdi:map-marker-distance", unit_type=LENGTH, @@ -129,7 +129,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", - name="Remaining fuel", + translation_key="remaining_fuel", key_class="fuel_and_battery", icon="mdi:gas-station", unit_type=VOLUME, @@ -137,7 +137,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_fuel_percent": BMWSensorEntityDescription( key="remaining_fuel_percent", - name="Remaining fuel percent", + translation_key="remaining_fuel_percent", key_class="fuel_and_battery", icon="mdi:gas-station", unit_type=PERCENTAGE, diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 506175becd9..af73417b1a9 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -26,5 +26,114 @@ } } } + }, + "entity": { + "binary_sensor": { + "lids": { + "name": "Lids" + }, + "windows": { + "name": "Windows" + }, + "door_lock_state": { + "name": "Door lock state" + }, + "condition_based_services": { + "name": "Condition based services" + }, + "check_control_messages": { + "name": "Check control messages" + }, + "charging_status": { + "name": "Charging status" + }, + "connection_status": { + "name": "Connection status" + }, + "is_pre_entry_climatization_enabled": { + "name": "Pre entry climatization" + } + }, + "button": { + "light_flash": { + "name": "Flash lights" + }, + "sound_horn": { + "name": "Sound horn" + }, + "activate_air_conditioning": { + "name": "Activate air conditioning" + }, + "find_vehicle": { + "name": "Find vehicle" + }, + "refresh": { + "name": "Refresh from cloud" + } + }, + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, + "number": { + "target_soc": { + "name": "Target SoC" + } + }, + "select": { + "ac_limit": { + "name": "AC Charging Limit" + }, + "charging_mode": { + "name": "Charging Mode" + } + }, + "sensor": { + "ac_current_limit": { + "name": "AC current limit" + }, + "charging_start_time": { + "name": "Charging start time" + }, + "charging_end_time": { + "name": "Charging end time" + }, + "charging_status": { + "name": "Charging status" + }, + "charging_target": { + "name": "Charging target" + }, + "remaining_battery_percent": { + "name": "Remaining battery percent" + }, + "mileage": { + "name": "Mileage" + }, + "remaining_range_total": { + "name": "Remaining range total" + }, + "remaining_range_electric": { + "name": "Remaining range electric" + }, + "remaining_range_fuel": { + "name": "Remaining range fuel" + }, + "remaining_fuel": { + "name": "Remaining fuel" + }, + "remaining_fuel_percent": { + "name": "Remaining fuel percent" + } + }, + "switch": { + "climate": { + "name": "Climate" + }, + "charging": { + "name": "Charging" + } + } } } diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index 41243ca9323..298338dc9fa 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -51,7 +51,7 @@ CHARGING_STATE_ON = { NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ BMWSwitchEntityDescription( key="climate", - name="Climate", + translation_key="climate", is_available=lambda v: v.is_remote_climate_stop_enabled, value_fn=lambda v: v.climate.is_climate_on, remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), @@ -60,7 +60,7 @@ NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ ), BMWSwitchEntityDescription( key="charging", - name="Charging", + translation_key="charging", is_available=lambda v: v.is_remote_charge_stop_enabled, value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, remote_service_on=lambda v: v.remote_services.trigger_charge_start(), From 3f0393154e21fabece161021fc951a7a7af2a101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 26 Jun 2023 18:58:51 +0200 Subject: [PATCH 565/857] Remove unused ConfigEntry from Airzone Cloud entities (#95103) --- .../components/airzone_cloud/binary_sensor.py | 4 +--- homeassistant/components/airzone_cloud/entity.py | 4 ---- homeassistant/components/airzone_cloud/sensor.py | 12 +++--------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 1bd42118835..052318b6b10 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -55,7 +55,6 @@ async def async_setup_entry( AirzoneZoneBinarySensor( coordinator, description, - entry, zone_id, zone_data, ) @@ -95,12 +94,11 @@ class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): self, coordinator: AirzoneUpdateCoordinator, description: AirzoneBinarySensorEntityDescription, - entry: ConfigEntry, zone_id: str, zone_data: dict[str, Any], ) -> None: """Initialize.""" - super().__init__(coordinator, entry, zone_id, zone_data) + super().__init__(coordinator, zone_id, zone_data) self._attr_unique_id = f"{zone_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 077f7940940..9b3dfdae06c 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -15,7 +15,6 @@ from aioairzone_cloud.const import ( AZD_ZONES, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -43,7 +42,6 @@ class AirzoneAidooEntity(AirzoneEntity): def __init__( self, coordinator: AirzoneUpdateCoordinator, - entry: ConfigEntry, aidoo_id: str, aidoo_data: dict[str, Any], ) -> None: @@ -73,7 +71,6 @@ class AirzoneWebServerEntity(AirzoneEntity): def __init__( self, coordinator: AirzoneUpdateCoordinator, - entry: ConfigEntry, ws_id: str, ws_data: dict[str, Any], ) -> None: @@ -104,7 +101,6 @@ class AirzoneZoneEntity(AirzoneEntity): def __init__( self, coordinator: AirzoneUpdateCoordinator, - entry: ConfigEntry, zone_id: str, zone_data: dict[str, Any], ) -> None: diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 2bc3f7fbda4..90fbf849389 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -89,7 +89,6 @@ async def async_setup_entry( AirzoneAidooSensor( coordinator, description, - entry, aidoo_id, aidoo_data, ) @@ -103,7 +102,6 @@ async def async_setup_entry( AirzoneWebServerSensor( coordinator, description, - entry, ws_id, ws_data, ) @@ -117,7 +115,6 @@ async def async_setup_entry( AirzoneZoneSensor( coordinator, description, - entry, zone_id, zone_data, ) @@ -148,12 +145,11 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): self, coordinator: AirzoneUpdateCoordinator, description: SensorEntityDescription, - entry: ConfigEntry, aidoo_id: str, aidoo_data: dict[str, Any], ) -> None: """Initialize.""" - super().__init__(coordinator, entry, aidoo_id, aidoo_data) + super().__init__(coordinator, aidoo_id, aidoo_data) self._attr_has_entity_name = True self._attr_unique_id = f"{aidoo_id}_{description.key}" @@ -169,12 +165,11 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): self, coordinator: AirzoneUpdateCoordinator, description: SensorEntityDescription, - entry: ConfigEntry, ws_id: str, ws_data: dict[str, Any], ) -> None: """Initialize.""" - super().__init__(coordinator, entry, ws_id, ws_data) + super().__init__(coordinator, ws_id, ws_data) self._attr_has_entity_name = True self._attr_unique_id = f"{ws_id}_{description.key}" @@ -190,12 +185,11 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): self, coordinator: AirzoneUpdateCoordinator, description: SensorEntityDescription, - entry: ConfigEntry, zone_id: str, zone_data: dict[str, Any], ) -> None: """Initialize.""" - super().__init__(coordinator, entry, zone_id, zone_data) + super().__init__(coordinator, zone_id, zone_data) self._attr_has_entity_name = True self._attr_unique_id = f"{zone_id}_{description.key}" From 1525901ffc744848d2ed2f279930bca233768acd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 19:02:52 +0200 Subject: [PATCH 566/857] Add entity translations to dormakaba (#95230) --- homeassistant/components/dormakaba_dkey/binary_sensor.py | 3 +-- homeassistant/components/dormakaba_dkey/sensor.py | 1 - homeassistant/components/dormakaba_dkey/strings.json | 7 +++++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index e21e35da1e5..6cfbdd50b34 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -39,13 +39,12 @@ class DormakabaDkeyBinarySensorDescription( BINARY_SENSOR_DESCRIPTIONS = ( DormakabaDkeyBinarySensorDescription( key="door_position", - name="Door", device_class=BinarySensorDeviceClass.DOOR, is_on=lambda state: state.door_position == DoorPosition.OPEN, ), DormakabaDkeyBinarySensorDescription( key="security_locked", - name="Deadbolt", + translation_key="deadbolt", device_class=BinarySensorDeviceClass.LOCK, is_on=lambda state: state.unlock_status not in (UnlockStatus.SECURITY_LOCKED, UnlockStatus.UNLOCKED_SECURITY_LOCKED), diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py index 8234b41c43a..39915563b03 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -22,7 +22,6 @@ from .models import DormakabaDkeyData BINARY_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key="battery_level", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index efe9d3acb52..15bcf3f9ddc 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -33,5 +33,12 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "binary_sensor": { + "deadbolt": { + "name": "Deadbolt" + } + } } } From f5975d4039760c0fadb194fdd3c65a444b3ceaf8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 26 Jun 2023 19:03:56 +0200 Subject: [PATCH 567/857] Update build system (#95237) --- .github/workflows/ci.yaml | 4 ++-- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 6 ++++-- requirements.txt | 2 +- script/setup | 2 +- setup.cfg | 5 ----- 6 files changed, 9 insertions(+), 12 deletions(-) delete mode 100644 setup.cfg diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ab93b7128e..bfc4be56077 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -492,10 +492,10 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel + pip install --cache-dir=$PIP_CACHE -U "pip>=21.3.1,<23.2" setuptools wheel pip install --cache-dir=$PIP_CACHE -r requirements_all.txt pip install --cache-dir=$PIP_CACHE -r requirements_test.txt - pip install -e . + pip install . hassfest: name: Check hassfest diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f97ec243c0..ba9145b18f6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ mutagen==1.46.0 orjson==3.9.1 paho-mqtt==1.6.1 Pillow==9.5.0 -pip>=21.0,<23.2 +pip>=21.3.1,<23.2 psutil-home-assistant==0.0.1 PyJWT==2.7.0 PyNaCl==1.5.0 diff --git a/pyproject.toml b/pyproject.toml index bea8a1696fa..ff238d97b69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools~=62.3", "wheel~=0.37.1"] +requires = ["setuptools~=68.0", "wheel~=0.40.0"] build-backend = "setuptools.build_meta" [project] @@ -19,6 +19,7 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Home Automation", ] requires-python = ">=3.10.0" @@ -45,7 +46,7 @@ dependencies = [ # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.1", - "pip>=21.0,<23.2", + "pip>=21.3.1,<23.2", "python-slugify==4.0.1", "PyYAML==6.0", "requests==2.31.0", @@ -57,6 +58,7 @@ dependencies = [ ] [project.urls] +"Homepage" = "https://www.home-assistant.io/" "Source Code" = "https://github.com/home-assistant/core" "Bug Reports" = "https://github.com/home-assistant/core/issues" "Docs: Dev" = "https://developers.home-assistant.io/" diff --git a/requirements.txt b/requirements.txt index cf86475098f..f4f2608b597 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ PyJWT==2.7.0 cryptography==41.0.1 pyOpenSSL==23.2.0 orjson==3.9.1 -pip>=21.0,<23.2 +pip>=21.3.1,<23.2 python-slugify==4.0.1 PyYAML==6.0 requests==2.31.0 diff --git a/script/setup b/script/setup index e68ec72cbba..a5c2d48b2b3 100755 --- a/script/setup +++ b/script/setup @@ -24,7 +24,7 @@ fi script/bootstrap pre-commit install -python3 -m pip install -e . --constraint homeassistant/package_constraints.txt +python3 -m pip install -e . --config-settings editable_mode=compat --constraint homeassistant/package_constraints.txt python3 -m script.translations develop --all hass --script ensure_config -c config diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 29713b6df46..00000000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -# Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). -# Keep this file until it does! - -[metadata] -url = https://www.home-assistant.io/ From 410b15df9205b660f7ee1b6800393f78cca1b473 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 26 Jun 2023 19:05:50 +0200 Subject: [PATCH 568/857] Improve exception handling for BMW remote services (#92199) Co-authored-by: rikroe --- .../components/bmw_connected_drive/button.py | 12 +++++- .../components/bmw_connected_drive/lock.py | 16 ++++++- .../components/bmw_connected_drive/notify.py | 10 ++++- .../components/bmw_connected_drive/select.py | 7 ++- .../bmw_connected_drive/test_select.py | 43 +++++++++++++++++++ 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index d221c011445..2fd0ea91d08 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -6,12 +6,14 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.remote_services import RemoteServiceStatus from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BMWBaseEntity @@ -111,7 +113,10 @@ class BMWButton(BMWBaseEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" if self.entity_description.remote_function: - await self.entity_description.remote_function(self.vehicle) + try: + await self.entity_description.remote_function(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex elif self.entity_description.account_function: _LOGGER.warning( "The 'Refresh from cloud' button is deprecated. Use the" @@ -120,6 +125,9 @@ class BMWButton(BMWBaseEntity, ButtonEntity): " https://www.home-assistant.io/integrations/bmw_connected_drive/#update-the-state--refresh-from-api" " for details" ) - await self.entity_description.account_function(self.coordinator) + try: + await self.entity_description.account_function(self.coordinator) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index c7495e3145a..6608206a0ee 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -4,12 +4,14 @@ from __future__ import annotations import logging from typing import Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.doors_windows import LockState from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BMWBaseEntity @@ -66,7 +68,12 @@ class BMWLock(BMWBaseEntity, LockEntity): # update callback response self._attr_is_locked = True self.async_write_ha_state() - await self.vehicle.remote_services.trigger_remote_door_lock() + try: + await self.vehicle.remote_services.trigger_remote_door_lock() + except MyBMWAPIError as ex: + self._attr_is_locked = False + self.async_write_ha_state() + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() @@ -79,7 +86,12 @@ class BMWLock(BMWBaseEntity, LockEntity): # update callback response self._attr_is_locked = False self.async_write_ha_state() - await self.vehicle.remote_services.trigger_remote_door_unlock() + try: + await self.vehicle.remote_services.trigger_remote_door_unlock() + except MyBMWAPIError as ex: + self._attr_is_locked = True + self.async_write_ha_state() + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index 036d5147c4f..4a9f7679dc4 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import Any, cast +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from homeassistant.components.notify import ( @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -87,7 +89,11 @@ class BMWNotificationService(BaseNotificationService): if k in ATTR_LOCATION_ATTRIBUTES } ) - - await vehicle.remote_services.trigger_send_poi(location_dict) + try: + await vehicle.remote_services.trigger_send_poi(location_dict) + except TypeError as ex: + raise ValueError(str(ex)) from ex + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex else: raise ValueError(f"'data.{ATTR_LOCATION}' is required.") diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 7c2ef4fed32..3467322a4af 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -4,6 +4,7 @@ from dataclasses import dataclass import logging from typing import Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.charging_profile import ChargingMode @@ -11,6 +12,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BMWBaseEntity @@ -123,6 +125,9 @@ class BMWSelect(BMWBaseEntity, SelectEntity): self.vehicle.vin, option, ) - await self.entity_description.remote_service(self.vehicle, option) + try: + await self.entity_description.remote_service(self.vehicle, option) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index b5a13a13b63..97da6f81d6e 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -1,4 +1,7 @@ """Test BMW selects.""" +from unittest.mock import AsyncMock + +from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices import pytest import respx @@ -8,6 +11,7 @@ from homeassistant.components.bmw_connected_drive.coordinator import ( BMWDataUpdateCoordinator, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import setup_mocked_integration @@ -87,3 +91,42 @@ async def test_update_triggers_fail( ) assert RemoteServices.trigger_remote_service.call_count == 0 assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (MyBMWRemoteServiceError, HomeAssistantError), + (MyBMWAPIError, HomeAssistantError), + (ValueError, ValueError), + ], +) +async def test_remote_service_exceptions( + hass: HomeAssistant, + raised: Exception, + expected: Exception, + bmw_fixture: respx.Router, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test exception handling for remote services.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=raised), + ) + + # Test + with pytest.raises(expected): + await hass.services.async_call( + "select", + "select_option", + service_data={"option": "16"}, + blocking=True, + target={"entity_id": "select.i4_edrive40_ac_charging_limit"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 From c1a37185b4a271f3aa0889aad0fdafa76ba38bd1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 26 Jun 2023 19:40:30 +0200 Subject: [PATCH 569/857] Mark Plugwise Illuminance sensor as diagnostic (#95240) --- homeassistant/components/plugwise/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index d708fe741c2..7a504a0db84 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -343,6 +343,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="modulation_level", From a691846b5db9b6ac8b9fd8197dde62bb54f1ccd9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 20:29:28 +0200 Subject: [PATCH 570/857] Use entity registry id in climate device conditions (#95252) --- .../components/climate/device_condition.py | 26 ++- .../climate/test_device_condition.py | 199 ++++++++++++++++-- 2 files changed, 203 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 97dc27cfa09..d9f1b240a9a 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -3,6 +3,9 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_CONDITION, @@ -28,7 +31,7 @@ CONDITION_TYPES = {"is_hvac_mode", "is_preset_mode"} HVAC_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "is_hvac_mode", vol.Required(const.ATTR_HVAC_MODE): vol.In(const.HVAC_MODES), } @@ -36,7 +39,7 @@ HVAC_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( PRESET_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "is_preset_mode", vol.Required(const.ATTR_PRESET_MODE): str, } @@ -63,7 +66,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions.append({**base_condition, CONF_TYPE: "is_hvac_mode"}) @@ -80,9 +83,12 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - if (state := hass.states.get(config[ATTR_ENTITY_ID])) is None: + if not entity_id or (state := hass.states.get(entity_id)) is None: return False if config[CONF_TYPE] == "is_hvac_mode": @@ -106,9 +112,11 @@ async def async_get_condition_capabilities( if condition_type == "is_hvac_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) hvac_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_HVAC_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_HVAC_MODES) or [] ) except HomeAssistantError: hvac_modes = [] @@ -116,9 +124,11 @@ async def async_get_condition_capabilities( elif condition_type == "is_preset_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) preset_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_PRESET_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_PRESET_MODES) or [] ) except HomeAssistantError: preset_modes = [] diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 20bbe05386f..33df78bf347 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -69,7 +69,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -87,7 +87,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in expected_condition_types @@ -121,7 +121,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -135,7 +135,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_hvac_mode"] @@ -146,8 +146,12 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -160,7 +164,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "climate.entity", + "entity_id": entry.id, "type": "is_hvac_mode", "hvac_mode": "cool", } @@ -182,7 +186,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "climate.entity", + "entity_id": entry.id, "type": "is_preset_mode", "preset_mode": "away", } @@ -207,7 +211,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 0 hass.states.async_set( - "climate.entity", + entry.entity_id, HVACMode.COOL, { const.ATTR_PRESET_MODE: const.PRESET_AWAY, @@ -220,7 +224,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert calls[0].data["some"] == "is_hvac_mode - event - test_event1" hass.states.async_set( - "climate.entity", + entry.entity_id, HVACMode.AUTO, { const.ATTR_PRESET_MODE: const.PRESET_AWAY, @@ -239,7 +243,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert calls[1].data["some"] == "is_preset_mode - event - test_event2" hass.states.async_set( - "climate.entity", + entry.entity_id, HVACMode.AUTO, { const.ATTR_PRESET_MODE: const.PRESET_HOME, @@ -252,6 +256,54 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 2 +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_hvac_mode", + "hvac_mode": "cool", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "is_hvac_mode - {{ trigger.platform }} " + "- {{ trigger.event.event_type }}" + ) + }, + }, + }, + ] + }, + ) + + hass.states.async_set( + entry.entity_id, + HVACMode.COOL, + ) + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_hvac_mode - event - test_event1" + + @pytest.mark.parametrize( ( "set_state", @@ -336,7 +388,7 @@ async def test_capabilities( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -345,7 +397,7 @@ async def test_capabilities( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", + entity_entry.entity_id, HVACMode.COOL, capabilities_state, ) @@ -356,7 +408,126 @@ async def test_capabilities( "condition": "device", "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, + "type": condition, + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert ( + voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) + == expected_capabilities + ) + + +@pytest.mark.parametrize( + ( + "set_state", + "capabilities_reg", + "capabilities_state", + "condition", + "expected_capabilities", + ), + [ + ( + False, + {const.ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF]}, + {}, + "is_hvac_mode", + [ + { + "name": "hvac_mode", + "options": [("cool", "cool"), ("off", "off")], + "required": True, + "type": "select", + } + ], + ), + ( + False, + {const.ATTR_PRESET_MODES: [const.PRESET_HOME, const.PRESET_AWAY]}, + {}, + "is_preset_mode", + [ + { + "name": "preset_mode", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ], + ), + ( + True, + {}, + {const.ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF]}, + "is_hvac_mode", + [ + { + "name": "hvac_mode", + "options": [("cool", "cool"), ("off", "off")], + "required": True, + "type": "select", + } + ], + ), + ( + True, + {}, + {const.ATTR_PRESET_MODES: [const.PRESET_HOME, const.PRESET_AWAY]}, + "is_preset_mode", + [ + { + "name": "preset_mode", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ], + ), + ], +) +async def test_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + set_state, + capabilities_reg, + capabilities_state, + condition, + expected_capabilities, +) -> None: + """Test getting capabilities.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + capabilities=capabilities_reg, + ) + if set_state: + hass.states.async_set( + entity_entry.entity_id, + HVACMode.COOL, + capabilities_state, + ) + + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "condition": "device", + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entity_entry.entity_id, "type": condition, }, ) @@ -388,7 +559,7 @@ async def test_capabilities_missing_entity( "condition": "device", "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": "01234567890123456789012345678901", "type": condition, }, ) From 16ec9b1e9f45f9ff06cc0caa589f221a81f5f864 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 20:29:39 +0200 Subject: [PATCH 571/857] Use entity registry id in device_tracker device conditions (#95254) --- .../device_tracker/device_condition.py | 8 ++- .../device_tracker/test_device_condition.py | 67 ++++++++++++++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 96ee70baca8..b5bf850b4fa 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -27,7 +27,7 @@ CONDITION_TYPES = {"is_home", "is_not_home"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -50,7 +50,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -63,12 +63,14 @@ def async_condition_from_config( hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) reverse = config[CONF_TYPE] == "is_not_home" @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - result = condition.state(hass, config[ATTR_ENTITY_ID], STATE_HOME) + result = condition.state(hass, entity_id, STATE_HOME) if reverse: result = not result return result diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index de5d373fa00..008a7eb75c4 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -41,7 +41,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -50,7 +50,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in ["is_not_home", "is_home"] @@ -84,7 +84,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -98,7 +98,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_not_home", "is_home"] @@ -109,9 +109,13 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" - hass.states.async_set("device_tracker.entity", STATE_HOME) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_HOME) assert await async_setup_component( hass, @@ -125,7 +129,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "device_tracker.entity", + "entity_id": entry.id, "type": "is_home", } ], @@ -147,7 +151,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "device_tracker.entity", + "entity_id": entry.id, "type": "is_not_home", } ], @@ -171,9 +175,54 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_home - event - test_event1" - hass.states.async_set("device_tracker.entity", "school") + hass.states.async_set(entry.entity_id, "school") hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_not_home - event - test_event2" + + +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_HOME) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_home", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "is_home " + "- {{ trigger.platform }} " + "- {{ trigger.event.event_type }}" + ) + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_home - event - test_event1" From c4589ad4e5d8d9187776802117b30332cb4eff04 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 20:29:52 +0200 Subject: [PATCH 572/857] Use entity registry id in fan device conditions (#95255) --- .../components/fan/device_condition.py | 9 ++- tests/components/fan/test_device_condition.py | 66 ++++++++++++++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index d4bd5f2e419..920f970185b 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -28,7 +28,7 @@ CONDITION_TYPES = {"is_on", "is_off"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -50,7 +50,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -63,6 +63,9 @@ def async_condition_from_config( hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + if config[CONF_TYPE] == "is_on": state = STATE_ON else: @@ -71,6 +74,6 @@ def async_condition_from_config( @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 2d4633f8bc5..20c84eb1436 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -41,7 +41,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -50,7 +50,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in ["is_off", "is_on"] @@ -84,7 +84,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -98,7 +98,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_off", "is_on"] @@ -109,9 +109,13 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" - hass.states.async_set("fan.entity", STATE_ON) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -125,7 +129,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "is_on", } ], @@ -147,7 +151,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "fan.entity", + "entity_id": entry.id, "type": "is_off", } ], @@ -171,9 +175,53 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_on - event - test_event1" - hass.states.async_set("fan.entity", STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_off - event - test_event2" + + +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "is_on " + "- {{ trigger.platform }} " + "- {{ trigger.event.event_type }}" + ) + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on - event - test_event1" From 2cfa889750f1cf0e03d30ae59e4fa130abd9c874 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 20:30:05 +0200 Subject: [PATCH 573/857] Use entity registry id in lock device conditions (#95257) --- .../components/lock/device_condition.py | 9 ++- .../components/lock/test_device_condition.py | 75 +++++++++++++++---- 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index c439fe99d14..5ba93554aec 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -39,7 +39,7 @@ CONDITION_TYPES = { CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -62,7 +62,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -86,8 +86,11 @@ def async_condition_from_config( else: state = STATE_UNLOCKED + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 765e5d3b5a7..43513930f2e 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -48,7 +48,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -57,7 +57,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in [ @@ -97,7 +97,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -111,7 +111,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in [ @@ -128,9 +128,13 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" - hass.states.async_set("lock.entity", STATE_LOCKED) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_LOCKED) assert await async_setup_component( hass, @@ -144,7 +148,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "is_locked", } ], @@ -162,7 +166,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "is_unlocked", } ], @@ -180,7 +184,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "is_unlocking", } ], @@ -198,7 +202,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "is_locking", } ], @@ -216,7 +220,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "is_jammed", } ], @@ -236,27 +240,68 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_locked - event - test_event1" - hass.states.async_set("lock.entity", STATE_UNLOCKED) + hass.states.async_set(entry.entity_id, STATE_UNLOCKED) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_unlocked - event - test_event2" - hass.states.async_set("lock.entity", STATE_UNLOCKING) + hass.states.async_set(entry.entity_id, STATE_UNLOCKING) hass.bus.async_fire("test_event3") await hass.async_block_till_done() assert len(calls) == 3 assert calls[2].data["some"] == "is_unlocking - event - test_event3" - hass.states.async_set("lock.entity", STATE_LOCKING) + hass.states.async_set(entry.entity_id, STATE_LOCKING) hass.bus.async_fire("test_event4") await hass.async_block_till_done() assert len(calls) == 4 assert calls[3].data["some"] == "is_locking - event - test_event4" - hass.states.async_set("lock.entity", STATE_JAMMED) + hass.states.async_set(entry.entity_id, STATE_JAMMED) hass.bus.async_fire("test_event5") await hass.async_block_till_done() assert len(calls) == 5 assert calls[4].data["some"] == "is_jammed - event - test_event5" + + +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_LOCKED) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_locked", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_locked - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_locked - event - test_event1" From 4021662b48b87d7829f7833152e457d80d1fd34c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 20:30:22 +0200 Subject: [PATCH 574/857] Use entity registry id in media_player device conditions (#95258) --- .../media_player/device_condition.py | 9 ++- .../media_player/test_device_condition.py | 78 +++++++++++++++---- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/media_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index 9e3981ed983..5efee0c0b49 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -39,7 +39,7 @@ CONDITION_TYPES = { CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -62,7 +62,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -88,8 +88,11 @@ def async_condition_from_config( else: # is_playing state = STATE_PLAYING + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index 59626701812..b89993dec65 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -49,7 +49,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -58,7 +58,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in [ @@ -99,7 +99,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -113,7 +113,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in [ @@ -131,9 +131,13 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" - hass.states.async_set("media_player.entity", STATE_ON) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -147,7 +151,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "media_player.entity", + "entity_id": entry.id, "type": "is_on", } ], @@ -165,7 +169,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "media_player.entity", + "entity_id": entry.id, "type": "is_off", } ], @@ -183,7 +187,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "media_player.entity", + "entity_id": entry.id, "type": "is_idle", } ], @@ -201,7 +205,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "media_player.entity", + "entity_id": entry.id, "type": "is_paused", } ], @@ -219,7 +223,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "media_player.entity", + "entity_id": entry.id, "type": "is_playing", } ], @@ -237,7 +241,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "media_player.entity", + "entity_id": entry.id, "type": "is_buffering", } ], @@ -261,7 +265,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_on - event - test_event1" - hass.states.async_set("media_player.entity", STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -272,7 +276,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 2 assert calls[1].data["some"] == "is_off - event - test_event2" - hass.states.async_set("media_player.entity", STATE_IDLE) + hass.states.async_set(entry.entity_id, STATE_IDLE) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -283,7 +287,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 3 assert calls[2].data["some"] == "is_idle - event - test_event3" - hass.states.async_set("media_player.entity", STATE_PAUSED) + hass.states.async_set(entry.entity_id, STATE_PAUSED) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -294,7 +298,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 4 assert calls[3].data["some"] == "is_paused - event - test_event4" - hass.states.async_set("media_player.entity", STATE_PLAYING) + hass.states.async_set(entry.entity_id, STATE_PLAYING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -305,7 +309,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 5 assert calls[4].data["some"] == "is_playing - event - test_event5" - hass.states.async_set("media_player.entity", STATE_BUFFERING) + hass.states.async_set(entry.entity_id, STATE_BUFFERING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") @@ -315,3 +319,43 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() assert len(calls) == 6 assert calls[5].data["some"] == "is_buffering - event - test_event6" + + +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_ON) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on - event - test_event1" From eb7ad2eb098120063ce249ddbf4bb46726e54313 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 20:30:29 +0200 Subject: [PATCH 575/857] Use entity registry id in select device conditions (#95259) --- .../components/select/device_condition.py | 16 +- .../select/test_device_condition.py | 139 ++++++++++++++++-- 2 files changed, 139 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index 13280ba4f0e..712e7bf78b6 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -3,6 +3,9 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, +) from homeassistant.const import ( CONF_CONDITION, CONF_DEVICE_ID, @@ -30,7 +33,7 @@ CONDITION_TYPES = {"selected_option"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), vol.Required(CONF_OPTION): str, vol.Optional(CONF_FOR): cv.positive_time_period_dict, @@ -48,7 +51,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "selected_option", } for entry in er.async_entries_for_device(registry, device_id) @@ -62,11 +65,14 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) + @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" return condition.state( - hass, config[CONF_ENTITY_ID], config[CONF_OPTION], config.get(CONF_FOR) + hass, entity_id, config[CONF_OPTION], config.get(CONF_FOR) ) return test_is_state @@ -76,8 +82,10 @@ async def async_get_condition_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List condition capabilities.""" + try: - options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] + entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) + options = get_capability(hass, entry.entity_id, ATTR_OPTIONS) or [] except HomeAssistantError: options = [] diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index 1ff237e2641..18ebd428891 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -45,7 +45,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -54,7 +54,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": "selected_option", "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } ] @@ -87,7 +87,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -101,7 +101,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["selected_option"] @@ -113,9 +113,13 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_selected_option( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, + calls: list[ServiceCall], + entity_registry: er.EntityRegistry, ) -> None: """Test for selected_option conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -128,7 +132,7 @@ async def test_if_selected_option( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "select.entity", + "entity_id": entry.id, "type": "selected_option", "option": "option1", } @@ -147,7 +151,7 @@ async def test_if_selected_option( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "select.entity", + "entity_id": entry.id, "type": "selected_option", "option": "option2", } @@ -170,7 +174,7 @@ async def test_if_selected_option( assert len(calls) == 0 hass.states.async_set( - "select.entity", "option1", {"options": ["option1", "option2"]} + entry.entity_id, "option1", {"options": ["option1", "option2"]} ) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") @@ -179,7 +183,7 @@ async def test_if_selected_option( assert calls[0].data["result"] == "option1 - event - test_event1" hass.states.async_set( - "select.entity", "option2", {"options": ["option1", "option2"]} + entry.entity_id, "option2", {"options": ["option1", "option2"]} ) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") @@ -188,13 +192,62 @@ async def test_if_selected_option( assert calls[1].data["result"] == "option2 - event - test_event2" -async def test_get_condition_capabilities(hass: HomeAssistant) -> None: +async def test_if_selected_option_legacy( + hass: HomeAssistant, + calls: list[ServiceCall], + entity_registry: er.EntityRegistry, +) -> None: + """Test for selected_option conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "selected_option", + "option": "option1", + } + ], + "action": { + "service": "test.automation", + "data": { + "result": "option1 - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2"]} + ) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["result"] == "option1 - event - test_event1" + + +async def test_get_condition_capabilities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we get the expected capabilities from a select condition.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config = { "platform": "device", "domain": DOMAIN, "type": "selected_option", - "entity_id": "select.test", + "entity_id": entry.id, "option": "option1", } @@ -219,7 +272,69 @@ async def test_get_condition_capabilities(hass: HomeAssistant) -> None: ] # Mock an entity - hass.states.async_set("select.test", "option1", {"options": ["option1", "option2"]}) + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2"]} + ) + + # Test if we get the right capabilities now + capabilities = await async_get_condition_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "option", + "required": True, + "type": "select", + "options": [("option1", "option1"), ("option2", "option2")], + }, + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + }, + ] + + +async def test_get_condition_capabilities_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test we get the expected capabilities from a select condition.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + config = { + "platform": "device", + "domain": DOMAIN, + "type": "selected_option", + "entity_id": entry.entity_id, + "option": "option1", + } + + # Test when entity doesn't exists + capabilities = await async_get_condition_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "option", + "required": True, + "type": "select", + "options": [], + }, + { + "name": "for", + "optional": True, + "type": "positive_time_period_dict", + }, + ] + + # Mock an entity + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2"]} + ) # Test if we get the right capabilities now capabilities = await async_get_condition_capabilities(hass, config) From 2930845b23f683f096256366a85e5f351bfa4074 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 20:31:06 +0200 Subject: [PATCH 576/857] Use entity registry id in vacuum device conditions (#95261) --- .../components/vacuum/device_condition.py | 15 +++-- .../vacuum/test_device_condition.py | 64 ++++++++++++++++--- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index cf5b0934663..8b7227e788e 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -4,7 +4,6 @@ from __future__ import annotations import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, @@ -26,7 +25,7 @@ CONDITION_TYPES = {"is_cleaning", "is_docked"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -48,7 +47,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -66,9 +65,15 @@ def async_condition_from_config( else: test_states = [STATE_CLEANING, STATE_RETURNING] + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - state = hass.states.get(config[ATTR_ENTITY_ID]) - return state is not None and state.state in test_states + return ( + entity_id is not None + and (state := hass.states.get(entity_id)) is not None + and state.state in test_states + ) return test_is_state diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 6d1f0e5ad75..694f4b64417 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -46,7 +46,7 @@ async def test_get_conditions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_conditions = [ @@ -55,7 +55,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in ["is_cleaning", "is_docked"] @@ -89,7 +89,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -103,7 +103,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_cleaning", "is_docked"] @@ -114,9 +114,13 @@ async def test_get_conditions_hidden_auxiliary( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls) -> None: +async def test_if_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for turn_on and turn_off conditions.""" - hass.states.async_set("vacuum.entity", STATE_DOCKED) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_DOCKED) assert await async_setup_component( hass, @@ -130,7 +134,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "vacuum.entity", + "entity_id": entry.id, "type": "is_cleaning", } ], @@ -148,7 +152,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": "vacuum.entity", + "entity_id": entry.id, "type": "is_docked", } ], @@ -168,7 +172,7 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 assert calls[0].data["some"] == "is_docked - event - test_event2" - hass.states.async_set("vacuum.entity", STATE_CLEANING) + hass.states.async_set(entry.entity_id, STATE_CLEANING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -176,9 +180,49 @@ async def test_if_state(hass: HomeAssistant, calls) -> None: assert calls[1].data["some"] == "is_cleaning - event - test_event1" # Returning means it's still cleaning - hass.states.async_set("vacuum.entity", STATE_RETURNING) + hass.states.async_set(entry.entity_id, STATE_RETURNING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() assert len(calls) == 3 assert calls[2].data["some"] == "is_cleaning - event - test_event1" + + +async def test_if_state_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: + """Test for turn_on and turn_off conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_CLEANING) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "is_cleaning", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_cleaning - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_cleaning - event - test_event1" From b71e0302d6408bdc535fcf87c64872079ffb13c9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 21:20:40 +0200 Subject: [PATCH 577/857] Use entity registry id in sensor device conditions (#95260) --- .../components/sensor/device_condition.py | 11 +- .../sensor/test_device_condition.py | 244 +++++++++++++----- 2 files changed, 194 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index c52e076e51e..7d6c57de296 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -3,6 +3,9 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, +) from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -136,7 +139,7 @@ ENTITY_CONDITIONS = { CONDITION_SCHEMA = vol.All( cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ CONF_IS_APPARENT_POWER, @@ -223,7 +226,7 @@ async def async_get_conditions( **template, "condition": "device", "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": DOMAIN, } for template in templates @@ -257,8 +260,10 @@ async def async_get_condition_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List condition capabilities.""" + try: - unit_of_measurement = get_unit_of_measurement(hass, config[CONF_ENTITY_ID]) + entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) + unit_of_measurement = get_unit_of_measurement(hass, entry.entity_id) except HomeAssistantError: unit_of_measurement = None diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 1989f95c789..301baf0fc49 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -91,6 +91,7 @@ async def test_get_conditions( platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + sensor_entries = {} config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -99,7 +100,7 @@ async def test_get_conditions( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) for device_class in SensorDeviceClass: - entity_registry.async_get_or_create( + sensor_entries[device_class] = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES[device_class].unique_id, @@ -112,7 +113,7 @@ async def test_get_conditions( "domain": DOMAIN, "type": condition["type"], "device_id": device_entry.id, - "entity_id": platform.ENTITIES[device_class].entity_id, + "entity_id": sensor_entries[device_class].id, "metadata": {"secondary": False}, } for device_class in SensorDeviceClass @@ -150,7 +151,7 @@ async def test_get_conditions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -165,7 +166,7 @@ async def test_get_conditions_hidden_auxiliary( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for condition in ["is_value"] @@ -188,16 +189,16 @@ async def test_get_conditions_no_state( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_ids = {} + sensor_entries = {} for device_class in SensorDeviceClass: - entity_ids[device_class] = entity_registry.async_get_or_create( + sensor_entries[device_class] = entity_registry.async_get_or_create( DOMAIN, "test", f"5678_{device_class}", device_id=device_entry.id, original_device_class=device_class, unit_of_measurement=UNITS_OF_MEASUREMENT.get(device_class), - ).entity_id + ) await hass.async_block_till_done() @@ -207,7 +208,7 @@ async def test_get_conditions_no_state( "domain": DOMAIN, "type": condition["type"], "device_id": device_entry.id, - "entity_id": entity_ids[device_class], + "entity_id": sensor_entries[device_class].id, "metadata": {"secondary": False}, } for device_class in SensorDeviceClass @@ -246,7 +247,7 @@ async def test_get_conditions_no_unit_or_stateclass( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -260,7 +261,7 @@ async def test_get_conditions_no_unit_or_stateclass( "domain": DOMAIN, "type": condition, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for condition in condition_types @@ -340,8 +341,22 @@ async def test_get_condition_capabilities( assert capabilities == expected_capabilities -async def test_get_condition_capabilities_none( - hass: HomeAssistant, enable_custom_integrations: None +@pytest.mark.parametrize( + ("set_state", "device_class_reg", "device_class_state", "unit_reg", "unit_state"), + [ + (False, SensorDeviceClass.BATTERY, None, PERCENTAGE, None), + (True, None, SensorDeviceClass.BATTERY, None, PERCENTAGE), + ], +) +async def test_get_condition_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + set_state, + device_class_reg, + device_class_state, + unit_reg, + unit_state, ) -> None: """Test we get the expected capabilities from a sensor condition.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -349,6 +364,72 @@ async def test_get_condition_capabilities_none( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_id = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, + original_device_class=device_class_reg, + unit_of_measurement=unit_reg, + ).entity_id + if set_state: + hass.states.async_set( + entity_id, + None, + {"device_class": device_class_state, "unit_of_measurement": unit_state}, + ) + + expected_capabilities = { + "extra_fields": [ + { + "description": {"suffix": PERCENTAGE}, + "name": "above", + "optional": True, + "type": "float", + }, + { + "description": {"suffix": PERCENTAGE}, + "name": "below", + "optional": True, + "type": "float", + }, + ] + } + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + assert len(conditions) == 1 + for condition in conditions: + condition["entity_id"] = entity_registry.async_get( + condition["entity_id"] + ).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.CONDITION, condition + ) + assert capabilities == expected_capabilities + + +async def test_get_condition_capabilities_none( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test we get the expected capabilities from a sensor condition.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + + entry_none = entity_registry.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["none"].unique_id, + ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() @@ -358,14 +439,14 @@ async def test_get_condition_capabilities_none( "condition": "device", "device_id": "8770c43885354d5fa27604db6817f63f", "domain": "sensor", - "entity_id": "sensor.beer", + "entity_id": "01234567890123456789012345678901", "type": "is_battery_level", }, { "condition": "device", "device_id": "8770c43885354d5fa27604db6817f63f", "domain": "sensor", - "entity_id": platform.ENTITIES["none"].entity_id, + "entity_id": entry_none.id, "type": "is_battery_level", }, ] @@ -380,18 +461,13 @@ async def test_get_condition_capabilities_none( async def test_if_state_not_above_below( hass: HomeAssistant, + entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for bad value conditions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - sensor1 = platform.ENTITIES["battery"] + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -405,7 +481,7 @@ async def test_if_state_not_above_below( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "is_battery_level", } ], @@ -418,16 +494,15 @@ async def test_if_state_not_above_below( async def test_if_state_above( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - sensor1 = platform.ENTITIES["battery"] + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) assert await async_setup_component( hass, @@ -441,7 +516,7 @@ async def test_if_state_above( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "is_battery_level", "above": 10, } @@ -458,36 +533,34 @@ async def test_if_state_above( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 9) + hass.states.async_set(entry.entity_id, 9) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 11) + hass.states.async_set(entry.entity_id, 11) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == "event - test_event1" -async def test_if_state_below( - hass: HomeAssistant, calls, enable_custom_integrations: None +async def test_if_state_above_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - sensor1 = platform.ENTITIES["battery"] + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) assert await async_setup_component( hass, @@ -501,7 +574,65 @@ async def test_if_state_below( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.entity_id, + "type": "is_battery_level", + "above": 10, + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + } + ] + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, 9) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set(entry.entity_id, 11) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "event - test_event1" + + +async def test_if_state_below( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for value conditions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.id, "type": "is_battery_level", "below": 10, } @@ -518,19 +649,18 @@ async def test_if_state_below( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 11) + hass.states.async_set(entry.entity_id, 11) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 9) + hass.states.async_set(entry.entity_id, 9) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 @@ -538,16 +668,15 @@ async def test_if_state_below( async def test_if_state_between( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - sensor1 = platform.ENTITIES["battery"] + hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) assert await async_setup_component( hass, @@ -561,7 +690,7 @@ async def test_if_state_between( "condition": "device", "domain": DOMAIN, "device_id": "", - "entity_id": sensor1.entity_id, + "entity_id": entry.id, "type": "is_battery_level", "above": 10, "below": 20, @@ -579,30 +708,29 @@ async def test_if_state_between( }, ) await hass.async_block_till_done() - assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 9) + hass.states.async_set(entry.entity_id, 9) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 - hass.states.async_set(sensor1.entity_id, 11) + hass.states.async_set(entry.entity_id, 11) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == "event - test_event1" - hass.states.async_set(sensor1.entity_id, 21) + hass.states.async_set(entry.entity_id, 21) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 - hass.states.async_set(sensor1.entity_id, 19) + hass.states.async_set(entry.entity_id, 19) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 2 From f9707cc87b4d7bad935689074e91941320771564 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 26 Jun 2023 15:36:59 -0400 Subject: [PATCH 578/857] Add optional limits to compensation sensors (#85886) Co-authored-by: Tom Harris Co-authored-by: J. Nick Koston --- .../components/compensation/__init__.py | 22 ++++++++- .../components/compensation/const.py | 2 + .../components/compensation/sensor.py | 17 ++++++- tests/components/compensation/test_sensor.py | 47 +++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index e36737c7d35..01003020108 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -1,5 +1,6 @@ """The Compensation integration.""" import logging +from operator import itemgetter import numpy as np import voluptuous as vol @@ -7,6 +8,8 @@ import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( CONF_ATTRIBUTE, + CONF_MAXIMUM, + CONF_MINIMUM, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -20,8 +23,10 @@ from .const import ( CONF_COMPENSATION, CONF_DATAPOINTS, CONF_DEGREE, + CONF_LOWER_LIMIT, CONF_POLYNOMIAL, CONF_PRECISION, + CONF_UPPER_LIMIT, DATA_COMPENSATION, DEFAULT_DEGREE, DEFAULT_PRECISION, @@ -50,6 +55,8 @@ COMPENSATION_SCHEMA = vol.Schema( ], vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean, + vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int, vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All( vol.Coerce(int), @@ -78,8 +85,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: degree = conf[CONF_DEGREE] + initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS] + sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0)) + # get x values and y values from the x,y point pairs - x_values, y_values = zip(*conf[CONF_DATAPOINTS]) + x_values, y_values = zip(*initial_coefficients) # try to get valid coefficients for a polynomial coefficients = None @@ -99,6 +109,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } data[CONF_POLYNOMIAL] = np.poly1d(coefficients) + if data[CONF_LOWER_LIMIT]: + data[CONF_MINIMUM] = sorted_coefficients[0] + else: + data[CONF_MINIMUM] = None + + if data[CONF_UPPER_LIMIT]: + data[CONF_MAXIMUM] = sorted_coefficients[-1] + else: + data[CONF_MAXIMUM] = None + hass.data[DATA_COMPENSATION][compensation] = data hass.async_create_task( diff --git a/homeassistant/components/compensation/const.py b/homeassistant/components/compensation/const.py index f116725883e..d49a6982166 100644 --- a/homeassistant/components/compensation/const.py +++ b/homeassistant/components/compensation/const.py @@ -4,6 +4,8 @@ DOMAIN = "compensation" SENSOR = "compensation" CONF_COMPENSATION = "compensation" +CONF_LOWER_LIMIT = "lower_limit" +CONF_UPPER_LIMIT = "upper_limit" CONF_DATAPOINTS = "data_points" CONF_DEGREE = "degree" CONF_PRECISION = "precision" diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 16226974120..4d6ff95b810 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -10,6 +10,8 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, + CONF_MAXIMUM, + CONF_MINIMUM, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -64,6 +66,8 @@ async def async_setup_platform( conf[CONF_PRECISION], conf[CONF_POLYNOMIAL], conf.get(CONF_UNIT_OF_MEASUREMENT), + conf[CONF_MINIMUM], + conf[CONF_MAXIMUM], ) ] ) @@ -83,6 +87,8 @@ class CompensationSensor(SensorEntity): precision: int, polynomial: np.poly1d, unit_of_measurement: str | None, + minimum: tuple[float, float] | None, + maximum: tuple[float, float] | None, ) -> None: """Initialize the Compensation sensor.""" self._source_entity_id = source @@ -93,6 +99,8 @@ class CompensationSensor(SensorEntity): self._coefficients = polynomial.coefficients.tolist() self._attr_unique_id = unique_id self._attr_name = name + self._minimum = minimum + self._maximum = maximum async def async_added_to_hass(self) -> None: """Handle added to Hass.""" @@ -132,7 +140,14 @@ class CompensationSensor(SensorEntity): else: value = None if new_state.state == STATE_UNKNOWN else new_state.state try: - self._attr_native_value = round(self._poly(float(value)), self._precision) + x_value = float(value) + if self._minimum is not None and x_value <= self._minimum[0]: + y_value = self._minimum[1] + elif self._maximum is not None and x_value >= self._maximum[0]: + y_value = self._maximum[1] + else: + y_value = self._poly(x_value) + self._attr_native_value = round(y_value, self._precision) except (ValueError, TypeError): self._attr_native_value = None diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 59804c6b854..5bc5a5e1c39 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -223,3 +223,50 @@ async def test_new_state_is_none(hass: HomeAssistant) -> None: ) assert last_changed == hass.states.get(expected_entity_id).last_changed + + +@pytest.mark.parametrize( + ("lower", "upper"), + [ + (True, False), + (False, True), + (True, True), + ], +) +async def test_limits(hass: HomeAssistant, lower: bool, upper: bool) -> None: + """Test compensation sensor state.""" + source = "sensor.test" + config = { + "compensation": { + "test": { + "source": source, + "data_points": [ + [1.0, 0.0], + [3.0, 2.0], + [2.0, 1.0], + ], + "precision": 2, + "lower_limit": lower, + "upper_limit": upper, + "unit_of_measurement": "a", + } + } + } + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + entity_id = "sensor.compensation_sensor_test" + + hass.states.async_set(source, 0, {}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + value = 0.0 if lower else -1.0 + assert float(state.state) == value + + hass.states.async_set(source, 5, {}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + value = 2.0 if upper else 4.0 + assert float(state.state) == value From 0bec93fa3755b6d490a044af6ae9c413a6e2c5c2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 26 Jun 2023 21:54:40 +0200 Subject: [PATCH 579/857] Reolink ONVIF long polling (#94770) --- homeassistant/components/reolink/host.py | 119 +++++++++++++++++++---- tests/components/reolink/test_init.py | 6 +- 2 files changed, 105 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index ec4ca304d49..81fbda63fef 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -25,9 +25,11 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 -FIRST_ONVIF_TIMEOUT = 15 +FIRST_ONVIF_TIMEOUT = 10 SUBSCRIPTION_RENEW_THRESHOLD = 300 POLL_INTERVAL_NO_PUSH = 5 +LONG_POLL_COOLDOWN = 0.75 +LONG_POLL_ERROR_COOLDOWN = 30 _LOGGER = logging.getLogger(__name__) @@ -60,10 +62,14 @@ class ReolinkHost: self.webhook_id: str | None = None self._base_url: str = "" self._webhook_url: str = "" - self._webhook_reachable: asyncio.Event = asyncio.Event() + self._webhook_reachable: bool = False + self._long_poll_received: bool = False + self._long_poll_error: bool = False self._cancel_poll: CALLBACK_TYPE | None = None self._cancel_onvif_check: CALLBACK_TYPE | None = None + self._cancel_long_poll_check: CALLBACK_TYPE | None = None self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) + self._long_poll_task: asyncio.Task | None = None self._lost_subscription: bool = False @property @@ -185,15 +191,32 @@ class ReolinkHost: async def _async_check_onvif(self, *_) -> None: """Check the ONVIF subscription.""" - if ( - self._api.supported(None, "initial_ONVIF_state") - and not self._webhook_reachable.is_set() - ): + if self._webhook_reachable: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + self._cancel_onvif_check = None + return + if self._api.supported(None, "initial_ONVIF_state"): _LOGGER.debug( "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, FIRST_ONVIF_TIMEOUT, ) + + # ONVIF push is not received, start long polling and schedule check + await self._async_start_long_polling() + self._cancel_long_poll_check = async_call_later( + self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif_long_poll + ) + + self._cancel_onvif_check = None + + async def _async_check_onvif_long_poll(self, *_) -> None: + """Check if ONVIF long polling is working.""" + if not self._long_poll_received: + _LOGGER.debug( + "Did not receive state through ONVIF long polling after %i seconds", + FIRST_ONVIF_TIMEOUT, + ) ir.async_create_issue( self._hass, DOMAIN, @@ -210,10 +233,10 @@ class ReolinkHost: else: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") - # If no ONVIF push is received, start fast polling + # If no ONVIF push or long polling state is received, start fast polling await self._async_poll_all_motion() - self._cancel_onvif_check = None + self._cancel_long_poll_check = None async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" @@ -241,6 +264,20 @@ class ReolinkHost: str(err), ) + async def _async_start_long_polling(self): + """Start ONVIF long polling task.""" + if self._long_poll_task is None: + await self._api.subscribe(sub_type=SubType.long_poll) + self._long_poll_task = asyncio.create_task(self._async_long_polling()) + + async def _async_stop_long_polling(self): + """Stop ONVIF long polling task.""" + if self._long_poll_task is not None: + self._long_poll_task.cancel() + self._long_poll_task = None + + await self._api.unsubscribe(sub_type=SubType.long_poll) + async def stop(self, event=None): """Disconnect the API.""" if self._cancel_poll is not None: @@ -249,6 +286,10 @@ class ReolinkHost: if self._cancel_onvif_check is not None: self._cancel_onvif_check() self._cancel_onvif_check = None + if self._cancel_long_poll_check is not None: + self._cancel_long_poll_check() + self._cancel_long_poll_check = None + await self._async_stop_long_polling() self.unregister_webhook() await self.disconnect() @@ -277,6 +318,8 @@ class ReolinkHost: """Renew the subscription of motion events (lease time is 15 minutes).""" try: await self._renew(SubType.push) + if self._long_poll_task is not None: + await self._renew(SubType.long_poll) except SubscriptionError as err: if not self._lost_subscription: self._lost_subscription = True @@ -297,7 +340,10 @@ class ReolinkHost: self._api.host, sub_type, ) - await self.subscribe() + if sub_type == SubType.push: + await self.subscribe() + else: + await self._api.subscribe(self._webhook_url, sub_type) return timer = self._api.renewtimer(sub_type) @@ -386,10 +432,44 @@ class ReolinkHost: webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None + async def _async_long_polling(self, *_) -> None: + """Use ONVIF long polling to immediately receive events.""" + # This task will be cancelled once _async_stop_long_polling is called + while True: + if self._webhook_reachable: + self._long_poll_task = None + await self._async_stop_long_polling() + return + + try: + channels = await self._api.pull_point_request() + except ReolinkError as ex: + if not self._long_poll_error: + _LOGGER.error("Error while requesting ONVIF pull point: %s", ex) + await self._api.unsubscribe(sub_type=SubType.long_poll) + self._long_poll_error = True + await asyncio.sleep(LONG_POLL_ERROR_COOLDOWN) + continue + except Exception as ex: + _LOGGER.exception("Error while requesting ONVIF pull point: %s", ex) + await self._api.unsubscribe(sub_type=SubType.long_poll) + raise ex + + self._long_poll_error = False + + if not self._long_poll_received and channels != []: + self._long_poll_received = True + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + + self._signal_write_ha_state(channels) + + # Cooldown to prevent CPU over usage on camera freezes + await asyncio.sleep(LONG_POLL_COOLDOWN) + async def _async_poll_all_motion(self, *_) -> None: """Poll motion and AI states until the first ONVIF push is received.""" - if self._webhook_reachable.is_set(): - # ONVIF push is working, stop polling + if self._webhook_reachable or self._long_poll_received: + # ONVIF push or long polling is working, stop fast polling self._cancel_poll = None return @@ -409,10 +489,7 @@ class ReolinkHost: self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job ) - # After receiving the new motion states in the upstream lib, - # update the binary sensors with async_write_ha_state - # The same dispatch as for the webhook can be used - async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) + self._signal_write_ha_state(None) async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request @@ -460,8 +537,8 @@ class ReolinkHost: """Process the data from the Reolink webhook.""" # This task is executed in the background so we need to catch exceptions # and log them - if not self._webhook_reachable.is_set(): - self._webhook_reachable.set() + if not self._webhook_reachable: + self._webhook_reachable = True ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") try: @@ -484,9 +561,13 @@ class ReolinkHost: ) return + self._signal_write_ha_state(channels) + + def _signal_write_ha_state(self, channels: list[int] | None) -> None: + """Update the binary sensors with async_write_ha_state.""" if channels is None: - async_dispatcher_send(hass, f"{webhook_id}_all", {}) + async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) return for channel in channels: - async_dispatcher_send(hass, f"{webhook_id}_{channel}", {}) + async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {}) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 8dd6db270fb..1e588d5e3a1 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -148,7 +148,11 @@ async def test_webhook_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" - with patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0): + with patch( + "homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0 + ), patch( + "homeassistant.components.reolink.host.ReolinkHost._async_long_polling", + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From fde82ee323a916d883e26b4d266ea508c68bcfd4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Jun 2023 15:20:56 -0500 Subject: [PATCH 580/857] Keep esphome update entity available when disconnected is expected (#95278) --- homeassistant/components/esphome/update.py | 6 +++++- tests/components/esphome/test_update.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 618e31024b1..6f51b9df744 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -111,7 +111,11 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): """ return ( super().available - and (self._entry_data.available or self._device_info.has_deep_sleep) + and ( + self._entry_data.available + or self._entry_data.expected_disconnect + or self._device_info.has_deep_sleep + ) and self._device_info.name in self.coordinator.data ) diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 5410af96bd7..dd0daf1c455 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard from homeassistant.components.update import UpdateEntityFeature +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -149,8 +150,12 @@ async def test_update_static_info( assert state.state == "off" +@pytest.mark.parametrize( + "expected_disconnect_state", [(True, STATE_ON), (False, STATE_UNAVAILABLE)] +) async def test_update_device_state_for_availability( hass: HomeAssistant, + expected_disconnect_state: tuple[bool, str], mock_config_entry, mock_device_info, mock_dashboard, @@ -167,6 +172,7 @@ async def test_update_device_state_for_availability( signal_device_updated = f"esphome_{mock_config_entry.entry_id}_on_device_update" runtime_data = Mock( available=True, + expected_disconnect=False, device_info=mock_device_info, signal_device_updated=signal_device_updated, ) @@ -183,11 +189,14 @@ async def test_update_device_state_for_availability( assert state is not None assert state.state == "on" + expected_disconnect, expected_state = expected_disconnect_state + runtime_data.available = False + runtime_data.expected_disconnect = expected_disconnect async_dispatcher_send(hass, signal_device_updated) state = hass.states.get("update.none_firmware") - assert state.state == "unavailable" + assert state.state == expected_state # Deep sleep devices should still be available runtime_data.device_info = dataclasses.replace( From 9b1b0937eb282e4bddb40677330d3bc2dd24fcf4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 22:22:15 +0200 Subject: [PATCH 581/857] Use entity registry id in button device actions (#95267) --- .../components/button/device_action.py | 14 +++-- .../components/device_automation/helpers.py | 1 + tests/components/button/test_device_action.py | 53 ++++++++++++++++--- tests/components/zha/test_device_action.py | 3 +- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index 8398b4990cd..338b11e765b 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -19,14 +20,21 @@ from .const import DOMAIN, SERVICE_PRESS ACTION_TYPES = {"press"} -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -36,7 +44,7 @@ async def async_get_actions( { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "press", } for entry in er.async_entries_for_device(registry, device_id) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 69c8872b217..e228b64bed8 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -27,6 +27,7 @@ STATIC_VALIDATOR = { ENTITY_PLATFORMS = { Platform.ALARM_CONTROL_PANEL.value, + Platform.BUTTON.value, Platform.FAN.value, Platform.HUMIDIFIER.value, Platform.LIGHT.value, diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py index 81d8d2971d7..43e2d3f855f 100644 --- a/tests/components/button/test_device_action.py +++ b/tests/components/button/test_device_action.py @@ -29,7 +29,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_actions = [ @@ -37,7 +37,7 @@ async def test_get_actions( "domain": DOMAIN, "type": "press", "device_id": device_entry.id, - "entity_id": "button.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } ] @@ -70,7 +70,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -84,7 +84,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["press"] @@ -95,8 +95,10 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant) -> None: +async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test for press action.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -110,7 +112,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "button.entity", + "entity_id": entry.id, "type": "press", }, }, @@ -125,4 +127,41 @@ async def test_action(hass: HomeAssistant) -> None: assert len(press_calls) == 1 assert press_calls[0].domain == DOMAIN assert press_calls[0].service == "press" - assert press_calls[0].data == {"entity_id": "button.entity"} + assert press_calls[0].data == {"entity_id": entry.entity_id} + + +async def test_action_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for press action.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.entity_id, + "type": "press", + }, + }, + ] + }, + ) + + press_calls = async_mock_service(hass, DOMAIN, "press") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(press_calls) == 1 + assert press_calls[0].domain == DOMAIN + assert press_calls[0].service == "press" + assert press_calls[0].data == {"entity_id": entry.entity_id} diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 32dbf2d88c0..d9357fb38fd 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -165,6 +165,7 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non {(DOMAIN, inovelli_ieee_address)} ) ha_entity_registry = er.async_get(hass) + inovelli_button = ha_entity_registry.async_get("button.inovelli_vzm31_sn_identify") inovelli_light = ha_entity_registry.async_get("light.inovelli_vzm31_sn_light") actions = await async_get_device_automations( @@ -187,7 +188,7 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non { "device_id": inovelli_reg_device.id, "domain": Platform.BUTTON, - "entity_id": "button.inovelli_vzm31_sn_identify", + "entity_id": inovelli_button.id, "metadata": {"secondary": True}, "type": "press", }, From 2872b6cf61ee955154f9fe9b9008ecd6c96f3525 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 22:23:43 +0200 Subject: [PATCH 582/857] Add entity translations to Environment Canada (#95295) --- .../components/environment_canada/camera.py | 2 +- .../components/environment_canada/sensor.py | 53 +++++------ .../environment_canada/strings.json | 95 +++++++++++++++++++ .../components/environment_canada/weather.py | 2 +- 4 files changed, 123 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 7b93f0b28f4..385f973a25a 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -42,7 +42,7 @@ class ECCamera(CoordinatorEntity, Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True - _attr_name = "Radar" + _attr_translation_key = "radar" def __init__(self, coordinator): """Initialize the camera.""" diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index e7eceb8dadc..987a779d2e8 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -52,12 +52,12 @@ class ECSensorEntityDescription( SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ECSensorEntityDescription( key="condition", - name="Current condition", + translation_key="condition", value_fn=lambda data: data.conditions.get("condition", {}).get("value"), ), ECSensorEntityDescription( key="dewpoint", - name="Dew point", + translation_key="dewpoint", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -65,7 +65,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="high_temp", - name="High temperature", + translation_key="high_temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -73,7 +73,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="humidex", - name="Humidex", + translation_key="humidex", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -81,7 +81,6 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -89,11 +88,13 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="icon_code", + translation_key="icon_code", name="Icon code", value_fn=lambda data: data.conditions.get("icon_code", {}).get("value"), ), ECSensorEntityDescription( key="low_temp", + translation_key="low_temp", name="Low temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -102,27 +103,27 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="normal_high", - name="Normal high temperature", + translation_key="normal_high", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: data.conditions.get("normal_high", {}).get("value"), ), ECSensorEntityDescription( key="normal_low", - name="Normal low temperature", + translation_key="normal_low", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: data.conditions.get("normal_low", {}).get("value"), ), ECSensorEntityDescription( key="pop", - name="Chance of precipitation", + translation_key="pop", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: data.conditions.get("pop", {}).get("value"), ), ECSensorEntityDescription( key="precip_yesterday", - name="Precipitation yesterday", + translation_key="precip_yesterday", device_class=SensorDeviceClass.PRECIPITATION, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, state_class=SensorStateClass.MEASUREMENT, @@ -130,7 +131,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="pressure", - name="Barometric pressure", + translation_key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.KPA, state_class=SensorStateClass.MEASUREMENT, @@ -138,7 +139,6 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -146,32 +146,32 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="tendency", - name="Tendency", + translation_key="tendency", value_fn=lambda data: data.conditions.get("tendency", {}).get("value"), transform=lambda val: str(val).capitalize(), ), ECSensorEntityDescription( key="text_summary", - name="Summary", + translation_key="text_summary", value_fn=lambda data: data.conditions.get("text_summary", {}).get("value"), transform=lambda val: val[:255], ), ECSensorEntityDescription( key="timestamp", - name="Observation time", + translation_key="timestamp", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.metadata.get("timestamp"), ), ECSensorEntityDescription( key="uv_index", - name="UV index", + translation_key="uv_index", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.conditions.get("uv_index", {}).get("value"), ), ECSensorEntityDescription( key="visibility", - name="Visibility", + translation_key="visibility", native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, @@ -179,13 +179,13 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="wind_bearing", - name="Wind bearing", + translation_key="wind_bearing", native_unit_of_measurement=DEGREE, value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"), ), ECSensorEntityDescription( key="wind_chill", - name="Wind chill", + translation_key="wind_chill", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -193,12 +193,12 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="wind_dir", - name="Wind direction", + translation_key="wind_dir", value_fn=lambda data: data.conditions.get("wind_dir", {}).get("value"), ), ECSensorEntityDescription( key="wind_gust", - name="Wind gust", + translation_key="wind_gust", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -206,7 +206,6 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="wind_speed", - name="Wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -226,7 +225,7 @@ def _get_aqhi_value(data): AQHI_SENSOR = ECSensorEntityDescription( key="aqhi", - name="AQHI", + translation_key="aqhi", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, value_fn=_get_aqhi_value, @@ -235,35 +234,35 @@ AQHI_SENSOR = ECSensorEntityDescription( ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = ( ECSensorEntityDescription( key="advisories", - name="Advisory", + translation_key="advisories", icon="mdi:bell-alert", value_fn=lambda data: data.alerts.get("advisories", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="endings", - name="Endings", + translation_key="endings", icon="mdi:alert-circle-check", value_fn=lambda data: data.alerts.get("endings", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="statements", - name="Statements", + translation_key="statements", icon="mdi:bell-alert", value_fn=lambda data: data.alerts.get("statements", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="warnings", - name="Warnings", + translation_key="warnings", icon="mdi:alert-octagon", value_fn=lambda data: data.alerts.get("warnings", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="watches", - name="Watches", + translation_key="watches", icon="mdi:alert", value_fn=lambda data: data.alerts.get("watches", {}).get("value"), transform=len, diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 4c6d75cfeb6..d30124ddf5a 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -22,5 +22,100 @@ "too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "condition": { + "name": "Current condition" + }, + "dewpoint": { + "name": "Dew point" + }, + "high_temp": { + "name": "High temperature" + }, + "humidex": { + "name": "Humidex" + }, + "icon_code": { + "name": "Icon code" + }, + "low_temp": { + "name": "Low temperature" + }, + "normal_high": { + "name": "Normal high temperature" + }, + "normal_low": { + "name": "Normal low temperature" + }, + "pop": { + "name": "Chance of precipitation" + }, + "precip_yesterday": { + "name": "Precipitation yesterday" + }, + "pressure": { + "name": "Barometric pressure" + }, + "tendency": { + "name": "Tendency" + }, + "text_summary": { + "name": "Summary" + }, + "timestamp": { + "name": "Observation time" + }, + "uv_index": { + "name": "UV index" + }, + "visibility": { + "name": "Visibility" + }, + "wind_bearing": { + "name": "Wind bearing" + }, + "wind_chill": { + "name": "Wind chill" + }, + "wind_dir": { + "name": "Wind direction" + }, + "wind_gust": { + "name": "Wind gust" + }, + "aqhi": { + "name": "AQHI" + }, + "advisories": { + "name": "Advisory" + }, + "endings": { + "name": "Endings" + }, + "statements": { + "name": "Statements" + }, + "warnings": { + "name": "Warnings" + }, + "watches": { + "name": "Watches" + } + }, + "camera": { + "radar": { + "name": "Radar" + } + }, + "weather": { + "hourly_forecast": { + "name": "Hourly forecast" + }, + "forecast": { + "name": "Forecast" + } + } } } diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 32ccfa901db..a9f79907b54 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -80,7 +80,7 @@ class ECWeather(CoordinatorEntity, WeatherEntity): super().__init__(coordinator) self.ec_data = coordinator.ec_data self._attr_attribution = self.ec_data.metadata["attribution"] - self._attr_name = "Hourly forecast" if hourly else "Forecast" + self._attr_translation_key = "hourly_forecast" if hourly else "forecast" self._attr_unique_id = ( f"{coordinator.config_entry.unique_id}{'-hourly' if hourly else '-daily'}" ) From bc8be9caead6a631fdd526ca777eea78fe5dafea Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Jun 2023 16:26:16 -0400 Subject: [PATCH 583/857] Rename HomeKit Controller to HomeKit Device (#95286) --- .../components/homekit_controller/connection.py | 12 ++++++------ .../components/homekit_controller/manifest.json | 2 +- .../components/homekit_controller/strings.json | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index db85dbda3d5..b937e7f2e0b 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,7 +272,7 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), - name=f"HomeKit Controller {self.unique_id} BLE availability " + name=f"HomeKit Device {self.unique_id} BLE availability " "check poll", ) ) @@ -291,7 +291,7 @@ class HKDevice: self.hass, self.async_request_update, self.pairing.poll_interval, - name=f"HomeKit Controller {self.unique_id} availability check poll", + name=f"HomeKit Device {self.unique_id} availability check poll", ) ) @@ -714,7 +714,7 @@ class HKDevice: if not self._polling_lock_warned: _LOGGER.warning( ( - "HomeKit controller update skipped as previous poll still in" + "HomeKit device update skipped as previous poll still in" " flight: %s" ), self.unique_id, @@ -725,7 +725,7 @@ class HKDevice: if self._polling_lock_warned: _LOGGER.info( ( - "HomeKit controller no longer detecting back pressure - not" + "HomeKit device no longer detecting back pressure - not" " skipping poll: %s" ), self.unique_id, @@ -733,7 +733,7 @@ class HKDevice: self._polling_lock_warned = False async with self._polling_lock: - _LOGGER.debug("Starting HomeKit controller update: %s", self.unique_id) + _LOGGER.debug("Starting HomeKit device update: %s", self.unique_id) try: new_values_dict = await self.get_characteristics( @@ -755,7 +755,7 @@ class HKDevice: self._poll_failures = 0 self.process_new_events(new_values_dict) - _LOGGER.debug("Finished HomeKit controller update: %s", self.unique_id) + _LOGGER.debug("Finished HomeKit device update: %s", self.unique_id) def process_new_events( self, new_values_dict: dict[tuple[int, int], dict[str, Any]] diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 19167e762e9..d0a88bf8249 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -1,6 +1,6 @@ { "domain": "homekit_controller", - "name": "HomeKit Controller", + "name": "HomeKit Device", "after_dependencies": ["thread"], "bluetooth": [ { diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 2291f66d88a..7420ef7f3f9 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -1,18 +1,18 @@ { - "title": "HomeKit Controller", + "title": "HomeKit Device", "config": { "flow_title": "{name} ({category})", "step": { "user": { "title": "Device selection", - "description": "HomeKit Controller communicates over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Select the device you want to pair with:", + "description": "HomeKit Device communicates over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Select the device you want to pair with:", "data": { "device": "Device" } }, "pair": { "title": "Pair with a device via HomeKit Accessory Protocol", - "description": "HomeKit Controller communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", + "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", "data": { "pairing_code": "Pairing Code", "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." From 433d6400719cd2fc7afb53891ff28509f1a33a70 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 22:27:19 +0200 Subject: [PATCH 584/857] Use entity registry id in light device actions (#95271) --- .../components/light/device_action.py | 10 +- tests/components/light/test_device_action.py | 214 ++++++++++++++++-- tests/components/zha/test_device_action.py | 6 +- 3 files changed, 199 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 2e3338e1253..2b49c963438 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -113,7 +113,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if brightness_supported(supported_color_modes): @@ -137,16 +137,12 @@ async def async_get_action_capabilities( if config[CONF_TYPE] != toggle_entity.CONF_TURN_ON: return {} - entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) - try: + entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) supported_color_modes = get_supported_color_modes(hass, entry.entity_id) - except HomeAssistantError: - supported_color_modes = None - - try: supported_features = get_supported_features(hass, entry.entity_id) except HomeAssistantError: + supported_color_modes = None supported_features = 0 extra_fields = {} diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 4f97eaec012..dcb52d68a79 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -60,8 +60,7 @@ async def test_get_actions( supported_features=LightEntityFeature.FLASH, capabilities={"supported_color_modes": ["brightness"]}, ) - expected_actions = [] - expected_actions += [ + expected_actions = [ { "domain": DOMAIN, "type": action, @@ -69,24 +68,13 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in [ - "turn_off", - "turn_on", - "toggle", - ] - ] - expected_actions += [ - { - "domain": DOMAIN, - "type": action, - "device_id": device_entry.id, - "entity_id": entity_entry.entity_id, - "metadata": {"secondary": False}, - } for action in [ "brightness_decrease", "brightness_increase", "flash", + "turn_off", + "turn_on", + "toggle", ] ] actions = await async_get_device_automations( @@ -332,6 +320,154 @@ async def test_get_action_capabilities_features( assert capabilities == expected +@pytest.mark.parametrize( + ( + "set_state", + "expected_actions", + "supported_features_reg", + "supported_features_state", + "capabilities_reg", + "attributes_state", + "expected_capabilities", + ), + [ + ( + False, + { + "turn_on", + "toggle", + "turn_off", + "brightness_increase", + "brightness_decrease", + }, + 0, + 0, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.BRIGHTNESS]}, + {}, + { + "turn_on": [ + { + "name": "brightness_pct", + "optional": True, + "type": "float", + "valueMax": 100, + "valueMin": 0, + } + ] + }, + ), + ( + True, + { + "turn_on", + "toggle", + "turn_off", + "brightness_increase", + "brightness_decrease", + }, + 0, + 0, + None, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.BRIGHTNESS]}, + { + "turn_on": [ + { + "name": "brightness_pct", + "optional": True, + "type": "float", + "valueMax": 100, + "valueMin": 0, + } + ] + }, + ), + ( + False, + {"turn_on", "toggle", "turn_off", "flash"}, + LightEntityFeature.FLASH, + 0, + None, + {}, + { + "turn_on": [ + { + "name": "flash", + "optional": True, + "type": "select", + "options": [("short", "short"), ("long", "long")], + } + ] + }, + ), + ( + True, + {"turn_on", "toggle", "turn_off", "flash"}, + 0, + LightEntityFeature.FLASH, + None, + {}, + { + "turn_on": [ + { + "name": "flash", + "optional": True, + "type": "select", + "options": [("short", "short"), ("long", "long")], + } + ] + }, + ), + ], +) +async def test_get_action_capabilities_features_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + set_state, + expected_actions, + supported_features_reg, + supported_features_state, + capabilities_reg, + attributes_state, + expected_capabilities, +) -> None: + """Test we get the expected capabilities from a light action.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_id = entity_registry.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=supported_features_reg, + capabilities=capabilities_reg, + ).entity_id + if set_state: + hass.states.async_set( + entity_id, + None, + {"supported_features": supported_features_state, **attributes_state}, + ) + + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + assert len(actions) == len(expected_actions) + action_types = {action["type"] for action in actions} + assert action_types == expected_actions + for action in actions: + action["entity_id"] = entity_registry.async_get(action["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.ACTION, action + ) + expected = {"extra_fields": expected_capabilities.get(action["type"], [])} + assert capabilities == expected + + async def test_action( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -378,7 +514,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "flash", }, }, @@ -387,7 +523,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "flash", "flash": "long", }, @@ -400,7 +536,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "brightness_increase", }, }, @@ -412,7 +548,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "brightness_decrease", }, }, @@ -421,7 +557,7 @@ async def test_action( "action": { "domain": DOMAIN, "device_id": "", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "turn_on", "brightness_pct": 75, }, @@ -486,3 +622,39 @@ async def test_action( await hass.async_block_till_done() assert len(turn_on_calls) == 6 assert turn_on_calls[-1].data == {"entity_id": entry.entity_id, "flash": FLASH_LONG} + + +async def test_action_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls, + enable_custom_integrations: None, +) -> None: + """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_off"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": entry.entity_id, + "type": "turn_off", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + turn_off_calls = async_mock_service(hass, DOMAIN, "turn_off") + + hass.bus.async_fire("test_off") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index d9357fb38fd..beb085408e0 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -216,21 +216,21 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non { "device_id": inovelli_reg_device.id, "domain": Platform.LIGHT, - "entity_id": "light.inovelli_vzm31_sn_light", + "entity_id": inovelli_light.id, "metadata": {"secondary": False}, "type": "brightness_increase", }, { "device_id": inovelli_reg_device.id, "domain": Platform.LIGHT, - "entity_id": "light.inovelli_vzm31_sn_light", + "entity_id": inovelli_light.id, "metadata": {"secondary": False}, "type": "brightness_decrease", }, { "device_id": inovelli_reg_device.id, "domain": Platform.LIGHT, - "entity_id": "light.inovelli_vzm31_sn_light", + "entity_id": inovelli_light.id, "metadata": {"secondary": False}, "type": "flash", }, From ec120608c299dbd405e2d2bc565b250f4706e3ae Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 22:28:12 +0200 Subject: [PATCH 585/857] Add entity translations to edl21 (#95289) --- homeassistant/components/edl21/sensor.py | 82 +++++++++++------- homeassistant/components/edl21/strings.json | 94 +++++++++++++++++++++ 2 files changed, 146 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 251a25ccc24..3ce42198fbd 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -50,41 +50,47 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0 Ownership ID SensorEntityDescription( key="1-0:0.0.0*255", - name="Ownership ID", + translation_key="ownership_id", icon="mdi:flash", entity_registry_enabled_default=False, ), # E=9: Electrity ID SensorEntityDescription( - key="1-0:0.0.9*255", name="Electricity ID", icon="mdi:flash" + key="1-0:0.0.9*255", + translation_key="electricity_id", + icon="mdi:flash", ), # D=2: Program entries SensorEntityDescription( - key="1-0:0.2.0*0", name="Configuration program version number", icon="mdi:flash" + key="1-0:0.2.0*0", + translation_key="configuration_program_version_number", + icon="mdi:flash", ), SensorEntityDescription( - key="1-0:0.2.0*1", name="Firmware version number", icon="mdi:flash" + key="1-0:0.2.0*1", + translation_key="firmware_version_number", + icon="mdi:flash", ), # C=1: Active power + # D=8: Time integral 1 # E=0: Total SensorEntityDescription( key="1-0:1.8.0*255", - name="Positive active energy total", + translation_key="positive_active_energy_total", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=1: Rate 1 SensorEntityDescription( key="1-0:1.8.1*255", - name="Positive active energy in tariff T1", + translation_key="positive_active_energy_tariff_t1", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=2: Rate 2 SensorEntityDescription( key="1-0:1.8.2*255", - name="Positive active energy in tariff T2", + translation_key="positive_active_energy_tariff_t2", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), @@ -92,28 +98,28 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:1.17.0*255", - name="Last signed positive active energy total", + translation_key="last_signed_positive_active_energy_total", ), # C=2: Active power - # D=8: Time integral 1 # E=0: Total SensorEntityDescription( key="1-0:2.8.0*255", - name="Negative active energy total", + translation_key="negative_active_energy_total", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=1: Rate 1 SensorEntityDescription( key="1-0:2.8.1*255", - name="Negative active energy in tariff T1", + translation_key="negative_active_energy_tariff_t1", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=2: Rate 2 SensorEntityDescription( key="1-0:2.8.2*255", - name="Negative active energy in tariff T2", + translation_key="negative_active_energy_tariff_t2", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), @@ -121,14 +127,16 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # D=7: Instantaneous value # E=0: Total SensorEntityDescription( - key="1-0:14.7.0*255", name="Supply frequency", icon="mdi:sine-wave" + key="1-0:14.7.0*255", + translation_key="supply_frequency", + icon="mdi:sine-wave", ), # C=15: Active power absolute # D=7: Instantaneous value # E=0: Total SensorEntityDescription( key="1-0:15.7.0*255", - name="Absolute active instantaneous power", + translation_key="absolute_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -137,7 +145,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:16.7.0*255", - name="Sum active instantaneous power", + translation_key="sum_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -146,7 +154,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:31.7.0*255", - name="L1 active instantaneous amperage", + translation_key="l1_active_instantaneous_amperage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, ), @@ -155,7 +163,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:32.7.0*255", - name="L1 active instantaneous voltage", + translation_key="l1_active_instantaneous_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), @@ -164,7 +172,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:36.7.0*255", - name="L1 active instantaneous power", + translation_key="l1_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -173,7 +181,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:51.7.0*255", - name="L2 active instantaneous amperage", + translation_key="l2_active_instantaneous_amperage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, ), @@ -182,7 +190,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:52.7.0*255", - name="L2 active instantaneous voltage", + translation_key="l2_active_instantaneous_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), @@ -191,7 +199,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:56.7.0*255", - name="L2 active instantaneous power", + translation_key="l2_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -200,7 +208,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:71.7.0*255", - name="L3 active instantaneous amperage", + translation_key="l3_active_instantaneous_amperage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, ), @@ -209,7 +217,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:72.7.0*255", - name="L3 active instantaneous voltage", + translation_key="l3_active_instantaneous_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), @@ -218,7 +226,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:76.7.0*255", - name="L3 active instantaneous power", + translation_key="l3_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -230,26 +238,40 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=15: U(L2) x I(L2) # E=26: U(L3) x I(L3) SensorEntityDescription( - key="1-0:81.7.1*255", name="U(L2)/U(L1) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.1*255", + translation_key="u_l2_u_l1_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.2*255", name="U(L3)/U(L1) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.2*255", + translation_key="u_l3_u_l1_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.4*255", name="U(L1)/I(L1) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.4*255", + translation_key="u_l1_i_l1_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.15*255", name="U(L2)/I(L2) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.15*255", + translation_key="u_l2_i_l2_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.26*255", name="U(L3)/I(L3) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.26*255", + translation_key="u_l3_i_l3_phase_angle", + icon="mdi:sine-wave", ), # C=96: Electricity-related service entries SensorEntityDescription( - key="1-0:96.1.0*255", name="Metering point ID 1", icon="mdi:flash" + key="1-0:96.1.0*255", + translation_key="metering_point_id_1", + icon="mdi:flash", ), SensorEntityDescription( - key="1-0:96.5.0*255", name="Internal operating status", icon="mdi:flash" + key="1-0:96.5.0*255", + translation_key="internal_operating_status", + icon="mdi:flash", ), ) diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json index 764cc41d2a4..43978642943 100644 --- a/homeassistant/components/edl21/strings.json +++ b/homeassistant/components/edl21/strings.json @@ -11,5 +11,99 @@ } } } + }, + "entity": { + "sensor": { + "ownership_id": { + "name": "Ownership ID" + }, + "electricity_id": { + "name": "Electricity ID" + }, + "configuration_program_version_number": { + "name": "Configuration program version number" + }, + "firmware_version_number": { + "name": "Firmware version number" + }, + "positive_active_energy_total": { + "name": "Positive active energy total" + }, + "positive_active_energy_tariff_t1": { + "name": "Positive active energy in tariff T1" + }, + "positive_active_energy_tariff_t2": { + "name": "Positive active energy in tariff T2" + }, + "last_signed_positive_active_energy_total": { + "name": "Last signed positive active energy total" + }, + "negative_active_energy_total": { + "name": "Negative active energy total" + }, + "negative_active_energy_tariff_t1": { + "name": "Negative active energy in tariff T1" + }, + "negative_active_energy_tariff_t2": { + "name": "Negative active energy in tariff T2" + }, + "supply_frequency": { + "name": "Supply frequency" + }, + "absolute_active_instantaneous_power": { + "name": "Absolute active instantaneous power" + }, + "sum_active_instantaneous_power": { + "name": "Sum active instantaneous power" + }, + "l1_active_instantaneous_amperage": { + "name": "L1 active instantaneous amperage" + }, + "l1_active_instantaneous_voltage": { + "name": "L1 active instantaneous voltage" + }, + "l1_active_instantaneous_power": { + "name": "L1 active instantaneous power" + }, + "l2_active_instantaneous_amperage": { + "name": "L2 active instantaneous amperage" + }, + "l2_active_instantaneous_voltage": { + "name": "L2 active instantaneous voltage" + }, + "l2_active_instantaneous_power": { + "name": "L2 active instantaneous power" + }, + "l3_active_instantaneous_amperage": { + "name": "L3 active instantaneous amperage" + }, + "l3_active_instantaneous_voltage": { + "name": "L3 active instantaneous voltage" + }, + "l3_active_instantaneous_power": { + "name": "L3 active instantaneous power" + }, + "u_l2_u_l1_phase_angle": { + "name": "U(L2)/U(L1) phase angle" + }, + "u_l3_u_l1_phase_angle": { + "name": "U(L3)/U(L1) phase angle" + }, + "u_l1_i_l1_phase_angle": { + "name": "U(L1)/I(L1) phase angle" + }, + "u_l2_i_l2_phase_angle": { + "name": "U(L2)/I(L2) phase angle" + }, + "u_l3_i_l3_phase_angle": { + "name": "U(L3)/I(L3) phase angle" + }, + "metering_point_id_1": { + "name": "Metering point ID 1" + }, + "internal_operating_status": { + "name": "Internal operating status" + } + } } } From 4efe217d9b5eccf06206c5d5e72eacc1a41ca62d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 22:29:14 +0200 Subject: [PATCH 586/857] Use entity registry id in select device actions (#95274) --- .../components/device_automation/helpers.py | 1 + .../components/select/device_action.py | 30 ++- tests/components/select/test_device_action.py | 192 ++++++++++++++++-- tests/components/zha/test_device_action.py | 21 +- 4 files changed, 213 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index e228b64bed8..8a7fcd95f48 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -32,6 +32,7 @@ ENTITY_PLATFORMS = { Platform.HUMIDIFIER.value, Platform.LIGHT.value, Platform.REMOTE.value, + Platform.SELECT.value, Platform.SWITCH.value, } diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index d553cdf3043..a7d47d8c833 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -5,6 +5,10 @@ from contextlib import suppress import voluptuous as vol +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, + async_validate_entity_schema, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -33,43 +37,50 @@ from .const import ( SERVICE_SELECT_PREVIOUS, ) -ACTION_SCHEMA = vol.Any( +_ACTION_SCHEMA = vol.Any( cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SELECT_FIRST, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ), cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SELECT_LAST, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ), cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SELECT_NEXT, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional(CONF_CYCLE, default=True): cv.boolean, } ), cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SELECT_PREVIOUS, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional(CONF_CYCLE, default=True): cv.boolean, } ), cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SELECT_OPTION, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_OPTION): cv.string, } ), ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -79,7 +90,7 @@ async def async_get_actions( { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: service_conf_type, } for service_conf_type in ( @@ -130,7 +141,10 @@ async def async_get_action_capabilities( if config[CONF_TYPE] == SERVICE_SELECT_OPTION: options: list[str] = [] with suppress(HomeAssistantError): - options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) + options = get_capability(hass, entry.entity_id, ATTR_OPTIONS) or [] return { "extra_fields": vol.Schema({vol.Required(CONF_OPTION): vol.In(options)}) } diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index a517d16ad9e..ce5d48bb358 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -35,7 +35,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_actions = [ @@ -43,7 +43,7 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": "select.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in [ @@ -83,7 +83,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -97,7 +97,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in [ @@ -115,8 +115,12 @@ async def test_get_actions_hidden_auxiliary( @pytest.mark.parametrize("action_type", ("select_first", "select_last")) -async def test_action_select_first_last(hass: HomeAssistant, action_type: str) -> None: +async def test_action_select_first_last( + hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str +) -> None: """Test for select_first and select_last actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -130,7 +134,7 @@ async def test_action_select_first_last(hass: HomeAssistant, action_type: str) - "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "select.entity", + "entity_id": entry.id, "type": action_type, }, }, @@ -145,11 +149,16 @@ async def test_action_select_first_last(hass: HomeAssistant, action_type: str) - assert len(select_calls) == 1 assert select_calls[0].domain == DOMAIN assert select_calls[0].service == action_type - assert select_calls[0].data == {"entity_id": "select.entity"} + assert select_calls[0].data == {"entity_id": entry.entity_id} -async def test_action_select_option(hass: HomeAssistant) -> None: - """Test for select_option action.""" +@pytest.mark.parametrize("action_type", ("select_first", "select_last")) +async def test_action_select_first_last_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str +) -> None: + """Test for select_first and select_last actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -163,7 +172,44 @@ async def test_action_select_option(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "select.entity", + "entity_id": entry.entity_id, + "type": action_type, + }, + }, + ] + }, + ) + + select_calls = async_mock_service(hass, DOMAIN, action_type) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(select_calls) == 1 + assert select_calls[0].domain == DOMAIN + assert select_calls[0].service == action_type + assert select_calls[0].data == {"entity_id": entry.entity_id} + + +async def test_action_select_option( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for select_option action.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.id, "type": "select_option", "option": "option1", }, @@ -179,14 +225,16 @@ async def test_action_select_option(hass: HomeAssistant) -> None: assert len(select_calls) == 1 assert select_calls[0].domain == DOMAIN assert select_calls[0].service == "select_option" - assert select_calls[0].data == {"entity_id": "select.entity", "option": "option1"} + assert select_calls[0].data == {"entity_id": entry.entity_id, "option": "option1"} @pytest.mark.parametrize("action_type", ["select_next", "select_previous"]) async def test_action_select_next_previous( - hass: HomeAssistant, action_type: str + hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str ) -> None: """Test for select_next and select_previous actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -200,7 +248,7 @@ async def test_action_select_next_previous( "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "select.entity", + "entity_id": entry.id, "type": action_type, "cycle": False, }, @@ -216,16 +264,20 @@ async def test_action_select_next_previous( assert len(select_calls) == 1 assert select_calls[0].domain == DOMAIN assert select_calls[0].service == action_type - assert select_calls[0].data == {"entity_id": "select.entity", "cycle": False} + assert select_calls[0].data == {"entity_id": entry.entity_id, "cycle": False} -async def test_get_action_capabilities(hass: HomeAssistant) -> None: +async def test_get_action_capabilities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we get the expected capabilities from a select action.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config = { "platform": "device", "domain": DOMAIN, "type": "select_option", - "entity_id": "select.test", + "entity_id": entry.id, "option": "option1", } @@ -245,7 +297,9 @@ async def test_get_action_capabilities(hass: HomeAssistant) -> None: ] # Mock an entity - hass.states.async_set("select.test", "option1", {"options": ["option1", "option2"]}) + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2"]} + ) # Test if we get the right capabilities now capabilities = await async_get_action_capabilities(hass, config) @@ -267,7 +321,7 @@ async def test_get_action_capabilities(hass: HomeAssistant) -> None: "platform": "device", "domain": DOMAIN, "type": "select_next", - "entity_id": "select.test", + "entity_id": entry.id, } capabilities = await async_get_action_capabilities(hass, config) assert capabilities @@ -303,7 +357,107 @@ async def test_get_action_capabilities(hass: HomeAssistant) -> None: "platform": "device", "domain": DOMAIN, "type": "select_first", - "entity_id": "select.test", + "entity_id": entry.id, + } + capabilities = await async_get_action_capabilities(hass, config) + assert capabilities == {} + + config["type"] = "select_last" + capabilities = await async_get_action_capabilities(hass, config) + assert capabilities == {} + + +async def test_get_action_capabilities_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test we get the expected capabilities from a select action.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + config = { + "platform": "device", + "domain": DOMAIN, + "type": "select_option", + "entity_id": entry.entity_id, + "option": "option1", + } + + # Test when entity doesn't exists + capabilities = await async_get_action_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "option", + "required": True, + "type": "select", + "options": [], + }, + ] + + # Mock an entity + hass.states.async_set( + entry.entity_id, "option1", {"options": ["option1", "option2"]} + ) + + # Test if we get the right capabilities now + capabilities = await async_get_action_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "option", + "required": True, + "type": "select", + "options": [("option1", "option1"), ("option2", "option2")], + }, + ] + + # Test next/previous actions + config = { + "platform": "device", + "domain": DOMAIN, + "type": "select_next", + "entity_id": entry.entity_id, + } + capabilities = await async_get_action_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "cycle", + "optional": True, + "type": "boolean", + "default": True, + }, + ] + + config["type"] = "select_previous" + capabilities = await async_get_action_capabilities(hass, config) + assert capabilities + assert "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "cycle", + "optional": True, + "type": "boolean", + "default": True, + }, + ] + + # Test action types without extra fields + config = { + "platform": "device", + "domain": DOMAIN, + "type": "select_first", + "entity_id": entry.entity_id, } capabilities = await async_get_action_capabilities(hass, config) assert capabilities == {} diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index beb085408e0..d938512981f 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -114,6 +114,19 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: ha_device_registry = dr.async_get(hass) reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}) + ha_entity_registry = er.async_get(hass) + siren_level_select = ha_entity_registry.async_get( + "select.fakemanufacturer_fakemodel_default_siren_level" + ) + siren_tone_select = ha_entity_registry.async_get( + "select.fakemanufacturer_fakemodel_default_siren_tone" + ) + strobe_level_select = ha_entity_registry.async_get( + "select.fakemanufacturer_fakemodel_default_strobe_level" + ) + strobe_select = ha_entity_registry.async_get( + "select.fakemanufacturer_fakemodel_default_strobe" + ) actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, reg_device.id @@ -145,10 +158,10 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: "select_previous", ] for entity_id in [ - "select.fakemanufacturer_fakemodel_default_siren_level", - "select.fakemanufacturer_fakemodel_default_siren_tone", - "select.fakemanufacturer_fakemodel_default_strobe_level", - "select.fakemanufacturer_fakemodel_default_strobe", + siren_level_select.id, + siren_tone_select.id, + strobe_level_select.id, + strobe_select.id, ] ] ) From c6775920f59b940cfe6fdd2b020c99875054bf95 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 26 Jun 2023 16:39:10 -0400 Subject: [PATCH 587/857] Tweak Dremel 3D Printer sensors (#94552) --- .../components/dremel_3d_printer/sensor.py | 21 +++++++------------ .../components/dremel_3d_printer/strings.json | 4 ++-- .../dremel_3d_printer/test_sensor.py | 15 ++++++------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index 71e60dc04fc..660e7a90487 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -56,11 +56,11 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( ), Dremel3DPrinterSensorEntityDescription( key="remaining_time", - translation_key="remaining_time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, available_fn=lambda api, key: api.get_job_status()[key] > 0, value_fn=ignore_variance( - lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), + lambda api, key: utcnow() + timedelta(seconds=api.get_job_status()[key]), timedelta(minutes=2), ), ), @@ -170,26 +170,22 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( Dremel3DPrinterSensorEntityDescription( key="elapsed_time", translation_key="elapsed_time", - device_class=SensorDeviceClass.TIMESTAMP, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, available_fn=lambda api, _: api.get_printing_status() == "building", - value_fn=ignore_variance( - lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), - timedelta(minutes=2), - ), + value_fn=lambda api, key: api.get_job_status()[key], ), Dremel3DPrinterSensorEntityDescription( key="estimated_total_time", translation_key="estimated_total_time", - device_class=SensorDeviceClass.TIMESTAMP, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, available_fn=lambda api, key: api.get_job_status()[key] > 0, - value_fn=ignore_variance( - lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), - timedelta(minutes=2), - ), + value_fn=lambda api, key: api.get_job_status()[key], ), Dremel3DPrinterSensorEntityDescription( key="job_status", @@ -245,7 +241,6 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( translation_key="hours_used", icon="mdi:clock", native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, key: api.get_printer_info()[key], diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 08d2a001d2d..77a9eac7a13 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -20,8 +20,8 @@ "job_phase": { "name": "Job phase" }, - "remaining_time": { - "name": "Remaining time" + "completion_time": { + "name": "Completion time" }, "progress": { "name": "Progress" diff --git a/tests/components/dremel_3d_printer/test_sensor.py b/tests/components/dremel_3d_printer/test_sensor.py index b38a5feff36..49d66fe1e61 100644 --- a/tests/components/dremel_3d_printer/test_sensor.py +++ b/tests/components/dremel_3d_printer/test_sensor.py @@ -38,8 +38,8 @@ async def test_sensors( assert await async_setup_component(hass, DOMAIN, {}) state = hass.states.get("sensor.dremel_3d45_job_phase") assert state.state == "building" - state = hass.states.get("sensor.dremel_3d45_remaining_time") - assert state.state == "2023-05-31T12:27:44+00:00" + state = hass.states.get("sensor.dremel_3d45_completion_time") + assert state.state == "2023-05-31T14:32:16+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP state = hass.states.get("sensor.dremel_3d45_progress") assert state.state == "13.9" @@ -85,11 +85,13 @@ async def test_sensors( state = hass.states.get("sensor.dremel_3d45_filament") assert state.state == "ECO-ABS" state = hass.states.get("sensor.dremel_3d45_elapsed_time") - assert state.state == "2023-05-31T13:30:00+00:00" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert state.state == "0" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.SECONDS state = hass.states.get("sensor.dremel_3d45_estimated_total_time") - assert state.state == "2023-05-31T12:17:40+00:00" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert state.state == "4340" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.SECONDS state = hass.states.get("sensor.dremel_3d45_job_status") assert state.state == "building" state = hass.states.get("sensor.dremel_3d45_job_name") @@ -107,4 +109,3 @@ async def test_sensors( state = hass.states.get("sensor.dremel_3d45_hours_used") assert state.state == "7" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTime.HOURS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION From 0f08e6699c39419446ab3b1bf7a08d48efa9f6ff Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 26 Jun 2023 15:47:32 -0500 Subject: [PATCH 588/857] Add VAD sensitivity to ESPHome (#95283) * Change to "finished speaking detection" * Add select entity to ESPHome for finished speaking detection * Fix entity name * Use vad select in stt stream --------- Co-authored-by: J. Nick Koston --- .../components/assist_pipeline/strings.json | 2 +- homeassistant/components/esphome/select.py | 21 +++++++++++++++++-- homeassistant/components/esphome/strings.json | 8 +++++++ .../components/esphome/voice_assistant.py | 20 ++++++++++++++---- tests/components/esphome/test_select.py | 14 +++++++++++++ tests/components/voip/test_select.py | 2 +- 6 files changed, 59 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index edcdff752f6..8fa67879fc3 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -13,7 +13,7 @@ } }, "vad_sensitivity": { - "name": "Silence sensitivity", + "name": "Finished speaking detection", "state": { "default": "Default", "aggressive": "Aggressive", diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 9849f7cded8..a3464b137dc 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -3,7 +3,10 @@ from __future__ import annotations from aioesphomeapi import EntityInfo, SelectInfo, SelectState -from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.assist_pipeline.select import ( + AssistPipelineSelect, + VadSensitivitySelect, +) from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -37,7 +40,12 @@ async def async_setup_entry( entry_data = DomainData.get(hass).get_entry_data(entry) assert entry_data.device_info is not None if entry_data.device_info.voice_assistant_version: - async_add_entities([EsphomeAssistPipelineSelect(hass, entry_data)]) + async_add_entities( + [ + EsphomeAssistPipelineSelect(hass, entry_data), + EsphomeVadSensitivitySelect(hass, entry_data), + ] + ) class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): @@ -68,3 +76,12 @@ class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): """Initialize a pipeline selector.""" EsphomeAssistEntity.__init__(self, entry_data) AssistPipelineSelect.__init__(self, hass, self._device_info.mac_address) + + +class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect): + """VAD sensitivity selector for VoIP devices.""" + + def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + """Initialize a VAD sensitivity selector.""" + EsphomeAssistEntity.__init__(self, entry_data) + VadSensitivitySelect.__init__(self, hass, self._device_info.mac_address) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 915e55fde32..2ec1fe1bc41 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -67,6 +67,14 @@ "state": { "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } + }, + "vad_sensitivity": { + "name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]", + "state": { + "default": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::default%]", + "aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]", + "relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]" + } } } }, diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 4f6131f449b..6b49549d812 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -19,7 +19,10 @@ from homeassistant.components.assist_pipeline import ( async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.components.assist_pipeline.vad import ( + VadSensitivity, + VoiceCommandSegmenter, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -251,9 +254,9 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): chunk = await self.queue.get() async def _iterate_packets_with_vad( - self, pipeline_timeout: float + self, pipeline_timeout: float, silence_seconds: float ) -> Callable[[], AsyncIterable[bytes]] | None: - segmenter = VoiceCommandSegmenter() + segmenter = VoiceCommandSegmenter(silence_seconds=silence_seconds) chunk_buffer: deque[bytes] = deque(maxlen=100) try: async with async_timeout.timeout(pipeline_timeout): @@ -305,7 +308,16 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): ) if use_vad: - stt_stream = await self._iterate_packets_with_vad(pipeline_timeout) + stt_stream = await self._iterate_packets_with_vad( + pipeline_timeout, + silence_seconds=VadSensitivity.to_seconds( + pipeline_select.get_vad_sensitivity( + self.hass, + DOMAIN, + self.device_info.mac_address, + ) + ), + ) # Error or timeout occurred and was handled already if stt_stream is None: return diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 5f6974ec035..8d17276c304 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -25,6 +25,20 @@ async def test_pipeline_selector( assert state.state == "preferred" +async def test_vad_sensitivity_select( + hass: HomeAssistant, + mock_voice_assistant_v1_entry, +) -> None: + """Test VAD sensitivity select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + state = hass.states.get("select.test_finished_speaking_detection") + assert state is not None + assert state.state == "default" + + async def test_select_generic_entity( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: diff --git a/tests/components/voip/test_select.py b/tests/components/voip/test_select.py index 9d45477a429..7dd041a6866 100644 --- a/tests/components/voip/test_select.py +++ b/tests/components/voip/test_select.py @@ -29,6 +29,6 @@ async def test_vad_sensitivity_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.192_168_1_210_silence_sensitivity") + state = hass.states.get("select.192_168_1_210_finished_speaking_detection") assert state is not None assert state.state == "default" From 7737271a30bfb61c80cd9615f53de28c3c05be45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Jun 2023 15:58:17 -0500 Subject: [PATCH 589/857] Migrate esphome alarm_control_panel platform to use _on_static_info_update (#94961) --- .../components/esphome/alarm_control_panel.py | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index f69560945c3..669241b05aa 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -7,6 +7,7 @@ from aioesphomeapi import ( AlarmControlPanelInfo, AlarmControlPanelState, APIIntEnum, + EntityInfo, ) from homeassistant.components.alarm_control_panel import ( @@ -27,7 +28,7 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( @@ -85,14 +86,11 @@ class EsphomeAlarmControlPanel( ): """An Alarm Control Panel implementation for ESPHome.""" - @property - def state(self) -> str | None: - """Return the state of the device.""" - return _ESPHOME_ACP_STATE_TO_HASS_STATE.from_esphome(self._state.state) - - @property - def supported_features(self) -> AlarmControlPanelEntityFeature: - """Return the list of supported features.""" + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info feature = 0 if self._static_info.supported_features & EspHomeACPFeatures.ARM_HOME: feature |= AlarmControlPanelEntityFeature.ARM_HOME @@ -106,58 +104,55 @@ class EsphomeAlarmControlPanel( feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS if self._static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: feature |= AlarmControlPanelEntityFeature.ARM_VACATION - return AlarmControlPanelEntityFeature(feature) + self._attr_supported_features = AlarmControlPanelEntityFeature(feature) + self._attr_code_format = ( + CodeFormat.NUMBER if static_info.requires_code else None + ) + self._attr_code_arm_required = bool(static_info.requires_code_to_arm) @property - def code_format(self) -> CodeFormat | None: - """Return code format for disarm.""" - if self._static_info.requires_code: - return CodeFormat.NUMBER - return None - - @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return bool(self._static_info.requires_code_to_arm) + def state(self) -> str | None: + """Return the state of the device.""" + return _ESPHOME_ACP_STATE_TO_HASS_STATE.from_esphome(self._state.state) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._client.alarm_control_panel_command( - self._static_info.key, AlarmControlPanelCommand.DISARM, code + self._key, AlarmControlPanelCommand.DISARM, code ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._client.alarm_control_panel_command( - self._static_info.key, AlarmControlPanelCommand.ARM_HOME, code + self._key, AlarmControlPanelCommand.ARM_HOME, code ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._client.alarm_control_panel_command( - self._static_info.key, AlarmControlPanelCommand.ARM_AWAY, code + self._key, AlarmControlPanelCommand.ARM_AWAY, code ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm away command.""" await self._client.alarm_control_panel_command( - self._static_info.key, AlarmControlPanelCommand.ARM_NIGHT, code + self._key, AlarmControlPanelCommand.ARM_NIGHT, code ) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm away command.""" await self._client.alarm_control_panel_command( - self._static_info.key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code + self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code ) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm away command.""" await self._client.alarm_control_panel_command( - self._static_info.key, AlarmControlPanelCommand.ARM_VACATION, code + self._key, AlarmControlPanelCommand.ARM_VACATION, code ) async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" await self._client.alarm_control_panel_command( - self._static_info.key, AlarmControlPanelCommand.TRIGGER, code + self._key, AlarmControlPanelCommand.TRIGGER, code ) From ad9bf431a8320d2058ca5984995cb00b4f71d5fb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 23:06:27 +0200 Subject: [PATCH 590/857] Add entity translations to filesize (#95299) --- homeassistant/components/filesize/sensor.py | 6 +++--- homeassistant/components/filesize/strings.json | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 6f7e31a1e67..0b5c39f3629 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -34,17 +34,17 @@ ICON = "mdi:file" SENSOR_TYPES = ( SensorEntityDescription( key="file", + translation_key="size", icon=ICON, - name="Size", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="bytes", + translation_key="size_bytes", entity_registry_enabled_default=False, icon=ICON, - name="Size bytes", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, @@ -52,9 +52,9 @@ SENSOR_TYPES = ( ), SensorEntityDescription( key="last_updated", + translation_key="last_updated", entity_registry_enabled_default=False, icon=ICON, - name="Last Updated", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/filesize/strings.json b/homeassistant/components/filesize/strings.json index 90c286e7088..3323c3411b2 100644 --- a/homeassistant/components/filesize/strings.json +++ b/homeassistant/components/filesize/strings.json @@ -15,5 +15,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "title": "Filesize" + "title": "Filesize", + "entity": { + "sensor": { + "size": { + "name": "Size" + }, + "size_bytes": { + "name": "Size in bytes" + }, + "last_updated": { + "name": "Last updated" + } + } + } } From b12c5a5ba2cb0a0b47bd00dc504984ca26f398be Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 23:08:30 +0200 Subject: [PATCH 591/857] Use entity registry id in humidifier device actions (#95270) --- .../components/humidifier/device_action.py | 13 +- .../humidifier/test_device_action.py | 178 +++++++++++++++--- 2 files changed, 158 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index f0f2d415a6f..f1f25101e93 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -4,6 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, async_validate_entity_schema, toggle_entity, ) @@ -29,7 +30,7 @@ from . import DOMAIN, const SET_HUMIDITY_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_humidity", - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(const.ATTR_HUMIDITY): vol.Coerce(int), } ) @@ -37,7 +38,7 @@ SET_HUMIDITY_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_mode", - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(ATTR_MODE): cv.string, } ) @@ -71,7 +72,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } actions.append({**base_action, CONF_TYPE: "set_humidity"}) @@ -118,9 +119,11 @@ async def async_get_action_capabilities( fields[vol.Required(const.ATTR_HUMIDITY)] = vol.Coerce(int) elif action_type == "set_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) available_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_AVAILABLE_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] ) except HomeAssistantError: available_modes = [] diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 4e20d16ea1d..600be154fc7 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -65,14 +65,13 @@ async def test_get_actions( f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} ) expected_actions = [] - basic_action_types = ["set_humidity"] - toggle_action_types = ["turn_on", "turn_off", "toggle"] + basic_action_types = ["set_humidity", "turn_on", "turn_off", "toggle"] expected_actions += [ { "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in basic_action_types @@ -85,16 +84,6 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in toggle_action_types - ] - expected_actions += [ - { - "domain": DOMAIN, - "type": action, - "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", - "metadata": {"secondary": False}, - } for action in expected_action_types ] actions = await async_get_device_automations( @@ -135,19 +124,8 @@ async def test_get_actions_hidden_auxiliary( hidden_by=hidden_by, supported_features=0, ) - basic_action_types = ["set_humidity"] - toggle_action_types = ["turn_on", "turn_off", "toggle"] + basic_action_types = ["set_humidity", "turn_on", "turn_off", "toggle"] expected_actions = [] - expected_actions += [ - { - "domain": DOMAIN, - "type": action, - "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", - "metadata": {"secondary": True}, - } - for action in basic_action_types - ] expected_actions += [ { "domain": DOMAIN, @@ -156,7 +134,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in toggle_action_types + for action in basic_action_types ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -220,7 +198,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "set_humidity", "humidity": 35, }, @@ -233,7 +211,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": entry.entity_id, + "entity_id": entry.id, "type": "set_mode", "mode": const.MODE_AWAY, }, @@ -480,6 +458,150 @@ async def test_capabilities( capabilities_state, ) + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entity_entry.id, + "type": action, + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert ( + voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) + == expected_capabilities + ) + + +@pytest.mark.parametrize( + ( + "set_state", + "capabilities_reg", + "capabilities_state", + "action", + "expected_capabilities", + ), + [ + ( + False, + {}, + {}, + "set_humidity", + [ + { + "name": "humidity", + "required": True, + "type": "integer", + } + ], + ), + ( + False, + {}, + {}, + "set_mode", + [ + { + "name": "mode", + "options": [], + "required": True, + "type": "select", + } + ], + ), + ( + False, + {const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY]}, + {}, + "set_mode", + [ + { + "name": "mode", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ], + ), + ( + True, + {}, + {}, + "set_humidity", + [ + { + "name": "humidity", + "required": True, + "type": "integer", + } + ], + ), + ( + True, + {}, + {}, + "set_mode", + [ + { + "name": "mode", + "options": [], + "required": True, + "type": "select", + } + ], + ), + ( + True, + {}, + {const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY]}, + "set_mode", + [ + { + "name": "mode", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ], + ), + ], +) +async def test_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + set_state, + capabilities_reg, + capabilities_state, + action, + expected_capabilities, +) -> None: + """Test getting capabilities.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + capabilities=capabilities_reg, + ) + if set_state: + hass.states.async_set( + f"{DOMAIN}.test_5678", + STATE_ON, + capabilities_state, + ) + capabilities = await device_action.async_get_action_capabilities( hass, { From 320003bf15f6e4e181a8ae8d4bc4113d446fd754 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 23:09:26 +0200 Subject: [PATCH 592/857] Use entity registry id in lock device actions (#95272) --- .../components/device_automation/helpers.py | 1 + .../components/lock/device_action.py | 14 +++- tests/components/lock/test_device_action.py | 66 ++++++++++++++++--- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 8a7fcd95f48..3857ac3a467 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -31,6 +31,7 @@ ENTITY_PLATFORMS = { Platform.FAN.value, Platform.HUMIDIFIER.value, Platform.LIGHT.value, + Platform.LOCK.value, Platform.REMOTE.value, Platform.SELECT.value, Platform.SWITCH.value, diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index 01e7b21d4b6..fba95a932de 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -23,14 +24,21 @@ from . import DOMAIN, LockEntityFeature ACTION_TYPES = {"lock", "unlock", "open"} -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -49,7 +57,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } actions.append({**base_action, CONF_TYPE: "lock"}) diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index ed0ce279498..f87fa4cc178 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -48,7 +48,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -66,7 +66,7 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in basic_action_types @@ -76,7 +76,7 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in expected_action_types @@ -110,7 +110,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -125,7 +125,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["lock", "unlock"] @@ -136,8 +136,10 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant) -> None: +async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test for lock actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -148,7 +150,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "lock", }, }, @@ -157,7 +159,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "unlock", }, }, @@ -166,7 +168,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "lock.entity", + "entity_id": entry.id, "type": "open", }, }, @@ -196,3 +198,49 @@ async def test_action(hass: HomeAssistant) -> None: assert len(lock_calls) == 1 assert len(unlock_calls) == 1 assert len(open_calls) == 1 + + assert lock_calls[0].domain == DOMAIN + assert lock_calls[0].service == "lock" + assert lock_calls[0].data == {"entity_id": entry.entity_id} + assert unlock_calls[0].domain == DOMAIN + assert unlock_calls[0].service == "unlock" + assert unlock_calls[0].data == {"entity_id": entry.entity_id} + assert open_calls[0].domain == DOMAIN + assert open_calls[0].service == "open" + assert open_calls[0].data == {"entity_id": entry.entity_id} + + +async def test_action_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for lock actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event_lock"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.id, + "type": "lock", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + lock_calls = async_mock_service(hass, "lock", "lock") + + hass.bus.async_fire("test_event_lock") + await hass.async_block_till_done() + assert len(lock_calls) == 1 + + assert lock_calls[0].domain == DOMAIN + assert lock_calls[0].service == "lock" + assert lock_calls[0].data == {"entity_id": entry.entity_id} From cb9cbdfb288fae2b7915cb8bfbd18352bec7c319 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 23:12:48 +0200 Subject: [PATCH 593/857] Add entity translations to ecobee (#95281) --- homeassistant/components/ecobee/binary_sensor.py | 16 ++++------------ homeassistant/components/ecobee/climate.py | 16 ++++------------ homeassistant/components/ecobee/humidifier.py | 16 ++++------------ homeassistant/components/ecobee/number.py | 5 ++--- homeassistant/components/ecobee/sensor.py | 8 ++------ homeassistant/components/ecobee/strings.json | 10 ++++++++++ homeassistant/components/ecobee/weather.py | 15 ++++----------- tests/components/ecobee/test_climate.py | 3 ++- 8 files changed, 32 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 2266d70e0ad..e65dc221a9f 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -35,19 +35,16 @@ async def async_setup_entry( class EcobeeBinarySensor(BinarySensorEntity): """Representation of an Ecobee sensor.""" + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY + _attr_has_entity_name = True + def __init__(self, data, sensor_name, sensor_index): """Initialize the Ecobee sensor.""" self.data = data - self._name = f"{sensor_name} Occupancy" - self.sensor_name = sensor_name + self.sensor_name = sensor_name.rstrip() self.index = sensor_index self._state = None - @property - def name(self): - """Return the name of the Ecobee sensor.""" - return self._name.rstrip() - @property def unique_id(self): """Return a unique identifier for this sensor.""" @@ -101,11 +98,6 @@ class EcobeeBinarySensor(BinarySensorEntity): """Return the status of the sensor.""" return self._state == "true" - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return BinarySensorDeviceClass.OCCUPANCY - async def async_update(self) -> None: """Get the latest state of the sensor.""" await self.data.update() diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 7925832953b..8c0b77b913d 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -310,6 +310,8 @@ class Thermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_name = None + _attr_has_entity_name = True def __init__( self, data: EcobeeData, thermostat_index: int, thermostat: dict @@ -318,7 +320,7 @@ class Thermostat(ClimateEntity): self.data = data self.thermostat_index = thermostat_index self.thermostat = thermostat - self._name = self.thermostat["name"] + self._attr_unique_id = self.thermostat["identifier"] self.vacation = None self._last_active_hvac_mode = HVACMode.HEAT_COOL @@ -364,16 +366,6 @@ class Thermostat(ClimateEntity): supported = supported | ClimateEntityFeature.AUX_HEAT return supported - @property - def name(self): - """Return the name of the Ecobee Thermostat.""" - return self.thermostat["name"] - - @property - def unique_id(self): - """Return a unique identifier for this ecobee thermostat.""" - return self.thermostat["identifier"] - @property def device_info(self) -> DeviceInfo: """Return device information for this ecobee thermostat.""" @@ -388,7 +380,7 @@ class Thermostat(ClimateEntity): identifiers={(DOMAIN, self.thermostat["identifier"])}, manufacturer=MANUFACTURER, model=model, - name=self.name, + name=self.thermostat["name"], ) @property diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index f4c4dad6527..fb5533adf07 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -44,27 +44,19 @@ class EcobeeHumidifier(HumidifierEntity): """A humidifier class for an ecobee thermostat with humidifier attached.""" _attr_supported_features = HumidifierEntityFeature.MODES + _attr_has_entity_name = True + _attr_name = None def __init__(self, data, thermostat_index): """Initialize ecobee humidifier platform.""" self.data = data self.thermostat_index = thermostat_index self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) - self._name = self.thermostat["name"] + self._attr_unique_id = self.thermostat["identifier"] self._last_humidifier_on_mode = MODE_MANUAL self.update_without_throttle = False - @property - def name(self): - """Return the name of the humidifier.""" - return self._name - - @property - def unique_id(self): - """Return unique_id for humidifier.""" - return f"{self.thermostat['identifier']}" - @property def device_info(self) -> DeviceInfo: """Return device information for the ecobee humidifier.""" @@ -79,7 +71,7 @@ class EcobeeHumidifier(HumidifierEntity): identifiers={(DOMAIN, self.thermostat["identifier"])}, manufacturer=MANUFACTURER, model=model, - name=self.name, + name=self.thermostat["name"], ) @property diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 15ad17b0e39..67c975010ab 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -36,7 +36,7 @@ class EcobeeNumberEntityDescription( VENTILATOR_NUMBERS = ( EcobeeNumberEntityDescription( key="home", - name="home", + translation_key="ventilator_min_type_home", ecobee_setting_key="ventilatorMinOnTimeHome", set_fn=lambda data, id, min_time: data.ecobee.set_ventilator_min_on_time_home( id, min_time @@ -44,7 +44,7 @@ VENTILATOR_NUMBERS = ( ), EcobeeNumberEntityDescription( key="away", - name="away", + translation_key="ventilator_min_type_away", ecobee_setting_key="ventilatorMinOnTimeAway", set_fn=lambda data, id, min_time: data.ecobee.set_ventilator_min_on_time_away( id, min_time @@ -92,7 +92,6 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): """Initialize ecobee ventilator platform.""" super().__init__(data, thermostat_index) self.entity_description = description - self._attr_name = f"Ventilator min time {description.name}" self._attr_unique_id = f"{self.base_unique_id}_ventilator_{description.key}" async def async_update(self) -> None: diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 90d4ba4202e..3996ec6fd35 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -42,7 +42,6 @@ class EcobeeSensorEntityDescription( SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( EcobeeSensorEntityDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -50,7 +49,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="humidity", - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -58,7 +56,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="co2PPM", - name="CO2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -66,7 +63,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="vocPPM", - name="VOC", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -74,7 +70,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="airQuality", - name="Air Quality Index", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, runtime_key="actualAQScore", @@ -104,6 +99,8 @@ async def async_setup_entry( class EcobeeSensor(SensorEntity): """Representation of an Ecobee sensor.""" + _attr_has_entity_name = True + entity_description: EcobeeSensorEntityDescription def __init__( @@ -119,7 +116,6 @@ class EcobeeSensor(SensorEntity): self.sensor_name = sensor_name self.index = sensor_index self._state = None - self._attr_name = f"{sensor_name} {description.name}" @property def unique_id(self): diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 19f379de7d9..af44845887b 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -20,5 +20,15 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "number": { + "ventilator_min_type_home": { + "name": "Ventilator min time home" + }, + "ventilator_min_type_away": { + "name": "Ventilator min time away" + } + } } } diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index 5610cdb2a9c..d38bc82c6f2 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -57,6 +57,8 @@ class EcobeeWeather(WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_native_visibility_unit = UnitOfLength.METERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_has_entity_name = True + _attr_name = None def __init__(self, data, name, index): """Initialize the Ecobee weather platform.""" @@ -64,6 +66,7 @@ class EcobeeWeather(WeatherEntity): self._name = name self._index = index self.weather = None + self._attr_unique_id = data.ecobee.get_thermostat(self._index)["identifier"] def get_forecast(self, index, param): """Retrieve forecast parameter.""" @@ -73,16 +76,6 @@ class EcobeeWeather(WeatherEntity): except (IndexError, KeyError) as err: raise ValueError from err - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return a unique identifier for the weather platform.""" - return self.data.ecobee.get_thermostat(self._index)["identifier"] - @property def device_info(self) -> DeviceInfo: """Return device information for the ecobee weather platform.""" @@ -98,7 +91,7 @@ class EcobeeWeather(WeatherEntity): identifiers={(DOMAIN, thermostat["identifier"])}, manufacturer=MANUFACTURER, model=model, - name=self.name, + name=self._name, ) @property diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 09b127432db..8572764ce4d 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -25,6 +25,7 @@ def ecobee_fixture(): vals = { "name": "Ecobee", "modelNumber": "athenaSmart", + "identifier": "abc", "program": { "climates": [ {"name": "Climate1", "climateRef": "c1"}, @@ -83,7 +84,7 @@ def thermostat_fixture(data): async def test_name(thermostat) -> None: """Test name property.""" - assert thermostat.name == "Ecobee" + assert thermostat.device_info["name"] == "Ecobee" async def test_aux_heat_not_supported_by_default(hass: HomeAssistant) -> None: From a44f3e62e3a1ad41316759e48d58f0cff4f8b7ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 23:20:34 +0200 Subject: [PATCH 594/857] Add entity translations to Energyzero (#95293) --- homeassistant/components/energyzero/sensor.py | 20 +++++------ .../components/energyzero/strings.json | 34 +++++++++++++++++++ .../energyzero/snapshots/test_sensor.ambr | 24 ++++++------- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 75b5fa6fea6..17052dfab57 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -42,7 +42,7 @@ class EnergyZeroSensorEntityDescription( SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( EnergyZeroSensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_gas", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", @@ -50,14 +50,14 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( ), EnergyZeroSensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_gas", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", value_fn=lambda data: get_gas_price(data, 1), ), EnergyZeroSensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", @@ -65,7 +65,7 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( ), EnergyZeroSensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( @@ -74,42 +74,42 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( ), EnergyZeroSensorEntityDescription( key="average_price", - name="Average - today", + translation_key="average_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.average_price, ), EnergyZeroSensorEntityDescription( key="max_price", - name="Highest price - today", + translation_key="max_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_prices[1], ), EnergyZeroSensorEntityDescription( key="min_price", - name="Lowest price - today", + translation_key="min_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_prices[0], ), EnergyZeroSensorEntityDescription( key="highest_price_time", - name="Time of highest price - today", + translation_key="highest_price_time", service_type="today_energy", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.highest_price_time, ), EnergyZeroSensorEntityDescription( key="lowest_price_time", - name="Time of lowest price - today", + translation_key="lowest_price_time", service_type="today_energy", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.lowest_price_time, ), EnergyZeroSensorEntityDescription( key="percentage_of_max", - name="Current percentage of highest price - today", + translation_key="percentage_of_max", service_type="today_energy", native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index ed89e0068d4..93fb264b01d 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -8,5 +8,39 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "current_hour_price": { + "name": "Current hour" + }, + "next_hour_price": { + "name": "Next hour" + }, + "average_price": { + "name": "Average - today" + }, + "max_price": { + "name": "Highest price - today" + }, + "min_price": { + "name": "Lowest price - today" + }, + "highest_price_time": { + "name": "Time of highest price - today" + }, + "lowest_price_time": { + "name": "Time of lowest price - today" + }, + "percentage_of_max": { + "name": "Current percentage of highest price - today" + }, + "hours_priced_equal_or_lower": { + "name": "Hours priced equal or lower than current - today" + }, + "hours_priced_equal_or_higher": { + "name": "Hours priced equal or higher than current - today" + } + } } } diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index f758e8f53ca..619813c52c1 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -41,7 +41,7 @@ 'original_name': 'Current hour', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -105,7 +105,7 @@ 'original_name': 'Average - today', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'average_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -169,7 +169,7 @@ 'original_name': 'Average - today', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -236,7 +236,7 @@ 'original_name': 'Current hour', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -303,7 +303,7 @@ 'original_name': 'Current hour', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'average_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -367,7 +367,7 @@ 'original_name': 'Time of highest price - today', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'highest_price_time', 'unit_of_measurement': None, }) # --- @@ -431,7 +431,7 @@ 'original_name': 'Highest price - today', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'max_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -495,7 +495,7 @@ 'original_name': 'Average - today', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'average_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -562,7 +562,7 @@ 'original_name': 'Current hour', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -626,7 +626,7 @@ 'original_name': 'Time of highest price - today', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'highest_price_time', 'unit_of_measurement': None, }) # --- @@ -690,7 +690,7 @@ 'original_name': 'Highest price - today', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'max_price', 'unit_of_measurement': '€/kWh', }) # --- @@ -757,7 +757,7 @@ 'original_name': 'Current hour', 'platform': 'energyzero', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/m³', }) # --- From 3635508a08d23840cae7365f0c5b087165e31074 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 23:21:15 +0200 Subject: [PATCH 595/857] Use entity registry id in vacuum device actions (#95275) --- .../components/device_automation/helpers.py | 1 + .../components/vacuum/device_action.py | 16 ++++-- tests/components/vacuum/test_device_action.py | 55 ++++++++++++++++--- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 3857ac3a467..1d727b598a0 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -35,6 +35,7 @@ ENTITY_PLATFORMS = { Platform.REMOTE.value, Platform.SELECT.value, Platform.SWITCH.value, + Platform.VACUUM.value, } diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index 9b53c761247..0f212235673 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -19,14 +20,21 @@ from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START ACTION_TYPES = {"clean", "dock"} -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -42,7 +50,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } actions.append({**base_action, CONF_TYPE: "clean"}) @@ -58,8 +66,6 @@ async def async_call_action_from_config( context: Context | None, ) -> None: """Execute a device action.""" - config = ACTION_SCHEMA(config) - service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} if config[CONF_TYPE] == "clean": diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index 1a4aa1455a7..617b8d41609 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -35,16 +35,15 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) - expected_actions = [] - expected_actions += [ + expected_actions = [ { "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": "vacuum.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in ["clean", "dock"] @@ -78,7 +77,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -92,7 +91,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["clean", "dock"] @@ -103,8 +102,10 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant) -> None: +async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -115,7 +116,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "vacuum.entity", + "entity_id": entry.id, "type": "dock", }, }, @@ -124,7 +125,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "vacuum.entity", + "entity_id": entry.id, "type": "clean", }, }, @@ -144,3 +145,39 @@ async def test_action(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(dock_calls) == 1 assert len(clean_calls) == 1 + + assert dock_calls[-1].data == {"entity_id": entry.entity_id} + assert clean_calls[-1].data == {"entity_id": entry.entity_id} + + +async def test_action_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event_dock"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.entity_id, + "type": "dock", + }, + }, + ] + }, + ) + + dock_calls = async_mock_service(hass, "vacuum", "return_to_base") + + hass.bus.async_fire("test_event_dock") + await hass.async_block_till_done() + assert len(dock_calls) == 1 + + assert dock_calls[-1].data == {"entity_id": entry.entity_id} From b02cb569881e4ba4357f7a9d311e4bc86a4a4366 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 23:22:43 +0200 Subject: [PATCH 596/857] Clean up Awair const (#95135) --- homeassistant/components/awair/__init__.py | 17 ++- homeassistant/components/awair/const.py | 121 -------------------- homeassistant/components/awair/sensor.py | 124 +++++++++++++++++++-- tests/components/awair/test_sensor.py | 2 + 4 files changed, 134 insertions(+), 130 deletions(-) diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index cef2c7d1fd4..dca885ffe0d 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -2,16 +2,22 @@ from __future__ import annotations from asyncio import gather +from dataclasses import dataclass from datetime import timedelta from aiohttp import ClientSession from async_timeout import timeout from python_awair import Awair, AwairLocal +from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice from python_awair.exceptions import AuthError, AwairError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -23,7 +29,6 @@ from .const import ( LOGGER, UPDATE_INTERVAL_CLOUD, UPDATE_INTERVAL_LOCAL, - AwairResult, ) PLATFORMS = [Platform.SENSOR] @@ -72,6 +77,14 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +@dataclass +class AwairResult: + """Wrapper class to hold an awair device and set of air data.""" + + device: AwairBaseDevice + air_data: AirData + + class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): """Define a wrapper class to update Awair data.""" diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index d483df64298..19341ab6050 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -1,28 +1,9 @@ """Constants for the Awair component.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from python_awair.air_data import AirData -from python_awair.devices import AwairBaseDevice - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_BILLION, - CONCENTRATION_PARTS_PER_MILLION, - LIGHT_LUX, - PERCENTAGE, - UnitOfSoundPressure, - UnitOfTemperature, -) - API_CO2 = "carbon_dioxide" API_DUST = "dust" API_HUMID = "humidity" @@ -39,109 +20,7 @@ ATTRIBUTION = "Awair air quality sensor" DOMAIN = "awair" -DUST_ALIASES = [API_PM25, API_PM10] - LOGGER = logging.getLogger(__package__) UPDATE_INTERVAL_CLOUD = timedelta(minutes=5) UPDATE_INTERVAL_LOCAL = timedelta(seconds=30) - - -@dataclass -class AwairRequiredKeysMixin: - """Mixin for required keys.""" - - unique_id_tag: str - - -@dataclass -class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): - """Describes Awair sensor entity.""" - - -SENSOR_TYPE_SCORE = AwairSensorEntityDescription( - key=API_SCORE, - icon="mdi:blur", - native_unit_of_measurement=PERCENTAGE, - name="Score", - unique_id_tag="score", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, -) - -SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( - AwairSensorEntityDescription( - key=API_HUMID, - device_class=SensorDeviceClass.HUMIDITY, - native_unit_of_measurement=PERCENTAGE, - name="Humidity", - unique_id_tag="HUMID", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_LUX, - device_class=SensorDeviceClass.ILLUMINANCE, - native_unit_of_measurement=LIGHT_LUX, - name="Illuminance", - unique_id_tag="illuminance", - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_SPL_A, - device_class=SensorDeviceClass.SOUND_PRESSURE, - native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, - name="Sound level", - unique_id_tag="sound_level", - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_VOC, - icon="mdi:molecule", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, - name="Volatile organic compounds", - unique_id_tag="VOC", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_TEMP, - device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", - unique_id_tag="TEMP", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_CO2, - device_class=SensorDeviceClass.CO2, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - name="Carbon dioxide", - unique_id_tag="CO2", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), -) - -SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( - AwairSensorEntityDescription( - key=API_PM25, - device_class=SensorDeviceClass.PM25, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - name="PM2.5", - unique_id_tag="PM25", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_PM10, - device_class=SensorDeviceClass.PM10, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - name="PM10", - unique_id_tag="PM10", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), -) - - -@dataclass -class AwairResult: - """Wrapper class to hold an awair device and set of air data.""" - - device: AwairBaseDevice - air_data: AirData diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index f42a46999fb..e771c29d45b 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,14 +1,30 @@ """Support for Awair sensors.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CONNECTIONS, ATTR_SW_VERSION +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_SW_VERSION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + UnitOfSoundPressure, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo @@ -17,18 +33,112 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AwairDataUpdateCoordinator, AwairResult from .const import ( + API_CO2, API_DUST, + API_HUMID, + API_LUX, + API_PM10, API_PM25, API_SCORE, + API_SPL_A, API_TEMP, API_VOC, ATTRIBUTION, DOMAIN, - DUST_ALIASES, - SENSOR_TYPE_SCORE, - SENSOR_TYPES, - SENSOR_TYPES_DUST, - AwairSensorEntityDescription, +) + +DUST_ALIASES = [API_PM25, API_PM10] + + +@dataclass +class AwairRequiredKeysMixin: + """Mixin for required keys.""" + + unique_id_tag: str + + +@dataclass +class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): + """Describes Awair sensor entity.""" + + +SENSOR_TYPE_SCORE = AwairSensorEntityDescription( + key=API_SCORE, + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + name="Score", + unique_id_tag="score", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, +) + +SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_HUMID, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + unique_id_tag="HUMID", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + name="Illuminance", + unique_id_tag="illuminance", + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_SPL_A, + device_class=SensorDeviceClass.SOUND_PRESSURE, + native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + name="Sound level", + unique_id_tag="sound_level", + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_VOC, + icon="mdi:molecule", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="Volatile organic compounds", + unique_id_tag="VOC", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + name="Temperature", + unique_id_tag="TEMP", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_CO2, + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="Carbon dioxide", + unique_id_tag="CO2", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), +) + +SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_PM25, + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM2.5", + unique_id_tag="PM25", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_PM10, + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM10", + unique_id_tag="PM10", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), ) diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index 287eb72d21e..2c8aa78f791 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -11,6 +11,8 @@ from homeassistant.components.awair.const import ( API_SPL_A, API_TEMP, API_VOC, +) +from homeassistant.components.awair.sensor import ( SENSOR_TYPE_SCORE, SENSOR_TYPES, SENSOR_TYPES_DUST, From a568885ad21fa636f4710c7095599f40a88e3346 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 26 Jun 2023 23:23:07 +0200 Subject: [PATCH 597/857] Add backport of cached_property from CPython 3.12 (#95292) --- homeassistant/backports/functools.py | 62 +++++++++++++++++++ homeassistant/components/dlna_dms/dms.py | 3 +- homeassistant/components/fints/sensor.py | 2 +- .../components/nibe_heatpump/__init__.py | 2 +- .../components/thread/dataset_store.py | 2 +- .../zha/core/cluster_handlers/lighting.py | 4 +- homeassistant/components/zha/core/device.py | 2 +- 7 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 homeassistant/backports/functools.py diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py new file mode 100644 index 00000000000..b05277173c4 --- /dev/null +++ b/homeassistant/backports/functools.py @@ -0,0 +1,62 @@ +"""Functools backports from standard lib.""" +from __future__ import annotations + +from collections.abc import Callable +from types import GenericAlias +from typing import Any, Generic, TypeVar, cast + +_T = TypeVar("_T") +_R = TypeVar("_R") + + +class cached_property(Generic[_T, _R]): # pylint: disable=invalid-name + """Backport of Python 3.12's cached_property. + + Includes https://github.com/python/cpython/pull/101890/files + """ + + def __init__(self, func: Callable[[_T], _R]) -> None: + """Initialize.""" + self.func = func + self.attrname: Any = None + self.__doc__ = func.__doc__ + + def __set_name__(self, owner: type[_T], name: str) -> None: + """Set name.""" + if self.attrname is None: + self.attrname = name + elif name != self.attrname: + raise TypeError( + "Cannot assign the same cached_property to two different names " + f"({self.attrname!r} and {name!r})." + ) + + def __get__(self, instance: _T | None, owner: type[_T] | None = None) -> _R: + """Get.""" + if instance is None: + return cast(_R, self) + if self.attrname is None: + raise TypeError( + "Cannot use cached_property instance without calling __set_name__ on it." + ) + try: + cache = instance.__dict__ + # not all objects have __dict__ (e.g. class defines slots) + except AttributeError: + msg = ( + f"No '__dict__' attribute on {type(instance).__name__!r} " + f"instance to cache {self.attrname!r} property." + ) + raise TypeError(msg) from None + val = self.func(instance) + try: + cache[self.attrname] = val + except TypeError: + msg = ( + f"The '__dict__' attribute on {type(instance).__name__!r} instance " + f"does not support item assignment for caching {self.attrname!r} property." + ) + raise TypeError(msg) from None + return val + + __class_getitem__ = classmethod(GenericAlias) diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 2fd1a85ebae..8fc55830c63 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -16,6 +16,7 @@ from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from didl_lite import didl_lite from homeassistant.backports.enum import StrEnum +from homeassistant.backports.functools import cached_property from homeassistant.components import ssdp from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source.error import Unresolvable @@ -619,7 +620,7 @@ class DmsDeviceSource: """Make an identifier for BrowseMediaSource.""" return f"{self.source_id}/{action}{object_id}" - @functools.cached_property + @cached_property def _sort_criteria(self) -> list[str]: """Return criteria to be used for sorting results. diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 479e59d9cdf..3b961054544 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import namedtuple from datetime import timedelta -from functools import cached_property import logging from typing import Any @@ -11,6 +10,7 @@ from fints.client import FinTS3PinTanClient from fints.models import SEPAAccount import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index b46102879c4..a38e2182ad7 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -5,7 +5,6 @@ import asyncio from collections import defaultdict from collections.abc import Callable, Iterable from datetime import timedelta -from functools import cached_property from typing import Any, Generic, TypeVar from nibe.coil import Coil, CoilData @@ -15,6 +14,7 @@ from nibe.connection.nibegw import NibeGW, ProductInfo from nibe.exceptions import CoilNotFoundException, ReadException from nibe.heatpump import HeatPump, Model, Series +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 643981e763b..55623f7e3a4 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -3,13 +3,13 @@ from __future__ import annotations import dataclasses from datetime import datetime -from functools import cached_property import logging from typing import Any, cast from python_otbr_api import tlv_parser from python_otbr_api.tlv_parser import MeshcopTLVType +from homeassistant.backports.functools import cached_property from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 993ecca29cd..5f54ce381cc 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -1,10 +1,10 @@ """Lighting cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -from functools import cached_property - from zigpy.zcl.clusters import lighting +from homeassistant.backports.functools import cached_property + from .. import registries from ..const import REPORT_CONFIG_DEFAULT from . import AttrReportConfig, ClientClusterHandler, ClusterHandler diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 311e876bbc0..51ab65e3318 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Callable from datetime import timedelta from enum import Enum -from functools import cached_property import logging import random import time @@ -23,6 +22,7 @@ from zigpy.zcl.clusters.general import Groups, Identify from zigpy.zcl.foundation import Status as ZclStatus, ZCLCommandDef import zigpy.zdo.types as zdo_types +from homeassistant.backports.functools import cached_property from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError From 9734f45202b51633d6f0c0213130fd5ce146dc5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 23:32:37 +0200 Subject: [PATCH 598/857] Add entity translations to Eufylife BLE (#95296) --- homeassistant/components/eufylife_ble/sensor.py | 6 +++--- homeassistant/components/eufylife_ble/strings.json | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index d7c69dec165..741f71b34d2 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -86,7 +86,7 @@ class EufyLifeSensorEntity(SensorEntity): class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): """Representation of an EufyLife real-time weight sensor.""" - _attr_name = "Real-time weight" + _attr_translation_key = "real_time_weight" _attr_native_unit_of_measurement = UnitOfMass.KILOGRAMS _attr_device_class = SensorDeviceClass.WEIGHT @@ -115,7 +115,7 @@ class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife weight sensor.""" - _attr_name = "Weight" + _attr_translation_key = "weight" _attr_native_unit_of_measurement = UnitOfMass.KILOGRAMS _attr_device_class = SensorDeviceClass.WEIGHT @@ -176,7 +176,7 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife heart rate sensor.""" - _attr_name = "Heart rate" + _attr_translation_key = "heart_rate" _attr_icon = "mdi:heart-pulse" _attr_native_unit_of_measurement = "bpm" diff --git a/homeassistant/components/eufylife_ble/strings.json b/homeassistant/components/eufylife_ble/strings.json index a045d84771e..5f7924f4cbd 100644 --- a/homeassistant/components/eufylife_ble/strings.json +++ b/homeassistant/components/eufylife_ble/strings.json @@ -18,5 +18,18 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "real_time_weight": { + "name": "Real-time weight" + }, + "weight": { + "name": "Weight" + }, + "heart_rate": { + "name": "Heart rate" + } + } } } From b70a67404bc844dff8f0d0b54de12ad12d2b642a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 26 Jun 2023 16:36:02 -0500 Subject: [PATCH 599/857] Check end stage as well when preparing pipeline (#95303) --- .../components/assist_pipeline/pipeline.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 4a811b25f1f..891fc639fee 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -737,17 +737,30 @@ class PipelineInput: ) start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + end_stage_index = PIPELINE_STAGE_ORDER.index(self.run.end_stage) prepare_tasks = [] - if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + if ( + start_stage_index + <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT) + <= end_stage_index + ): # self.stt_metadata can't be None or we'd raise above prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) # type: ignore[arg-type] - if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + if ( + start_stage_index + <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT) + <= end_stage_index + ): prepare_tasks.append(self.run.prepare_recognize_intent()) - if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + if ( + start_stage_index + <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS) + <= end_stage_index + ): prepare_tasks.append(self.run.prepare_text_to_speech()) if prepare_tasks: From 185936dedae4639925811b68a1cf0543cb660f63 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 23:36:06 +0200 Subject: [PATCH 600/857] Use DeviceInfo type for Aurora ABB PowerOne (#95133) --- .../aurora_abb_powerone/aurora_device.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py index 5a524851bdf..6d3260a45f4 100644 --- a/homeassistant/components/aurora_abb_powerone/aurora_device.py +++ b/homeassistant/components/aurora_abb_powerone/aurora_device.py @@ -47,10 +47,10 @@ class AuroraEntity(Entity): @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "identifiers": {(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, - "manufacturer": MANUFACTURER, - "model": self._data[ATTR_MODEL], - "name": self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), - "sw_version": self._data[ATTR_FIRMWARE], - } + return DeviceInfo( + identifiers={(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, + manufacturer=MANUFACTURER, + model=self._data[ATTR_MODEL], + name=self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), + sw_version=self._data[ATTR_FIRMWARE], + ) From dbe4252d34be1e33fd1de9abc2970ec8ac05564e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 Jun 2023 23:39:02 +0200 Subject: [PATCH 601/857] Add entity translation to Aussie broadband (#95134) --- .../components/aussie_broadband/sensor.py | 24 ++++++------ .../components/aussie_broadband/strings.json | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index 1ed146b6237..fa407949b40 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -35,7 +35,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( # Internet Services sensors SensorValueEntityDescription( key="usedMb", - name="Data used", + translation_key="data_used", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -43,7 +43,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="downloadedMb", - name="Downloaded", + translation_key="downloaded", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -51,7 +51,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="uploadedMb", - name="Uploaded", + translation_key="uploaded", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -60,21 +60,21 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( # Mobile Phone Services sensors SensorValueEntityDescription( key="national", - name="National calls", + translation_key="national_calls", state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="mobile", - name="Mobile calls", + translation_key="mobile_calls", state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="international", - name="International calls", + translation_key="international_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone-plus", @@ -82,14 +82,14 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="sms", - name="SMS sent", + translation_key="sms_sent", state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:message-processing", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="internet", - name="Data used", + translation_key="data_used", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.KILOBYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -98,7 +98,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="voicemail", - name="Voicemail calls", + translation_key="voicemail_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", @@ -106,7 +106,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="other", - name="Other calls", + translation_key="other_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", @@ -115,13 +115,13 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( # Generic sensors SensorValueEntityDescription( key="daysTotal", - name="Billing cycle length", + translation_key="billing_cycle_length", native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:calendar-range", ), SensorValueEntityDescription( key="daysRemaining", - name="Billing cycle remaining", + translation_key="billing_cycle_remaining", native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:calendar-clock", ), diff --git a/homeassistant/components/aussie_broadband/strings.json b/homeassistant/components/aussie_broadband/strings.json index c2052defa81..90e4f094ee6 100644 --- a/homeassistant/components/aussie_broadband/strings.json +++ b/homeassistant/components/aussie_broadband/strings.json @@ -46,5 +46,42 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "data_used": { + "name": "Data used" + }, + "downloaded": { + "name": "Downloaded" + }, + "uploaded": { + "name": "Uploaded" + }, + "national_calls": { + "name": "National calls" + }, + "mobile_calls": { + "name": "Mobile calls" + }, + "international_calls": { + "name": "International calls" + }, + "sms_sent": { + "name": "SMS sent" + }, + "voicemail_calls": { + "name": "Voicemail calls" + }, + "other_calls": { + "name": "Other calls" + }, + "billing_cycle_length": { + "name": "Billing cycle length" + }, + "billing_cycle_remaining": { + "name": "Billing cycle remaining" + } + } } } From 9fe24c54e90de2b1927cf039e42c21f3e763d86a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Jun 2023 17:49:00 -0500 Subject: [PATCH 602/857] Add test coverage for ESPHome switch platform (#95306) --- .coveragerc | 1 - tests/components/esphome/test_switch.py | 55 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/components/esphome/test_switch.py diff --git a/.coveragerc b/.coveragerc index 1dc9dd79904..39395d4667b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -314,7 +314,6 @@ omit = homeassistant/components/esphome/domain_data.py homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/light.py - homeassistant/components/esphome/switch.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* homeassistant/components/eufylife_ble/__init__.py diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py new file mode 100644 index 00000000000..39e01a7d07c --- /dev/null +++ b/tests/components/esphome/test_switch.py @@ -0,0 +1,55 @@ +"""Test ESPHome switches.""" + + +from unittest.mock import call + +from aioesphomeapi import APIClient, SwitchInfo, SwitchState + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_switch_generic_entity( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic switch entity.""" + entity_info = [ + SwitchInfo( + object_id="myswitch", + key=1, + name="my switch", + unique_id="my_switch", + ) + ] + states = [SwitchState(key=1, state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("switch.test_my_switch") + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_my_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, True)]) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_my_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, False)]) From 398dbed72e51f1ecbb4ca501304d3d14045ccb92 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 27 Jun 2023 01:36:01 +0200 Subject: [PATCH 603/857] Improve type annotations of cached_property backport (#95309) --- homeassistant/backports/functools.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index b05277173c4..c7ab0d08693 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -3,7 +3,9 @@ from __future__ import annotations from collections.abc import Callable from types import GenericAlias -from typing import Any, Generic, TypeVar, cast +from typing import Any, Generic, TypeVar, overload + +from typing_extensions import Self _T = TypeVar("_T") _R = TypeVar("_R") @@ -31,10 +33,18 @@ class cached_property(Generic[_T, _R]): # pylint: disable=invalid-name f"({self.attrname!r} and {name!r})." ) - def __get__(self, instance: _T | None, owner: type[_T] | None = None) -> _R: + @overload + def __get__(self, instance: None, owner: type[_T]) -> Self: + ... + + @overload + def __get__(self, instance: _T, owner: type[_T]) -> _R: + ... + + def __get__(self, instance: _T | None, owner: type[_T] | None = None) -> _R | Self: """Get.""" if instance is None: - return cast(_R, self) + return self if self.attrname is None: raise TypeError( "Cannot use cached_property instance without calling __set_name__ on it." From c4288e7b1f5bb60a0f41916cfdb4540ee4c99841 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Jun 2023 19:18:46 -0500 Subject: [PATCH 604/857] Use cached_property in entity.py instead of manual cache (#95307) --- homeassistant/helpers/entity.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index dbf9fe2f2f0..d6f2574f388 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config import DATA_CUSTOMIZE from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -312,10 +313,6 @@ class Entity(ABC): _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None - # Translation cache - _cached_name_translation_key: str | None = None - _cached_device_class_name: str | None = None - @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -354,7 +351,7 @@ class Entity(ABC): if hasattr(self, "_attr_name"): return not self._attr_name - if name_translation_key := self._name_translation_key(): + if name_translation_key := self._name_translation_key: if name_translation_key in self.platform.platform_translations: return False @@ -384,10 +381,9 @@ class Entity(ABC): return self.entity_description.has_entity_name return False + @cached_property def _device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" - if self._cached_device_class_name is not None: - return self._cached_device_class_name if not self.has_entity_name: return None device_class_key = self.device_class or "_" @@ -395,27 +391,22 @@ class Entity(ABC): name_translation_key = ( f"component.{platform.domain}.entity_component." f"{device_class_key}.name" ) - self._cached_device_class_name = platform.component_translations.get( - name_translation_key - ) - return self._cached_device_class_name + return platform.component_translations.get(name_translation_key) def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class.""" return False + @cached_property def _name_translation_key(self) -> str | None: """Return translation key for entity name.""" - if self._cached_name_translation_key is not None: - return self._cached_name_translation_key if self.translation_key is None: return None platform = self.platform - self._cached_name_translation_key = ( + return ( f"component.{platform.platform_name}.entity.{platform.domain}" f".{self.translation_key}.name" ) - return self._cached_name_translation_key @property def name(self) -> str | UndefinedType | None: @@ -424,7 +415,7 @@ class Entity(ABC): return self._attr_name if ( self.has_entity_name - and (name_translation_key := self._name_translation_key()) + and (name_translation_key := self._name_translation_key) and (name := self.platform.platform_translations.get(name_translation_key)) ): if TYPE_CHECKING: @@ -433,13 +424,13 @@ class Entity(ABC): if hasattr(self, "entity_description"): description_name = self.entity_description.name if description_name is UNDEFINED and self._default_to_device_class_name(): - return self._device_class_name() + return self._device_class_name return description_name # The entity has no name set by _attr_name, translation_key or entity_description # Check if the entity should be named by its device class if self._default_to_device_class_name(): - return self._device_class_name() + return self._device_class_name return UNDEFINED @property From d6cd5648b9e6dc7d7fd2892df1a17289157488a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Jun 2023 22:10:17 -0400 Subject: [PATCH 605/857] Change conversation default agent behavior (#95225) * Change conversation default agent behavior * Fix tests --- .../components/conversation/__init__.py | 4 ---- .../conversation/snapshots/test_init.ambr | 19 ++++++++---------- tests/components/conversation/test_init.py | 10 ++++------ .../google_assistant_sdk/test_init.py | 12 ++++++----- .../test_init.py | 17 ++++++++++++---- tests/components/mobile_app/test_webhook.py | 20 +++++++++++-------- .../openai_conversation/test_init.py | 17 ++++++++++++---- 7 files changed, 57 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f3d883b1565..f704a8baa33 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -529,12 +529,8 @@ class AgentManager: def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> None: """Set the agent.""" self._agents[agent_id] = agent - if self.default_agent == HOME_ASSISTANT_AGENT: - self.default_agent = agent_id @core.callback def async_unset_agent(self, agent_id: str) -> None: """Unset the agent.""" - if self.default_agent == agent_id: - self.default_agent = HOME_ASSISTANT_AGENT self._agents.pop(agent_id, None) diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index f4325e2f291..afc2d2e4418 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,22 +1,22 @@ # serializer version: 1 # name: test_get_agent_info - dict({ - 'id': 'mock-entry', - 'name': 'Mock Title', - }) -# --- -# name: test_get_agent_info.1 dict({ 'id': 'homeassistant', 'name': 'Home Assistant', }) # --- -# name: test_get_agent_info.2 +# name: test_get_agent_info.1 dict({ 'id': 'mock-entry', 'name': 'Mock Title', }) # --- +# name: test_get_agent_info.2 + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }) +# --- # name: test_get_agent_info.3 dict({ 'id': 'mock-entry', @@ -344,10 +344,7 @@ # --- # name: test_ws_get_agent_info dict({ - 'attribution': dict({ - 'name': 'Mock assistant', - 'url': 'https://assist.me', - }), + 'attribution': None, }) # --- # name: test_ws_get_agent_info.1 diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index b55bd651b9e..ec2128e3bd7 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1054,16 +1054,16 @@ async def test_http_api_wrong_data( assert resp.status == HTTPStatus.BAD_REQUEST -@pytest.mark.parametrize("agent_id", (None, "mock-entry")) async def test_custom_agent( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, mock_agent, - agent_id, ) -> None: """Test a custom conversation agent.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) client = await hass_client() @@ -1071,9 +1071,8 @@ async def test_custom_agent( "text": "Test Text", "conversation_id": "test-conv-id", "language": "test-language", + "agent_id": mock_agent.agent_id, } - if agent_id is not None: - data["agent_id"] = agent_id resp = await client.post("/api/conversation/process", json=data) assert resp.status == HTTPStatus.OK @@ -1599,8 +1598,7 @@ async def test_get_agent_info( """Test get agent info.""" agent_info = conversation.async_get_agent_info(hass) # Test it's the default - assert agent_info.id == mock_agent.agent_id - assert agent_info == snapshot + assert conversation.async_get_agent_info(hass, "homeassistant") == agent_info assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot assert conversation.async_get_agent_info(hass, mock_agent.agent_id) == snapshot assert conversation.async_get_agent_info(hass, "not exist") is None diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 4cfdd42bcdd..25066f73b6d 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -16,7 +16,7 @@ from homeassistant.util.dt import utcnow from .conftest import ComponentSetup, ExpectedCredentials -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -322,6 +322,7 @@ async def test_send_text_command_media_player( async def test_conversation_agent( hass: HomeAssistant, setup_integration: ComponentSetup, + config_entry: MockConfigEntry, ) -> None: """Test GoogleAssistantConversationAgent.""" await setup_integration() @@ -348,13 +349,13 @@ async def test_conversation_agent( await hass.services.async_call( "conversation", "process", - {"text": text1}, + {"text": text1, "agent_id": config_entry.entry_id}, blocking=True, ) await hass.services.async_call( "conversation", "process", - {"text": text2}, + {"text": text2, "agent_id": config_entry.entry_id}, blocking=True, ) @@ -367,6 +368,7 @@ async def test_conversation_agent( async def test_conversation_agent_refresh_token( hass: HomeAssistant, + config_entry: MockConfigEntry, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, ) -> None: @@ -392,7 +394,7 @@ async def test_conversation_agent_refresh_token( await hass.services.async_call( "conversation", "process", - {"text": text1}, + {"text": text1, "agent_id": config_entry.entry_id}, blocking=True, ) @@ -412,7 +414,7 @@ async def test_conversation_agent_refresh_token( await hass.services.async_call( "conversation", "process", - {"text": text2}, + {"text": text2, "agent_id": config_entry.entry_id}, blocking=True, ) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 7335903b43b..e8da4cf3920 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -13,6 +13,7 @@ from tests.common import MockConfigEntry async def test_default_prompt( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, mock_init_component, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, @@ -89,16 +90,22 @@ async def test_default_prompt( suggested_area="Test Area 2", ) with patch("google.generativeai.chat_async") as mock_chat: - result = await conversation.async_converse(hass, "hello", None, Context()) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert mock_chat.mock_calls[0][2] == snapshot -async def test_error_handling(hass: HomeAssistant, mock_init_component) -> None: +async def test_error_handling( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: """Test that the default prompt works.""" with patch("google.generativeai.chat_async", side_effect=ClientError("")): - result = await conversation.async_converse(hass, "hello", None, Context()) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result @@ -119,7 +126,9 @@ async def test_template_error( ), patch("google.generativeai.chat_async"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - result = await conversation.async_converse(hass, "hello", None, Context()) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 02c9ace7cd4..ce1dc19319a 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1026,15 +1026,19 @@ async def test_webhook_handle_conversation_process( """Test that we can converse.""" webhook_client.server.app.router._frozen = False - resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), - json={ - "type": "conversation_process", - "data": { - "text": "Turn the kitchen light off", + with patch( + "homeassistant.components.conversation.AgentManager.async_get_agent", + return_value=mock_agent, + ): + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "conversation_process", + "data": { + "text": "Turn the kitchen light off", + }, }, - }, - ) + ) assert resp.status == HTTPStatus.OK json = await resp.json() diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 4016ac03c97..fe23bbac56c 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -13,6 +13,7 @@ from tests.common import MockConfigEntry async def test_default_prompt( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, mock_init_component, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, @@ -101,18 +102,24 @@ async def test_default_prompt( ] }, ) as mock_create: - result = await conversation.async_converse(hass, "hello", None, Context()) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert mock_create.mock_calls[0][2]["messages"] == snapshot -async def test_error_handling(hass: HomeAssistant, mock_init_component) -> None: +async def test_error_handling( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: """Test that the default prompt works.""" with patch( "openai.ChatCompletion.acreate", side_effect=error.ServiceUnavailableError ): - result = await conversation.async_converse(hass, "hello", None, Context()) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result @@ -133,7 +140,9 @@ async def test_template_error( ), patch("openai.ChatCompletion.acreate"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - result = await conversation.async_converse(hass, "hello", None, Context()) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result From 0af71851a4940971dad2fae8b6d05dbd040af65a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Jun 2023 22:34:37 -0500 Subject: [PATCH 606/857] Fix ESPHome button not getting device updates (#95311) --- .coveragerc | 1 - homeassistant/components/esphome/button.py | 16 +++++-- tests/components/esphome/conftest.py | 31 ++++++++++--- tests/components/esphome/test_button.py | 54 ++++++++++++++++++++++ 4 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 tests/components/esphome/test_button.py diff --git a/.coveragerc b/.coveragerc index 39395d4667b..978c05af3c1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -308,7 +308,6 @@ omit = homeassistant/components/escea/discovery.py homeassistant/components/esphome/__init__.py homeassistant/components/esphome/bluetooth/* - homeassistant/components/esphome/button.py homeassistant/components/esphome/camera.py homeassistant/components/esphome/cover.py homeassistant/components/esphome/domain_data.py diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 7087cb034ae..eca8d226c69 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -42,10 +42,18 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): @callback def _on_device_update(self) -> None: - """Update the entity state when device info has changed.""" - # This override the EsphomeEntity method as the button entity - # never gets a state update. - self._on_state_update() + """Call when device updates or entry data changes. + + The default behavior is only to write entity state when the + device is unavailable when the device state changes. + This method overrides the default behavior since buttons do + not have a state, so we will never get a state update for a + button. As such, we need to write the state on every device + update to ensure the button goes available and unavailable + as the device becomes available or unavailable. + """ + self._on_entry_data_changed() + self.async_write_ha_state() async def async_press(self) -> None: """Press the button.""" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index d78af769a17..ffd87691b38 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -153,6 +153,7 @@ class MockESPHomeDevice: """Init the mock.""" self.entry = entry self.state_callback: Callable[[EntityState], None] + self.on_disconnect: Callable[[bool], None] def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """Set the state callback.""" @@ -162,6 +163,14 @@ class MockESPHomeDevice: """Mock setting state.""" self.state_callback(state) + def set_on_disconnect(self, on_disconnect: Callable[[bool], None]) -> None: + """Set the disconnect callback.""" + self.on_disconnect = on_disconnect + + async def mock_disconnect(self, expected_disconnect: bool) -> None: + """Mock disconnecting.""" + await self.on_disconnect(expected_disconnect) + async def _mock_generic_device_entry( hass: HomeAssistant, @@ -209,15 +218,23 @@ async def _mock_generic_device_entry( mock_client.subscribe_states = _subscribe_states try_connect_done = Event() - real_try_connect = ReconnectLogic._try_connect - async def mock_try_connect(self): - """Set an event when ReconnectLogic._try_connect has been awaited.""" - result = await real_try_connect(self) - try_connect_done.set() - return result + class MockReconnectLogic(ReconnectLogic): + """Mock ReconnectLogic.""" - with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): + def __init__(self, *args, **kwargs): + """Init the mock.""" + super().__init__(*args, **kwargs) + mock_device.set_on_disconnect(kwargs["on_disconnect"]) + self._try_connect = self.mock_try_connect + + async def mock_try_connect(self): + """Set an event when ReconnectLogic._try_connect has been awaited.""" + result = await super()._try_connect() + try_connect_done.set() + return result + + with patch("homeassistant.components.esphome.ReconnectLogic", MockReconnectLogic): assert await hass.config_entries.async_setup(entry.entry_id) await try_connect_done.wait() diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py new file mode 100644 index 00000000000..c0e7db14998 --- /dev/null +++ b/tests/components/esphome/test_button.py @@ -0,0 +1,54 @@ +"""Test ESPHome buttones.""" + + +from unittest.mock import call + +from aioesphomeapi import APIClient, ButtonInfo + +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + + +async def test_button_generic_entity( + hass: HomeAssistant, mock_client: APIClient, mock_esphome_device +) -> None: + """Test a generic button entity.""" + entity_info = [ + ButtonInfo( + object_id="mybutton", + key=1, + name="my button", + unique_id="my_button", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("button.test_my_button") + assert state is not None + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_my_button"}, + blocking=True, + ) + mock_client.button_command.assert_has_calls([call(1)]) + state = hass.states.get("button.test_my_button") + assert state is not None + assert state.state != STATE_UNKNOWN + + await mock_device.mock_disconnect(False) + state = hass.states.get("button.test_my_button") + assert state is not None + assert state.state == STATE_UNAVAILABLE From c2457b8574c8c3e590b9580158d44794b7132d7b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 27 Jun 2023 08:20:05 +0200 Subject: [PATCH 607/857] Use entity registry id in cover device actions (#95269) --- .../components/cover/device_action.py | 16 +- .../components/device_automation/helpers.py | 1 + tests/components/cover/test_device_action.py | 169 +++++++++++++++--- 3 files changed, 154 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index dd22821d5e4..e34a623be93 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -42,21 +43,28 @@ POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(CMD_ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(POSITION_ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional("position", default=0): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) ), } ) -ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) +_ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_get_actions( @@ -77,7 +85,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if supported_features & SUPPORT_SET_POSITION: diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 1d727b598a0..8a7e80cc560 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -28,6 +28,7 @@ STATIC_VALIDATOR = { ENTITY_PLATFORMS = { Platform.ALARM_CONTROL_PANEL.value, Platform.BUTTON.value, + Platform.COVER.value, Platform.FAN.value, Platform.HUMIDIFIER.value, Platform.LIGHT.value, diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index ac798e8b3d4..0cc6716bd3c 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -61,7 +61,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -70,7 +70,7 @@ async def test_get_actions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, "attributes", {"supported_features": features_state} ) await hass.async_block_till_done() @@ -80,7 +80,7 @@ async def test_get_actions( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in expected_action_types @@ -114,7 +114,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -129,7 +129,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["close"] @@ -190,6 +190,57 @@ async def test_get_action_capabilities( assert capabilities == {"extra_fields": []} +async def test_get_action_capabilities_legacy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test we get the expected capabilities from a cover action.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockCover( + name="Set position cover", + is_on=True, + unique_id="unique_set_pos_cover", + current_cover_position=50, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + ), + ) + ent = platform.ENTITIES[0] + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_registry.async_get_or_create( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + assert len(actions) == 5 # open, close, open_tilt, close_tilt + action_types = {action["type"] for action in actions} + assert action_types == {"open", "close", "stop", "open_tilt", "close_tilt"} + for action in actions: + action["entity_id"] = entity_registry.async_get(action["entity_id"]).entity_id + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.ACTION, action + ) + assert capabilities == {"extra_fields": []} + + async def test_get_action_capabilities_set_pos( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -298,11 +349,13 @@ async def test_get_action_capabilities_set_tilt_pos( assert capabilities == {"extra_fields": []} -async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> None: +async def test_action( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: """Test for cover actions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -314,7 +367,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "open", }, }, @@ -323,7 +376,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "close", }, }, @@ -332,7 +385,7 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "stop", }, }, @@ -363,14 +416,24 @@ async def test_action(hass: HomeAssistant, enable_custom_integrations: None) -> assert len(close_calls) == 1 assert len(stop_calls) == 1 + assert open_calls[0].domain == DOMAIN + assert open_calls[0].service == "open_cover" + assert open_calls[0].data == {"entity_id": entry.entity_id} + assert close_calls[0].domain == DOMAIN + assert close_calls[0].service == "close_cover" + assert close_calls[0].data == {"entity_id": entry.entity_id} + assert stop_calls[0].domain == DOMAIN + assert stop_calls[0].service == "stop_cover" + assert stop_calls[0].data == {"entity_id": entry.entity_id} -async def test_action_tilt( - hass: HomeAssistant, enable_custom_integrations: None + +async def test_action_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, ) -> None: - """Test for cover tilt actions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + """Test for cover actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -382,7 +445,45 @@ async def test_action_tilt( "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "cover.entity", + "entity_id": entry.id, + "type": "open", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + open_calls = async_mock_service(hass, "cover", "open_cover") + + hass.bus.async_fire("test_event_open") + await hass.async_block_till_done() + assert len(open_calls) == 1 + + assert open_calls[0].domain == DOMAIN + assert open_calls[0].service == "open_cover" + assert open_calls[0].data == {"entity_id": entry.entity_id} + + +async def test_action_tilt( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test for cover tilt actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event_open"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.id, "type": "open_tilt", }, }, @@ -391,7 +492,7 @@ async def test_action_tilt( "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "close_tilt", }, }, @@ -418,14 +519,21 @@ async def test_action_tilt( assert len(open_calls) == 1 assert len(close_calls) == 1 + assert open_calls[0].domain == DOMAIN + assert open_calls[0].service == "open_cover_tilt" + assert open_calls[0].data == {"entity_id": entry.entity_id} + assert close_calls[0].domain == DOMAIN + assert close_calls[0].service == "close_cover_tilt" + assert close_calls[0].data == {"entity_id": entry.entity_id} + async def test_action_set_position( - hass: HomeAssistant, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, ) -> None: """Test for cover set position actions.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") assert await async_setup_component( hass, @@ -440,7 +548,7 @@ async def test_action_set_position( "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "set_position", "position": 25, }, @@ -453,7 +561,7 @@ async def test_action_set_position( "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "cover.entity", + "entity_id": entry.id, "type": "set_tilt_position", "position": 75, }, @@ -469,11 +577,16 @@ async def test_action_set_position( hass.bus.async_fire("test_event_set_pos") await hass.async_block_till_done() assert len(cover_pos_calls) == 1 - assert cover_pos_calls[0].data["position"] == 25 assert len(tilt_pos_calls) == 0 hass.bus.async_fire("test_event_set_tilt_pos") await hass.async_block_till_done() assert len(cover_pos_calls) == 1 assert len(tilt_pos_calls) == 1 - assert tilt_pos_calls[0].data["tilt_position"] == 75 + + assert cover_pos_calls[0].domain == DOMAIN + assert cover_pos_calls[0].service == "set_cover_position" + assert cover_pos_calls[0].data == {"entity_id": entry.entity_id, "position": 25} + assert tilt_pos_calls[0].domain == DOMAIN + assert tilt_pos_calls[0].service == "set_cover_tilt_position" + assert tilt_pos_calls[0].data == {"entity_id": entry.entity_id, "tilt_position": 75} From 9dc586bd984e138f5f423e64c93fa304d4bb536c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 27 Jun 2023 08:20:45 +0200 Subject: [PATCH 608/857] Use entity registry id in number device actions (#95273) --- .../components/device_automation/helpers.py | 1 + .../components/number/device_action.py | 14 ++- tests/components/number/test_device_action.py | 99 ++++++++++++++++--- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 8a7e80cc560..e123202026f 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -33,6 +33,7 @@ ENTITY_PLATFORMS = { Platform.HUMIDIFIER.value, Platform.LIGHT.value, Platform.LOCK.value, + Platform.NUMBER.value, Platform.REMOTE.value, Platform.SELECT.value, Platform.SWITCH.value, diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 971f8d5a514..22a51d85ad9 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -19,15 +20,22 @@ from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE ATYP_SET_VALUE = "set_value" -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): ATYP_SET_VALUE, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(ATTR_VALUE): vol.Coerce(float), } ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -44,7 +52,7 @@ async def async_get_actions( { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: ATYP_SET_VALUE, } ) diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 70422974422..1e0cfd5b391 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -40,7 +40,7 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) hass.states.async_set("number.test_5678", 0.5, {"min_value": 0.0, "max_value": 1.0}) @@ -49,7 +49,7 @@ async def test_get_actions( "domain": DOMAIN, "type": "set_value", "device_id": device_entry.id, - "entity_id": "number.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, ] @@ -82,7 +82,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -96,7 +96,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["set_value"] @@ -119,7 +119,7 @@ async def test_get_action_no_state( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) expected_actions = [ @@ -127,7 +127,7 @@ async def test_get_action_no_state( "domain": DOMAIN, "type": "set_value", "device_id": device_entry.id, - "entity_id": "number.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, }, ] @@ -137,9 +137,11 @@ async def test_get_action_no_state( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant) -> None: +async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test for actions.""" - hass.states.async_set("number.entity", 0.5, {"min_value": 0.0, "max_value": 1.0}) + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) assert await async_setup_component( hass, @@ -154,7 +156,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "number.entity", + "entity_id": entry.id, "type": "set_value", "value": 0.3, }, @@ -164,23 +166,96 @@ async def test_action(hass: HomeAssistant) -> None: ) calls = async_mock_service(hass, DOMAIN, "set_value") - assert len(calls) == 0 hass.bus.async_fire("test_event_set_value") await hass.async_block_till_done() assert len(calls) == 1 + assert calls[0].domain == DOMAIN + assert calls[0].service == "set_value" + assert calls[0].data == {"entity_id": entry.entity_id, "value": 0.3} -async def test_capabilities(hass: HomeAssistant) -> None: +async def test_action_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_value", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.entity_id, + "type": "set_value", + "value": 0.3, + }, + }, + ] + }, + ) + + calls = async_mock_service(hass, DOMAIN, "set_value") + assert len(calls) == 0 + + hass.bus.async_fire("test_event_set_value") + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == DOMAIN + assert calls[0].service == "set_value" + assert calls[0].data == {"entity_id": entry.entity_id, "value": 0.3} + + +async def test_capabilities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test getting capabilities.""" + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id="abcdefgh" + ) capabilities = await device_action.async_get_action_capabilities( hass, { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "number.entity", + "entity_id": entry.id, + "type": "set_value", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"name": "value", "required": True, "type": "float"}] + + +async def test_capabilities_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test getting capabilities.""" + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id="abcdefgh" + ) + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.entity_id, "type": "set_value", }, ) From 51aa2ba835869eee87bd2a3d520f49661cc00013 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 27 Jun 2023 08:21:15 +0200 Subject: [PATCH 609/857] Use entity registry id in water_heater device actions (#95276) --- .../components/device_automation/helpers.py | 1 + .../components/water_heater/device_action.py | 14 ++++- .../water_heater/test_device_action.py | 58 ++++++++++++++++--- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index e123202026f..858dc466587 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -38,6 +38,7 @@ ENTITY_PLATFORMS = { Platform.SELECT.value, Platform.SWITCH.value, Platform.VACUUM.value, + Platform.WATER_HEATER.value, } diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 8ae75527abc..5b0fe46934c 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -21,14 +22,21 @@ from . import DOMAIN ACTION_TYPES = {"turn_on", "turn_off"} -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -43,7 +51,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } actions.append({**base_action, CONF_TYPE: "turn_on"}) diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index 35b78a3d926..a8ca41905d6 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -35,16 +35,15 @@ async def test_get_actions( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) - expected_actions = [] - expected_actions += [ + expected_actions = [ { "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": False}, } for action in ["turn_on", "turn_off"] @@ -78,7 +77,7 @@ async def test_get_actions_hidden_auxiliary( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", @@ -92,7 +91,7 @@ async def test_get_actions_hidden_auxiliary( "domain": DOMAIN, "type": action, "device_id": device_entry.id, - "entity_id": f"{DOMAIN}.test_5678", + "entity_id": entity_entry.id, "metadata": {"secondary": True}, } for action in ["turn_on", "turn_off"] @@ -103,8 +102,10 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant) -> None: +async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + assert await async_setup_component( hass, automation.DOMAIN, @@ -118,7 +119,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "water_heater.entity", + "entity_id": entry.id, "type": "turn_off", }, }, @@ -130,7 +131,7 @@ async def test_action(hass: HomeAssistant) -> None: "action": { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": "water_heater.entity", + "entity_id": entry.id, "type": "turn_on", }, }, @@ -150,3 +151,42 @@ async def test_action(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 + + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} + assert turn_on_calls[-1].data == {"entity_id": entry.entity_id} + + +async def test_action_legacy( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test for turn_on and turn_off actions.""" + entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_turn_off", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": entry.entity_id, + "type": "turn_off", + }, + }, + ] + }, + ) + + turn_off_calls = async_mock_service(hass, "water_heater", "turn_off") + + hass.bus.async_fire("test_event_turn_off") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + + assert turn_off_calls[-1].data == {"entity_id": entry.entity_id} From 5f14cdf69ddc5e2bc78013372eac53b74063ef40 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Jun 2023 02:24:22 -0400 Subject: [PATCH 610/857] Allow stopping a script with a response value (#95284) --- homeassistant/components/script/__init__.py | 34 +++++++++++--- .../components/websocket_api/commands.py | 10 ++++- homeassistant/const.py | 3 +- homeassistant/helpers/config_validation.py | 7 ++- homeassistant/helpers/script.py | 45 ++++++++++++++----- homeassistant/helpers/template_entity.py | 2 +- homeassistant/helpers/trace.py | 5 ++- tests/common.py | 21 ++++++++- tests/components/script/test_init.py | 32 +++++++++++++ .../components/websocket_api/test_commands.py | 9 +++- 10 files changed, 140 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 659131e902b..1a60c7131c7 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -28,7 +28,14 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema @@ -436,6 +443,12 @@ class ScriptEntity(ToggleEntity, RestoreEntity): variables = kwargs.get("variables") context = kwargs.get("context") wait = kwargs.get("wait", True) + await self._async_start_run(variables, context, wait) + + async def _async_start_run( + self, variables: dict, context: Context, wait: bool + ) -> ServiceResponse: + """Start the run of a script.""" self.async_set_context(context) self.hass.bus.async_fire( EVENT_SCRIPT_STARTED, @@ -444,8 +457,7 @@ class ScriptEntity(ToggleEntity, RestoreEntity): ) coro = self._async_run(variables, context) if wait: - await coro - return + return await coro # Caller does not want to wait for called script to finish so let script run in # separate Task. Make a new empty script stack; scripts are allowed to @@ -457,6 +469,7 @@ class ScriptEntity(ToggleEntity, RestoreEntity): # Wait for first state change so we can guarantee that # it is written to the State Machine before we return. await self._changed.wait() + return None async def _async_run(self, variables, context): with trace_script( @@ -483,16 +496,25 @@ class ScriptEntity(ToggleEntity, RestoreEntity): """ await self.script.async_stop() - async def _service_handler(self, service: ServiceCall) -> None: + async def _service_handler(self, service: ServiceCall) -> ServiceResponse: """Execute a service call to script.