From b8d260f6eabe3fe20cd444846e621e3ea794e01a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 May 2022 20:49:10 +0200 Subject: [PATCH 01/90] Bumped version to 2022.6.0b0 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 74fe6adfdfd..e437c171c50 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index 825a4407012..c9b915b229a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0.dev0 +version = 2022.6.0b0 url = https://www.home-assistant.io/ [options] From d39de6e6994f24e2e4961199fae07cc9b14505cc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 25 May 2022 13:00:48 -0700 Subject: [PATCH 02/90] Throw nest climate API errors as HomeAssistantErrors (#72474) --- homeassistant/components/nest/climate_sdm.py | 34 +++++++---- tests/components/nest/test_climate_sdm.py | 60 ++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index bbbf83501f7..8a56f78028b 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -6,6 +6,7 @@ from typing import Any, cast from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.device_traits import FanTrait, TemperatureTrait +from google_nest_sdm.exceptions import ApiException from google_nest_sdm.thermostat_traits import ( ThermostatEcoTrait, ThermostatHeatCoolTrait, @@ -30,6 +31,7 @@ from homeassistant.components.climate.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -294,7 +296,10 @@ class ThermostatEntity(ClimateEntity): hvac_mode = HVACMode.OFF api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] trait = self._device.traits[ThermostatModeTrait.NAME] - await trait.set_mode(api_mode) + try: + await trait.set_mode(api_mode) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -308,20 +313,26 @@ class ThermostatEntity(ClimateEntity): if ThermostatTemperatureSetpointTrait.NAME not in self._device.traits: return trait = self._device.traits[ThermostatTemperatureSetpointTrait.NAME] - if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: - if low_temp and high_temp: - await trait.set_range(low_temp, high_temp) - elif hvac_mode == HVACMode.COOL and temp: - await trait.set_cool(temp) - elif hvac_mode == HVACMode.HEAT and temp: - await trait.set_heat(temp) + try: + if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: + if low_temp and high_temp: + await trait.set_range(low_temp, high_temp) + elif hvac_mode == HVACMode.COOL and temp: + await trait.set_cool(temp) + elif hvac_mode == HVACMode.HEAT and temp: + await trait.set_heat(temp) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" if preset_mode not in self.preset_modes: raise ValueError(f"Unsupported preset_mode '{preset_mode}'") trait = self._device.traits[ThermostatEcoTrait.NAME] - await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) + try: + await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" @@ -331,4 +342,7 @@ class ThermostatEntity(ClimateEntity): duration = None if fan_mode != FAN_OFF: duration = MAX_FAN_DURATION - await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration) + try: + await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index 5f3efa362b3..123742607ad 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -6,8 +6,10 @@ pubsub subscriber. """ from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any +import aiohttp from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.event import EventMessage import pytest @@ -41,6 +43,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .common import ( DEVICE_COMMAND, @@ -1380,3 +1383,60 @@ async def test_thermostat_invalid_set_preset_mode( # Preset is unchanged assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_NONE assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] + + +async def test_thermostat_hvac_mode_failure( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test setting an hvac_mode that is not supported.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatEco": { + "availableModes": ["MANUAL_ECO", "OFF"], + "mode": "OFF", + "heatCelsius": 15.0, + "coolCelsius": 28.0, + }, + } + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_hvac_mode(hass, HVAC_MODE_HEAT) + await hass.async_block_till_done() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_temperature( + hass, hvac_mode=HVAC_MODE_HEAT, temperature=25.0 + ) + await hass.async_block_till_done() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_fan_mode(hass, FAN_ON) + await hass.async_block_till_done() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_preset_mode(hass, PRESET_ECO) + await hass.async_block_till_done() From f9d9c3401897768de4e7fb637bddba24bfbd6de1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 May 2022 22:15:44 +0200 Subject: [PATCH 03/90] Add hardkernel hardware integration (#72489) * Add hardkernel hardware integration * Remove debug prints * Improve tests * Improve test coverage --- CODEOWNERS | 2 + .../components/hardkernel/__init__.py | 22 +++++ .../components/hardkernel/config_flow.py | 22 +++++ homeassistant/components/hardkernel/const.py | 3 + .../components/hardkernel/hardware.py | 39 ++++++++ .../components/hardkernel/manifest.json | 9 ++ script/hassfest/manifest.py | 1 + tests/components/hardkernel/__init__.py | 1 + .../components/hardkernel/test_config_flow.py | 58 ++++++++++++ tests/components/hardkernel/test_hardware.py | 89 +++++++++++++++++++ tests/components/hardkernel/test_init.py | 72 +++++++++++++++ 11 files changed, 318 insertions(+) create mode 100644 homeassistant/components/hardkernel/__init__.py create mode 100644 homeassistant/components/hardkernel/config_flow.py create mode 100644 homeassistant/components/hardkernel/const.py create mode 100644 homeassistant/components/hardkernel/hardware.py create mode 100644 homeassistant/components/hardkernel/manifest.json create mode 100644 tests/components/hardkernel/__init__.py create mode 100644 tests/components/hardkernel/test_config_flow.py create mode 100644 tests/components/hardkernel/test_hardware.py create mode 100644 tests/components/hardkernel/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 98d60fbfcb7..1b0d0ae4d3c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -416,6 +416,8 @@ build.json @home-assistant/supervisor /tests/components/guardian/ @bachya /homeassistant/components/habitica/ @ASMfreaK @leikoilja /tests/components/habitica/ @ASMfreaK @leikoilja +/homeassistant/components/hardkernel/ @home-assistant/core +/tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core /tests/components/hardware/ @home-assistant/core /homeassistant/components/harmony/ @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan diff --git a/homeassistant/components/hardkernel/__init__.py b/homeassistant/components/hardkernel/__init__.py new file mode 100644 index 00000000000..6dfe30b9e75 --- /dev/null +++ b/homeassistant/components/hardkernel/__init__.py @@ -0,0 +1,22 @@ +"""The Hardkernel integration.""" +from __future__ import annotations + +from homeassistant.components.hassio import get_os_info +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Hardkernel config entry.""" + if (os_info := get_os_info(hass)) is None: + # The hassio integration has not yet fetched data from the supervisor + raise ConfigEntryNotReady + + board: str + if (board := os_info.get("board")) is None or not board.startswith("odroid"): + # Not running on a Hardkernel board, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + return True diff --git a/homeassistant/components/hardkernel/config_flow.py b/homeassistant/components/hardkernel/config_flow.py new file mode 100644 index 00000000000..b0445fae231 --- /dev/null +++ b/homeassistant/components/hardkernel/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for the Hardkernel integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class HardkernelConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hardkernel.""" + + VERSION = 1 + + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="Hardkernel", data={}) diff --git a/homeassistant/components/hardkernel/const.py b/homeassistant/components/hardkernel/const.py new file mode 100644 index 00000000000..2850f3d4ebb --- /dev/null +++ b/homeassistant/components/hardkernel/const.py @@ -0,0 +1,3 @@ +"""Constants for the Hardkernel integration.""" + +DOMAIN = "hardkernel" diff --git a/homeassistant/components/hardkernel/hardware.py b/homeassistant/components/hardkernel/hardware.py new file mode 100644 index 00000000000..804f105f2ed --- /dev/null +++ b/homeassistant/components/hardkernel/hardware.py @@ -0,0 +1,39 @@ +"""The Hardkernel hardware platform.""" +from __future__ import annotations + +from homeassistant.components.hardware.models import BoardInfo, HardwareInfo +from homeassistant.components.hassio import get_os_info +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +BOARD_NAMES = { + "odroid-c2": "Hardkernel Odroid-C2", + "odroid-c4": "Hardkernel Odroid-C4", + "odroid-n2": "Home Assistant Blue / Hardkernel Odroid-N2", + "odroid-xu4": "Hardkernel Odroid-XU4", +} + + +@callback +def async_info(hass: HomeAssistant) -> HardwareInfo: + """Return board info.""" + if (os_info := get_os_info(hass)) is None: + raise HomeAssistantError + board: str + if (board := os_info.get("board")) is None: + raise HomeAssistantError + if not board.startswith("odroid"): + raise HomeAssistantError + + return HardwareInfo( + board=BoardInfo( + hassio_board_id=board, + manufacturer=DOMAIN, + model=board, + revision=None, + ), + name=BOARD_NAMES.get(board, f"Unknown hardkernel Odroid model '{board}'"), + url=None, + ) diff --git a/homeassistant/components/hardkernel/manifest.json b/homeassistant/components/hardkernel/manifest.json new file mode 100644 index 00000000000..366ca245191 --- /dev/null +++ b/homeassistant/components/hardkernel/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "hardkernel", + "name": "Hardkernel", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/hardkernel", + "dependencies": ["hardware", "hassio"], + "codeowners": ["@home-assistant/core"], + "integration_type": "hardware" +} diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index c478d16cf0f..7f2e8e0d477 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -52,6 +52,7 @@ NO_IOT_CLASS = [ "downloader", "ffmpeg", "frontend", + "hardkernel", "hardware", "history", "homeassistant", diff --git a/tests/components/hardkernel/__init__.py b/tests/components/hardkernel/__init__.py new file mode 100644 index 00000000000..d63b70d5cc5 --- /dev/null +++ b/tests/components/hardkernel/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hardkernel integration.""" diff --git a/tests/components/hardkernel/test_config_flow.py b/tests/components/hardkernel/test_config_flow.py new file mode 100644 index 00000000000..f74b4a4e658 --- /dev/null +++ b/tests/components/hardkernel/test_config_flow.py @@ -0,0 +1,58 @@ +"""Test the Hardkernel config flow.""" +from unittest.mock import patch + +from homeassistant.components.hardkernel.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test the config flow.""" + mock_integration(hass, MockModule("hassio")) + + with patch( + "homeassistant.components.hardkernel.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Hardkernel" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == {} + assert config_entry.title == "Hardkernel" + + +async def test_config_flow_single_entry(hass: HomeAssistant) -> None: + """Test only a single entry is allowed.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.hardkernel.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + mock_setup_entry.assert_not_called() diff --git a/tests/components/hardkernel/test_hardware.py b/tests/components/hardkernel/test_hardware.py new file mode 100644 index 00000000000..1c71959719c --- /dev/null +++ b/tests/components/hardkernel/test_hardware.py @@ -0,0 +1,89 @@ +"""Test the Hardkernel hardware platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.hardkernel.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get the board info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "odroid-n2"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.hardkernel.hardware.get_os_info", + return_value={"board": "odroid-n2"}, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == { + "hardware": [ + { + "board": { + "hassio_board_id": "odroid-n2", + "manufacturer": "hardkernel", + "model": "odroid-n2", + "revision": None, + }, + "name": "Home Assistant Blue / Hardkernel Odroid-N2", + "url": None, + } + ] + } + + +@pytest.mark.parametrize("os_info", [None, {"board": None}, {"board": "other"}]) +async def test_hardware_info_fail(hass: HomeAssistant, hass_ws_client, os_info) -> None: + """Test async_info raises if os_info is not as expected.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "odroid-n2"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.hardkernel.hardware.get_os_info", + return_value=os_info, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == {"hardware": []} diff --git a/tests/components/hardkernel/test_init.py b/tests/components/hardkernel/test_init.py new file mode 100644 index 00000000000..f202777f530 --- /dev/null +++ b/tests/components/hardkernel/test_init.py @@ -0,0 +1,72 @@ +"""Test the Hardkernel integration.""" +from unittest.mock import patch + +from homeassistant.components.hardkernel.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_setup_entry(hass: HomeAssistant) -> None: + """Test setup of a config entry.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "odroid-n2"}, + ) as mock_get_os_info: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + +async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: + """Test setup of a config entry with wrong board type.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "generic-x86-64"}, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + +async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: + """Test setup of a config entry when hassio has not fetched os_info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value=None, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY From 6bb78754dd8cdbbba47d83f8dac804437f65c8b6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 26 May 2022 22:02:39 +0200 Subject: [PATCH 04/90] Move manual configuration of MQTT device_tracker to the integration key (#72493) --- homeassistant/components/mqtt/__init__.py | 4 +- .../mqtt/device_tracker/__init__.py | 10 ++- .../mqtt/device_tracker/schema_discovery.py | 26 +++++-- tests/components/mqtt/test_device_tracker.py | 69 +++++++++++++------ 4 files changed, 82 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index df5d52c443a..e8847375584 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -159,6 +159,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, + Platform.DEVICE_TRACKER, Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, @@ -196,17 +197,18 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( vol.Optional(Platform.CAMERA.value): cv.ensure_list, vol.Optional(Platform.CLIMATE.value): cv.ensure_list, vol.Optional(Platform.COVER.value): cv.ensure_list, + vol.Optional(Platform.DEVICE_TRACKER.value): cv.ensure_list, vol.Optional(Platform.FAN.value): cv.ensure_list, vol.Optional(Platform.HUMIDIFIER.value): cv.ensure_list, vol.Optional(Platform.LIGHT.value): cv.ensure_list, vol.Optional(Platform.LOCK.value): cv.ensure_list, + vol.Optional(Platform.NUMBER.value): cv.ensure_list, vol.Optional(Platform.SCENE.value): cv.ensure_list, vol.Optional(Platform.SELECT.value): cv.ensure_list, vol.Optional(Platform.SIREN.value): cv.ensure_list, vol.Optional(Platform.SENSOR.value): cv.ensure_list, vol.Optional(Platform.SWITCH.value): cv.ensure_list, vol.Optional(Platform.VACUUM.value): cv.ensure_list, - vol.Optional(Platform.NUMBER.value): cv.ensure_list, } ) diff --git a/homeassistant/components/mqtt/device_tracker/__init__.py b/homeassistant/components/mqtt/device_tracker/__init__.py index 03574e6554b..bcd5bbd4ee1 100644 --- a/homeassistant/components/mqtt/device_tracker/__init__.py +++ b/homeassistant/components/mqtt/device_tracker/__init__.py @@ -1,7 +1,15 @@ """Support for tracking MQTT enabled devices.""" +import voluptuous as vol + +from homeassistant.components import device_tracker + +from ..mixins import warn_for_legacy_schema from .schema_discovery import async_setup_entry_from_discovery from .schema_yaml import PLATFORM_SCHEMA_YAML, async_setup_scanner_from_yaml -PLATFORM_SCHEMA = PLATFORM_SCHEMA_YAML +# Configuring MQTT Device Trackers under the device_tracker platform key is deprecated in HA Core 2022.6 +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA_YAML, warn_for_legacy_schema(device_tracker.DOMAIN) +) async_setup_scanner = async_setup_scanner_from_yaml async_setup_entry = async_setup_entry_from_discovery diff --git a/homeassistant/components/mqtt/device_tracker/schema_discovery.py b/homeassistant/components/mqtt/device_tracker/schema_discovery.py index a7b597d0689..aa7506bd5e3 100644 --- a/homeassistant/components/mqtt/device_tracker/schema_discovery.py +++ b/homeassistant/components/mqtt/device_tracker/schema_discovery.py @@ -1,4 +1,5 @@ -"""Support for tracking MQTT enabled devices identified through discovery.""" +"""Support for tracking MQTT enabled devices.""" +import asyncio import functools import voluptuous as vol @@ -22,13 +23,18 @@ from .. import MqttValueTemplate, subscription from ... import mqtt from ..const import CONF_QOS, CONF_STATE_TOPIC from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from ..mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_get_platform_config_from_yaml, + async_setup_entry_helper, +) CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_SOURCE_TYPE = "source_type" -PLATFORM_SCHEMA_DISCOVERY = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, @@ -37,11 +43,21 @@ PLATFORM_SCHEMA_DISCOVERY = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_DISCOVERY.extend({}, extra=vol.REMOVE_EXTRA) +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry_from_discovery(hass, config_entry, async_add_entities): - """Set up MQTT device tracker dynamically through MQTT discovery.""" + """Set up MQTT device tracker configuration.yaml and dynamically through MQTT discovery.""" + # load and initialize platform config from configuration.yaml + await asyncio.gather( + *( + _async_setup_entity(hass, async_add_entities, config, config_entry) + for config in await async_get_platform_config_from_yaml( + hass, device_tracker.DOMAIN, PLATFORM_SCHEMA_MODERN + ) + ) + ) + # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index c85fcef7dc4..020fbad6166 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -1,22 +1,17 @@ -"""The tests for the MQTT device tracker platform.""" +"""The tests for the MQTT device tracker platform using configuration.yaml.""" from unittest.mock import patch -import pytest - from homeassistant.components.device_tracker.const import DOMAIN, SOURCE_TYPE_BLUETOOTH from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.setup import async_setup_component +from .test_common import help_test_setup_manual_entity_from_yaml + from tests.common import async_fire_mqtt_message -@pytest.fixture(autouse=True) -def setup_comp(hass, mqtt_mock): - """Set up mqtt component.""" - pass - - -async def test_ensure_device_tracker_platform_validation(hass): +# Deprecated in HA Core 2022.6 +async def test_legacy_ensure_device_tracker_platform_validation(hass, mqtt_mock): """Test if platform validation was done.""" async def mock_setup_scanner(hass, config, see, discovery_info=None): @@ -37,7 +32,8 @@ async def test_ensure_device_tracker_platform_validation(hass): assert mock_sp.call_count == 1 -async def test_new_message(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_new_message(hass, mock_device_tracker_conf, mqtt_mock): """Test new message.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -53,7 +49,10 @@ async def test_new_message(hass, mock_device_tracker_conf): assert hass.states.get(entity_id).state == location -async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_single_level_wildcard_topic( + hass, mock_device_tracker_conf, mqtt_mock +): """Test single level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -72,7 +71,10 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): assert hass.states.get(entity_id).state == location -async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_multi_level_wildcard_topic( + hass, mock_device_tracker_conf, mqtt_mock +): """Test multi level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -91,7 +93,10 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): assert hass.states.get(entity_id).state == location -async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_single_level_wildcard_topic_not_matching( + hass, mock_device_tracker_conf, mqtt_mock +): """Test not matching single level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -110,7 +115,10 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke assert hass.states.get(entity_id) is None -async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_multi_level_wildcard_topic_not_matching( + hass, mock_device_tracker_conf, mqtt_mock +): """Test not matching multi level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -129,8 +137,9 @@ async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker assert hass.states.get(entity_id) is None -async def test_matching_custom_payload_for_home_and_not_home( - hass, mock_device_tracker_conf +# Deprecated in HA Core 2022.6 +async def test_legacy_matching_custom_payload_for_home_and_not_home( + hass, mock_device_tracker_conf, mqtt_mock ): """Test custom payload_home sets state to home and custom payload_not_home sets state to not_home.""" dev_id = "paulus" @@ -161,8 +170,9 @@ async def test_matching_custom_payload_for_home_and_not_home( assert hass.states.get(entity_id).state == STATE_NOT_HOME -async def test_not_matching_custom_payload_for_home_and_not_home( - hass, mock_device_tracker_conf +# Deprecated in HA Core 2022.6 +async def test_legacy_not_matching_custom_payload_for_home_and_not_home( + hass, mock_device_tracker_conf, mqtt_mock ): """Test not matching payload does not set state to home or not_home.""" dev_id = "paulus" @@ -191,7 +201,8 @@ async def test_not_matching_custom_payload_for_home_and_not_home( assert hass.states.get(entity_id).state != STATE_NOT_HOME -async def test_matching_source_type(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_matching_source_type(hass, mock_device_tracker_conf, mqtt_mock): """Test setting source type.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -215,3 +226,21 @@ async def test_matching_source_type(hass, mock_device_tracker_conf): async_fire_mqtt_message(hass, topic, location) await hass.async_block_till_done() assert hass.states.get(entity_id).attributes["source_type"] == SOURCE_TYPE_BLUETOOTH + + +async def test_setup_with_modern_schema( + hass, caplog, tmp_path, mock_device_tracker_conf +): + """Test setup using the modern schema.""" + dev_id = "jan" + entity_id = f"{DOMAIN}.{dev_id}" + topic = "/location/jan" + + hass.config.components = {"zone"} + config = {"name": dev_id, "state_topic": topic} + + await help_test_setup_manual_entity_from_yaml( + hass, caplog, tmp_path, DOMAIN, config + ) + + assert hass.states.get(entity_id) is not None From ff72c32b20248342cc7ab022cb03662cdb5deb6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 May 2022 15:17:08 -1000 Subject: [PATCH 05/90] Fixes for logbook filtering and add it to the live stream (#72501) --- homeassistant/components/logbook/processor.py | 32 ++- .../components/logbook/queries/__init__.py | 12 +- .../components/logbook/queries/all.py | 18 +- .../components/logbook/queries/common.py | 42 +--- .../components/logbook/queries/devices.py | 7 +- .../components/logbook/queries/entities.py | 9 +- homeassistant/components/recorder/filters.py | 100 +++++++--- homeassistant/components/recorder/history.py | 4 +- homeassistant/components/recorder/models.py | 40 +++- tests/components/logbook/test_init.py | 5 +- .../components/logbook/test_websocket_api.py | 185 ++++++++++++++++++ 11 files changed, 340 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 03506695700..ea6002cc62c 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -173,12 +173,6 @@ class EventProcessor: self.filters, self.context_id, ) - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Literal statement: %s", - stmt.compile(compile_kwargs={"literal_binds": True}), - ) - with session_scope(hass=self.hass) as session: return self.humanify(yield_rows(session.execute(stmt))) @@ -214,20 +208,16 @@ def _humanify( include_entity_name = logbook_run.include_entity_name format_time = logbook_run.format_time - def _keep_row(row: Row | EventAsRow, event_type: str) -> bool: + def _keep_row(row: EventAsRow) -> bool: """Check if the entity_filter rejects a row.""" assert entities_filter is not None - if entity_id := _row_event_data_extract(row, ENTITY_ID_JSON_EXTRACT): + if entity_id := row.entity_id: return entities_filter(entity_id) - - if event_type in external_events: - # If the entity_id isn't described, use the domain that describes - # the event for filtering. - domain: str | None = external_events[event_type][0] - else: - domain = _row_event_data_extract(row, DOMAIN_JSON_EXTRACT) - - return domain is not None and entities_filter(f"{domain}._") + if entity_id := row.data.get(ATTR_ENTITY_ID): + return entities_filter(entity_id) + if domain := row.data.get(ATTR_DOMAIN): + return entities_filter(f"{domain}._") + return True # Process rows for row in rows: @@ -236,12 +226,12 @@ def _humanify( continue event_type = row.event_type if event_type == EVENT_CALL_SERVICE or ( - event_type is not PSUEDO_EVENT_STATE_CHANGED - and entities_filter is not None - and not _keep_row(row, event_type) + entities_filter + # We literally mean is EventAsRow not a subclass of EventAsRow + and type(row) is EventAsRow # pylint: disable=unidiomatic-typecheck + and not _keep_row(row) ): continue - if event_type is PSUEDO_EVENT_STATE_CHANGED: entity_id = row.entity_id assert entity_id is not None diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 3672f1e761c..3c027823612 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -27,8 +27,16 @@ def statement_for_request( # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: - entity_filter = filters.entity_filter() if filters else None - return all_stmt(start_day, end_day, event_types, entity_filter, context_id) + states_entity_filter = filters.states_entity_filter() if filters else None + events_entity_filter = filters.events_entity_filter() if filters else None + return all_stmt( + start_day, + end_day, + event_types, + states_entity_filter, + events_entity_filter, + context_id, + ) # sqlalchemy caches object quoting, the # json quotable ones must be a different diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index da17c7bddeb..d321578f545 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -22,7 +22,8 @@ def all_stmt( start_day: dt, end_day: dt, event_types: tuple[str, ...], - entity_filter: ClauseList | None = None, + states_entity_filter: ClauseList | None = None, + events_entity_filter: ClauseList | None = None, context_id: str | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" @@ -37,12 +38,17 @@ def all_stmt( _states_query_for_context_id(start_day, end_day, context_id), legacy_select_events_context_id(start_day, end_day, context_id), ) - elif entity_filter is not None: - stmt += lambda s: s.union_all( - _states_query_for_all(start_day, end_day).where(entity_filter) - ) else: - stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + if events_entity_filter is not None: + stmt += lambda s: s.where(events_entity_filter) + + if states_entity_filter is not None: + stmt += lambda s: s.union_all( + _states_query_for_all(start_day, end_day).where(states_entity_filter) + ) + else: + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + stmt += lambda s: s.order_by(Events.time_fired) return stmt diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 237fde3f653..6049d6beb81 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -1,22 +1,20 @@ """Queries for logbook.""" from __future__ import annotations -from collections.abc import Callable from datetime import datetime as dt -import json -from typing import Any import sqlalchemy -from sqlalchemy import JSON, select, type_coerce -from sqlalchemy.orm import Query, aliased +from sqlalchemy import select +from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN from homeassistant.components.recorder.models import ( - JSON_VARIENT_CAST, - JSONB_VARIENT_CAST, + OLD_FORMAT_ATTRS_JSON, + OLD_STATE, + SHARED_ATTRS_JSON, EventData, Events, StateAttributes, @@ -30,36 +28,6 @@ CONTINUOUS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in CONTINUOUS_DOMAINS] UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":' UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%" -OLD_STATE = aliased(States, name="old_state") - - -class JSONLiteral(JSON): # type: ignore[misc] - """Teach SA how to literalize json.""" - - def literal_processor(self, dialect: str) -> Callable[[Any], str]: - """Processor to convert a value to JSON.""" - - def process(value: Any) -> str: - """Dump json.""" - return json.dumps(value) - - return process - - -EVENT_DATA_JSON = type_coerce( - EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) -OLD_FORMAT_EVENT_DATA_JSON = type_coerce( - Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) - -SHARED_ATTRS_JSON = type_coerce( - StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) -OLD_FORMAT_ATTRS_JSON = type_coerce( - States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) - PSUEDO_EVENT_STATE_CHANGED = None # Since we don't store event_types and None diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 5e7827b87a0..64a6477017e 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -4,24 +4,21 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt -from sqlalchemy import Column, lambda_stmt, select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import Events, States +from homeassistant.components.recorder.models import DEVICE_ID_IN_EVENT, Events, States from .common import ( - EVENT_DATA_JSON, select_events_context_id_subquery, select_events_context_only, select_events_without_states, select_states_context_only, ) -DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] - def _select_device_id_context_ids_sub_query( start_day: dt, diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 844890c23a9..4fb211688f3 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -5,20 +5,20 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import Column, lambda_stmt, select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect from homeassistant.components.recorder.models import ( + ENTITY_ID_IN_EVENT, ENTITY_ID_LAST_UPDATED_INDEX, + OLD_ENTITY_ID_IN_EVENT, Events, States, ) from .common import ( - EVENT_DATA_JSON, - OLD_FORMAT_EVENT_DATA_JSON, apply_states_filters, select_events_context_id_subquery, select_events_context_only, @@ -27,9 +27,6 @@ from .common import ( select_states_context_only, ) -ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] -OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] - def _select_entities_context_ids_sub_query( start_day: dt, diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index adc746379e6..7f1d0bc597f 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -1,14 +1,18 @@ """Provide pre-made queries on top of the recorder component.""" from __future__ import annotations -from sqlalchemy import not_, or_ +from collections.abc import Callable, Iterable +import json +from typing import Any + +from sqlalchemy import Column, not_, or_ from sqlalchemy.sql.elements import ClauseList from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.typing import ConfigType -from .models import States +from .models import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States DOMAIN = "history" HISTORY_FILTERS = "history_filters" @@ -59,50 +63,84 @@ class Filters: or self.included_entity_globs ) - def entity_filter(self) -> ClauseList: - """Generate the entity filter query.""" + def _generate_filter_for_columns( + self, columns: Iterable[Column], encoder: Callable[[Any], Any] + ) -> ClauseList: includes = [] if self.included_domains: - includes.append( - or_( - *[ - States.entity_id.like(f"{domain}.%") - for domain in self.included_domains - ] - ).self_group() - ) + includes.append(_domain_matcher(self.included_domains, columns, encoder)) if self.included_entities: - includes.append(States.entity_id.in_(self.included_entities)) - for glob in self.included_entity_globs: - includes.append(_glob_to_like(glob)) + includes.append(_entity_matcher(self.included_entities, columns, encoder)) + if self.included_entity_globs: + includes.append( + _globs_to_like(self.included_entity_globs, columns, encoder) + ) excludes = [] if self.excluded_domains: - excludes.append( - or_( - *[ - States.entity_id.like(f"{domain}.%") - for domain in self.excluded_domains - ] - ).self_group() - ) + excludes.append(_domain_matcher(self.excluded_domains, columns, encoder)) if self.excluded_entities: - excludes.append(States.entity_id.in_(self.excluded_entities)) - for glob in self.excluded_entity_globs: - excludes.append(_glob_to_like(glob)) + excludes.append(_entity_matcher(self.excluded_entities, columns, encoder)) + if self.excluded_entity_globs: + excludes.append( + _globs_to_like(self.excluded_entity_globs, columns, encoder) + ) if not includes and not excludes: return None if includes and not excludes: - return or_(*includes) + return or_(*includes).self_group() if not includes and excludes: - return not_(or_(*excludes)) + return not_(or_(*excludes).self_group()) - return or_(*includes) & not_(or_(*excludes)) + return or_(*includes).self_group() & not_(or_(*excludes).self_group()) + + def states_entity_filter(self) -> ClauseList: + """Generate the entity filter query.""" + + def _encoder(data: Any) -> Any: + """Nothing to encode for states since there is no json.""" + return data + + return self._generate_filter_for_columns((States.entity_id,), _encoder) + + def events_entity_filter(self) -> ClauseList: + """Generate the entity filter query.""" + _encoder = json.dumps + return or_( + (ENTITY_ID_IN_EVENT == _encoder(None)) + & (OLD_ENTITY_ID_IN_EVENT == _encoder(None)), + self._generate_filter_for_columns( + (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder + ).self_group(), + ) -def _glob_to_like(glob_str: str) -> ClauseList: +def _globs_to_like( + glob_strs: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] +) -> ClauseList: """Translate glob to sql.""" - return States.entity_id.like(glob_str.translate(GLOB_TO_SQL_CHARS)) + return or_( + column.like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) + for glob_str in glob_strs + for column in columns + ) + + +def _entity_matcher( + entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] +) -> ClauseList: + return or_( + column.in_([encoder(entity_id) for entity_id in entity_ids]) + for column in columns + ) + + +def _domain_matcher( + domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] +) -> ClauseList: + return or_( + column.like(encoder(f"{domain}.%")) for domain in domains for column in columns + ) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 845a2af62bf..7e8e97eafd4 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -236,7 +236,7 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.entity_filter() + entity_filter = filters.states_entity_filter() stmt += lambda q: q.filter(entity_filter) stmt += lambda q: q.filter(States.last_updated > start_time) @@ -528,7 +528,7 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.entity_filter() + entity_filter = filters.states_entity_filter() stmt += lambda q: q.filter(entity_filter) if join_attributes: stmt += lambda q: q.outerjoin( diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 90c2e5e5616..dff8edde79f 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,6 +1,7 @@ """Models for SQLAlchemy.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import json import logging @@ -9,6 +10,7 @@ from typing import Any, TypedDict, cast, overload import ciso8601 from fnvhash import fnv1a_32 from sqlalchemy import ( + JSON, BigInteger, Boolean, Column, @@ -22,11 +24,12 @@ from sqlalchemy import ( String, Text, distinct, + type_coerce, ) from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.row import Row from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import declarative_base, relationship +from sqlalchemy.orm import aliased, declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.components.websocket_api.const import ( @@ -119,6 +122,21 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) + + +class JSONLiteral(JSON): # type: ignore[misc] + """Teach SA how to literalize json.""" + + def literal_processor(self, dialect: str) -> Callable[[Any], str]: + """Processor to convert a value to JSON.""" + + def process(value: Any) -> str: + """Dump json.""" + return json.dumps(value) + + return process + + EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} @@ -612,6 +630,26 @@ class StatisticsRuns(Base): # type: ignore[misc,valid-type] ) +EVENT_DATA_JSON = type_coerce( + EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) +OLD_FORMAT_EVENT_DATA_JSON = type_coerce( + Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) + +SHARED_ATTRS_JSON = type_coerce( + StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) +OLD_FORMAT_ATTRS_JSON = type_coerce( + States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) + +ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] +OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] +DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] +OLD_STATE = aliased(States, name="old_state") + + @overload def process_timestamp(ts: None) -> None: ... diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 101fb74e690..2903f29f5dc 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -510,7 +510,7 @@ async def test_exclude_described_event(hass, hass_client, recorder_mock): return { "name": "Test Name", "message": "tested a message", - "entity_id": event.data.get(ATTR_ENTITY_ID), + "entity_id": event.data[ATTR_ENTITY_ID], } def async_describe_events(hass, async_describe_event): @@ -2003,13 +2003,12 @@ async def test_include_events_domain_glob(hass, hass_client, recorder_mock): ) await async_recorder_block_till_done(hass) - # Should get excluded by domain hass.bus.async_fire( logbook.EVENT_LOGBOOK_ENTRY, { logbook.ATTR_NAME: "Alarm", logbook.ATTR_MESSAGE: "is triggered", - logbook.ATTR_DOMAIN: "switch", + logbook.ATTR_ENTITY_ID: "switch.any", }, ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 8706ccf7617..02fea4f980f 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -14,16 +14,21 @@ from homeassistant.components.logbook import websocket_api from homeassistant.components.script import EVENT_SCRIPT_STARTED from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ( + ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, + CONF_DOMAINS, + CONF_ENTITIES, + CONF_EXCLUDE, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, ) from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import device_registry +from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -457,6 +462,186 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): assert isinstance(results[3]["when"], float) +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream with excluded entities.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "automation", "script") + ] + ) + await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: ["light.exc"], + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: "*.excluded", + } + }, + }, + ) + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + state: State = hass.states.get("binary_sensor.is_light") + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": "off", + "when": state.last_updated.timestamp(), + } + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + hass.states.async_set("light.alpha", "on") + hass.states.async_set("light.alpha", "off") + alpha_off_state: State = hass.states.get("light.alpha") + hass.states.async_set("light.zulu", "on", {"color": "blue"}) + hass.states.async_set("light.zulu", "off", {"effect": "help"}) + zulu_off_state: State = hass.states.get("light.zulu") + hass.states.async_set( + "light.zulu", "on", {"effect": "help", "color": ["blue", "green"]} + ) + zulu_on_state: State = hass.states.get("light.zulu") + await hass.async_block_till_done() + + hass.states.async_remove("light.zulu") + await hass.async_block_till_done() + + hass.states.async_set("light.zulu", "on", {"effect": "help", "color": "blue"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "light.alpha", + "state": "off", + "when": alpha_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "off", + "when": zulu_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "on", + "when": zulu_on_state.last_updated.timestamp(), + }, + ] + + await async_wait_recording_done(hass) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.excluded"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + { + ATTR_NAME: "Mock automation switch matching entity", + ATTR_ENTITY_ID: "switch.match_domain", + }, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation switch matching domain", ATTR_DOMAIN: "switch"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation matches nothing"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "light.keep"}, + ) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation matches nothing", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "light.keep", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( hass, recorder_mock, hass_ws_client From 180b5cd2bb1c077e52231951b57b3ec871da1c5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 May 2022 17:02:21 -1000 Subject: [PATCH 06/90] Fix flux_led taking a long time to recover after offline (#72507) --- homeassistant/components/flux_led/__init__.py | 21 +++++- .../components/flux_led/config_flow.py | 14 +++- homeassistant/components/flux_led/const.py | 2 + .../components/flux_led/coordinator.py | 5 +- .../components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/flux_led/test_init.py | 70 ++++++++++++++++++- 8 files changed, 110 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 17dc28a5edf..e6c1393154a 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -15,7 +15,10 @@ 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.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, @@ -27,6 +30,7 @@ from .const import ( DISCOVER_SCAN_TIMEOUT, DOMAIN, FLUX_LED_DISCOVERY, + FLUX_LED_DISCOVERY_SIGNAL, FLUX_LED_EXCEPTIONS, SIGNAL_STATE_UPDATED, STARTUP_SCAN_TIMEOUT, @@ -196,6 +200,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to avoid a race condition where the add_update_listener is not # in place in time for the check in async_update_entry_from_discovery entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + async def _async_handle_discovered_device() -> None: + """Handle device discovery.""" + # Force a refresh if the device is now available + if not coordinator.last_update_success: + coordinator.force_next_update = True + await coordinator.async_refresh() + + entry.async_on_unload( + async_dispatcher_connect( + hass, + FLUX_LED_DISCOVERY_SIGNAL.format(entry_id=entry.entry_id), + _async_handle_discovered_device, + ) + ) return True diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index dfb6ff4a174..61395d744b3 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -21,6 +21,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import DiscoveryInfoType from . import async_wifi_bulb_for_host @@ -31,6 +32,7 @@ from .const import ( DEFAULT_EFFECT_SPEED, DISCOVER_SCAN_TIMEOUT, DOMAIN, + FLUX_LED_DISCOVERY_SIGNAL, FLUX_LED_EXCEPTIONS, TRANSITION_GRADUAL, TRANSITION_JUMP, @@ -109,12 +111,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and ":" in entry.unique_id and mac_matches_by_one(entry.unique_id, mac) ): - if async_update_entry_from_discovery( - self.hass, entry, device, None, allow_update_mac + if ( + async_update_entry_from_discovery( + self.hass, entry, device, None, allow_update_mac + ) + or entry.state == config_entries.ConfigEntryState.SETUP_RETRY ): self.hass.async_create_task( self.hass.config_entries.async_reload(entry.entry_id) ) + else: + async_dispatcher_send( + self.hass, + FLUX_LED_DISCOVERY_SIGNAL.format(entry_id=entry.entry_id), + ) raise AbortFlow("already_configured") async def _async_handle_discovery(self) -> FlowResult: diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index 7fa841ec77f..db545aa1e68 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -74,3 +74,5 @@ EFFECT_SPEED_SUPPORT_MODES: Final = {ColorMode.RGB, ColorMode.RGBW, ColorMode.RG CONF_CUSTOM_EFFECT_COLORS: Final = "custom_effect_colors" CONF_CUSTOM_EFFECT_SPEED_PCT: Final = "custom_effect_speed_pct" CONF_CUSTOM_EFFECT_TRANSITION: Final = "custom_effect_transition" + +FLUX_LED_DISCOVERY_SIGNAL = "flux_led_discovery_{entry_id}" diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py index 5f2c3c097c0..5a7b3c89216 100644 --- a/homeassistant/components/flux_led/coordinator.py +++ b/homeassistant/components/flux_led/coordinator.py @@ -30,6 +30,7 @@ class FluxLedUpdateCoordinator(DataUpdateCoordinator): self.device = device self.title = entry.title self.entry = entry + self.force_next_update = False super().__init__( hass, _LOGGER, @@ -45,6 +46,8 @@ class FluxLedUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.async_update() + await self.device.async_update(force=self.force_next_update) except FLUX_LED_EXCEPTIONS as ex: raise UpdateFailed(ex) from ex + finally: + self.force_next_update = False diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index d2eb4e1e2e0..7ccd708f89b 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/flux_led", - "requirements": ["flux_led==0.28.29"], + "requirements": ["flux_led==0.28.30"], "quality_scale": "platinum", "codeowners": ["@icemanch", "@bdraco"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index fbcada246a7..054f38972db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -657,7 +657,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.2 # homeassistant.components.flux_led -flux_led==0.28.29 +flux_led==0.28.30 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f347a7ea082..f35d8da8ec8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.2 # homeassistant.components.flux_led -flux_led==0.28.29 +flux_led==0.28.30 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index b0a2c5dd33b..3504dbf3bea 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from homeassistant import config_entries from homeassistant.components import flux_led from homeassistant.components.flux_led.const import ( CONF_REMOTE_ACCESS_ENABLED, @@ -19,6 +20,8 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STARTED, + STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -27,6 +30,7 @@ from homeassistant.util.dt import utcnow from . import ( DEFAULT_ENTRY_TITLE, + DHCP_DISCOVERY, FLUX_DISCOVERY, FLUX_DISCOVERY_PARTIAL, IP_ADDRESS, @@ -113,6 +117,70 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: assert config_entry.state == ConfigEntryState.SETUP_RETRY +async def test_config_entry_retry_right_away_on_discovery(hass: HomeAssistant) -> None: + """Test discovery makes the config entry reload if its in a retry state.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + with _patch_discovery(), _patch_wifibulb(): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_retry_right_away_on_discovery_already_setup( + hass: HomeAssistant, +) -> None: + """Test discovery makes the coordinator force poll if its already setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + entity_id = "light.bulb_rgbcw_ddeeff" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + now = utcnow() + bulb.async_update = AsyncMock(side_effect=RuntimeError) + async_fire_time_changed(hass, now + timedelta(seconds=50)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + bulb.async_update = AsyncMock() + + with _patch_discovery(), _patch_wifibulb(): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize( "discovery,title", [ From f038d0892a8fd6543f290907822cf5f1f887aac6 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 26 May 2022 03:03:43 -0400 Subject: [PATCH 07/90] Update node statistics for zwave_js device diagnostics dump (#72509) --- homeassistant/components/zwave_js/diagnostics.py | 4 +++- tests/components/zwave_js/test_diagnostics.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 4e1abe37b1b..3372b0eeec0 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -155,6 +155,8 @@ async def async_get_device_diagnostics( node = driver.controller.nodes[node_id] entities = get_device_entities(hass, node, device) assert client.version + node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) + node_state["statistics"] = node.statistics.data return { "versionInfo": { "driverVersion": client.version.driver_version, @@ -163,5 +165,5 @@ async def async_get_device_diagnostics( "maxSchemaVersion": client.version.max_schema_version, }, "entities": entities, - "state": redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)), + "state": node_state, } diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index 3fe3bdfeb89..3ac3f32b45a 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -92,7 +92,16 @@ async def test_device_diagnostics( assert len(diagnostics_data["entities"]) == len( list(async_discover_node_values(multisensor_6, device, {device.id: set()})) ) - assert diagnostics_data["state"] == multisensor_6.data + assert diagnostics_data["state"] == { + **multisensor_6.data, + "statistics": { + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "commandsRX": 0, + "commandsTX": 0, + "timeoutResponse": 0, + }, + } async def test_device_diagnostics_error(hass, integration): From fa98b7e136b3af53906c8197825eb1630fc638b1 Mon Sep 17 00:00:00 2001 From: jack5mikemotown <72000916+jack5mikemotown@users.noreply.github.com> Date: Thu, 26 May 2022 16:01:23 -0400 Subject: [PATCH 08/90] Fix Google Assistant brightness calculation (#72514) Co-authored-by: Paulus Schoutsen --- homeassistant/components/google_assistant/trait.py | 4 ++-- tests/components/google_assistant/test_google_assistant.py | 2 +- tests/components/google_assistant/test_smart_home.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 20191c61668..42fc43197ea 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -249,7 +249,7 @@ class BrightnessTrait(_Trait): if domain == light.DOMAIN: brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) if brightness is not None: - response["brightness"] = int(100 * (brightness / 255)) + response["brightness"] = round(100 * (brightness / 255)) else: response["brightness"] = 0 @@ -1948,7 +1948,7 @@ class VolumeTrait(_Trait): level = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) if level is not None: # Convert 0.0-1.0 to 0-100 - response["currentVolume"] = int(level * 100) + response["currentVolume"] = round(level * 100) muted = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED) if muted is not None: diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 8bf0e5573b2..e8a2603cae3 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -225,7 +225,7 @@ async def test_query_request(hass_fixture, assistant_client, auth_header): assert len(devices) == 4 assert devices["light.bed_light"]["on"] is False assert devices["light.ceiling_lights"]["on"] is True - assert devices["light.ceiling_lights"]["brightness"] == 70 + assert devices["light.ceiling_lights"]["brightness"] == 71 assert devices["light.ceiling_lights"]["color"]["temperatureK"] == 2631 assert devices["light.kitchen_lights"]["color"]["spectrumHsv"] == { "hue": 345, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index c3bbd9336f4..4b11910999a 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -385,7 +385,7 @@ async def test_query_message(hass): "light.another_light": { "on": True, "online": True, - "brightness": 30, + "brightness": 31, "color": { "spectrumHsv": { "hue": 180, @@ -1510,7 +1510,7 @@ async def test_query_recover(hass, caplog): "payload": { "devices": { "light.bad": {"online": False}, - "light.good": {"on": True, "online": True, "brightness": 19}, + "light.good": {"on": True, "online": True, "brightness": 20}, } }, } From e1ba0717e21e3336179cf2670aa2c5fc1b4b877b Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Thu, 26 May 2022 01:12:43 -0300 Subject: [PATCH 09/90] Fix bond device state with v3 firmwares (#72516) --- homeassistant/components/bond/__init__.py | 2 +- homeassistant/components/bond/button.py | 2 +- homeassistant/components/bond/config_flow.py | 2 +- homeassistant/components/bond/cover.py | 2 +- homeassistant/components/bond/entity.py | 10 +++-- homeassistant/components/bond/fan.py | 2 +- homeassistant/components/bond/light.py | 2 +- homeassistant/components/bond/manifest.json | 4 +- homeassistant/components/bond/switch.py | 2 +- homeassistant/components/bond/utils.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bond/common.py | 2 +- tests/components/bond/test_button.py | 2 +- tests/components/bond/test_cover.py | 2 +- tests/components/bond/test_entity.py | 42 +++++++++++++++----- tests/components/bond/test_fan.py | 2 +- tests/components/bond/test_init.py | 2 +- tests/components/bond/test_light.py | 2 +- tests/components/bond/test_switch.py | 2 +- 20 files changed, 59 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 062c1d844c4..557e68272c2 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientError, ClientResponseError, ClientTimeout -from bond_api import Bond, BPUPSubscriptions, start_bpup +from bond_async import Bond, BPUPSubscriptions, start_bpup from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 0152bedde23..0465e4c51fe 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import logging from typing import Any -from bond_api import Action, BPUPSubscriptions +from bond_async import Action, BPUPSubscriptions from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index d3a7b4adf72..6eba9897468 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any from aiohttp import ClientConnectionError, ClientResponseError -from bond_api import Bond +from bond_async import Bond import voluptuous as vol from homeassistant import config_entries, exceptions diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index a50f7b93bbb..3938de0d4bd 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from bond_api import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, BPUPSubscriptions, DeviceType from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 583f1cd96f7..832e9b5d464 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -8,7 +8,7 @@ import logging from typing import Any from aiohttp import ClientError -from bond_api import BPUPSubscriptions +from bond_async import BPUPSubscriptions from homeassistant.const import ( ATTR_HW_VERSION, @@ -156,9 +156,13 @@ class BondEntity(Entity): self._apply_state(state) @callback - def _async_bpup_callback(self, state: dict) -> None: + def _async_bpup_callback(self, json_msg: dict) -> None: """Process a state change from BPUP.""" - self._async_state_callback(state) + topic = json_msg["t"] + if topic != f"devices/{self._device_id}/state": + return + + self._async_state_callback(json_msg["b"]) self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 9acc7874657..f2f6b15f923 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -6,7 +6,7 @@ import math from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_api import Action, BPUPSubscriptions, DeviceType, Direction +from bond_async import Action, BPUPSubscriptions, DeviceType, Direction import voluptuous as vol from homeassistant.components.fan import ( diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index c0c3fc428b8..55084f37b03 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_api import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, BPUPSubscriptions, DeviceType import voluptuous as vol from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 187602057c0..52e9dd1763f 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,10 +3,10 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.18"], + "requirements": ["bond-async==0.1.20"], "zeroconf": ["_bond._tcp.local."], "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "quality_scale": "platinum", "iot_class": "local_push", - "loggers": ["bond_api"] + "loggers": ["bond_async"] } diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 01c224d8307..da0b19dd9ff 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_api import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, BPUPSubscriptions, DeviceType import voluptuous as vol from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index fc78c5758c1..cba213d9450 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast from aiohttp import ClientResponseError -from bond_api import Action, Bond +from bond_async import Action, Bond from homeassistant.util.async_ import gather_with_concurrency diff --git a/requirements_all.txt b/requirements_all.txt index 054f38972db..2404e4331a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ blockchain==1.4.4 # bluepy==1.3.0 # homeassistant.components.bond -bond-api==0.1.18 +bond-async==0.1.20 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f35d8da8ec8..0153a1b3323 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -318,7 +318,7 @@ blebox_uniapi==1.3.3 blinkpy==0.19.0 # homeassistant.components.bond -bond-api==0.1.18 +bond-async==0.1.20 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 9c53c0afb8b..4b45a4016c0 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -8,7 +8,7 @@ from typing import Any from unittest.mock import MagicMock, patch from aiohttp.client_exceptions import ClientResponseError -from bond_api import DeviceType +from bond_async import DeviceType from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN diff --git a/tests/components/bond/test_button.py b/tests/components/bond/test_button.py index ee6e98b8462..4411b25657b 100644 --- a/tests/components/bond/test_button.py +++ b/tests/components/bond/test_button.py @@ -1,6 +1,6 @@ """Tests for the Bond button device.""" -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType from homeassistant import core from homeassistant.components.bond.button import STEP_SIZE diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index ca467d4a38d..ccb44402a3e 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -1,7 +1,7 @@ """Tests for the Bond cover device.""" from datetime import timedelta -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType from homeassistant import core from homeassistant.components.cover import ( diff --git a/tests/components/bond/test_entity.py b/tests/components/bond/test_entity.py index 122e9c2f04e..9245f4513ed 100644 --- a/tests/components/bond/test_entity.py +++ b/tests/components/bond/test_entity.py @@ -3,7 +3,8 @@ import asyncio from datetime import timedelta from unittest.mock import patch -from bond_api import BPUPSubscriptions, DeviceType +from bond_async import BPUPSubscriptions, DeviceType +from bond_async.bpup import BPUP_ALIVE_TIMEOUT from homeassistant import core from homeassistant.components import fan @@ -44,24 +45,47 @@ async def test_bpup_goes_offline_and_recovers_same_entity(hass: core.HomeAssista bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 3, "direction": 0}, } ) await hass.async_block_till_done() assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + # Send a message for the wrong device to make sure its ignored + # we should never get this callback + bpup_subs.notify( + { + "s": 200, + "t": "devices/other-device-id/state", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + + # Test we ignore messages for the wrong topic + bpup_subs.notify( + { + "s": 200, + "t": "devices/test-device-id/other_topic", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 1, "direction": 0}, } ) await hass.async_block_till_done() assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 - bpup_subs.last_message_time = 0 + bpup_subs.last_message_time = -BPUP_ALIVE_TIMEOUT with patch_bond_device_state(side_effect=asyncio.TimeoutError): async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) await hass.async_block_till_done() @@ -75,7 +99,7 @@ async def test_bpup_goes_offline_and_recovers_same_entity(hass: core.HomeAssista bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 2, "direction": 0}, } ) @@ -106,7 +130,7 @@ async def test_bpup_goes_offline_and_recovers_different_entity( bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 3, "direction": 0}, } ) @@ -116,14 +140,14 @@ async def test_bpup_goes_offline_and_recovers_different_entity( bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 1, "direction": 0}, } ) await hass.async_block_till_done() assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 - bpup_subs.last_message_time = 0 + bpup_subs.last_message_time = -BPUP_ALIVE_TIMEOUT with patch_bond_device_state(side_effect=asyncio.TimeoutError): async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) await hass.async_block_till_done() @@ -133,7 +157,7 @@ async def test_bpup_goes_offline_and_recovers_different_entity( bpup_subs.notify( { "s": 200, - "t": "bond/not-this-device-id/update", + "t": "devices/not-this-device-id/state", "b": {"power": 1, "speed": 2, "direction": 0}, } ) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 061e94595bf..7c860e68efc 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import call -from bond_api import Action, DeviceType, Direction +from bond_async import Action, DeviceType, Direction import pytest from homeassistant import core diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 88615d98122..03eb490b65e 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import MagicMock, Mock from aiohttp import ClientConnectionError, ClientResponseError -from bond_api import DeviceType +from bond_async import DeviceType import pytest from homeassistant.components.bond.const import DOMAIN diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 6556c25efe2..c7d8f195423 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -1,7 +1,7 @@ """Tests for the Bond light device.""" from datetime import timedelta -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType import pytest from homeassistant import core diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 619eac69e71..b63bad2d431 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -1,7 +1,7 @@ """Tests for the Bond switch device.""" from datetime import timedelta -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType import pytest from homeassistant import core From 3be5a354c0e860d2a9d829007e2b04c3c1e7718f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 26 May 2022 15:17:44 -0400 Subject: [PATCH 10/90] Fix jitter in nzbget uptime sensor (#72518) --- homeassistant/components/nzbget/sensor.py | 30 +++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 9e5bd6e4ac9..a1097389020 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -1,7 +1,7 @@ """Monitor the NZBGet API.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from homeassistant.components.sensor import ( @@ -105,15 +105,16 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): description: SensorEntityDescription, ) -> None: """Initialize a new NZBGet sensor.""" - self.entity_description = description - self._attr_unique_id = f"{entry_id}_{description.key}" - super().__init__( coordinator=coordinator, entry_id=entry_id, name=f"{entry_name} {description.name}", ) + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + self._native_value: datetime | None = None + @property def native_value(self): """Return the state of the sensor.""" @@ -122,14 +123,17 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): if value is None: _LOGGER.warning("Unable to locate value for %s", sensor_type) - return None - - if "DownloadRate" in sensor_type and value > 0: + self._native_value = None + elif "DownloadRate" in sensor_type and value > 0: # Convert download rate from Bytes/s to MBytes/s - return round(value / 2**20, 2) + self._native_value = round(value / 2**20, 2) + elif "UpTimeSec" in sensor_type and value > 0: + uptime = utcnow().replace(microsecond=0) - timedelta(seconds=value) + if not isinstance(self._attr_native_value, datetime) or abs( + uptime - self._attr_native_value + ) > timedelta(seconds=5): + self._native_value = uptime + else: + self._native_value = value - if "UpTimeSec" in sensor_type and value > 0: - uptime = utcnow() - timedelta(seconds=value) - return uptime.replace(microsecond=0) - - return value + return self._native_value From e1c39d8c4b4d1bf571ca3dbfec6bf585915abfbe Mon Sep 17 00:00:00 2001 From: j-a-n Date: Thu, 26 May 2022 13:23:49 +0200 Subject: [PATCH 11/90] Fix Moehlenhoff Alpha2 set_target_temperature and set_heat_area_mode (#72533) Fix set_target_temperature and set_heat_area_mode --- .../components/moehlenhoff_alpha2/__init__.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 93ddaa781ab..86306a56033 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -98,7 +98,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): update_data = {"T_TARGET": target_temperature} is_cooling = self.get_cooling() - heat_area_mode = self.data[heat_area_id]["HEATAREA_MODE"] + heat_area_mode = self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] if heat_area_mode == 1: if is_cooling: update_data["T_COOL_DAY"] = target_temperature @@ -116,7 +116,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): raise HomeAssistantError( "Failed to set target temperature, communication error with alpha2 base" ) from http_err - self.data[heat_area_id].update(update_data) + self.data["heat_areas"][heat_area_id].update(update_data) for update_callback in self._listeners: update_callback() @@ -141,25 +141,25 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): "Failed to set heat area mode, communication error with alpha2 base" ) from http_err - self.data[heat_area_id]["HEATAREA_MODE"] = heat_area_mode + self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] = heat_area_mode is_cooling = self.get_cooling() if heat_area_mode == 1: if is_cooling: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_COOL_DAY" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_COOL_DAY"] else: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_HEAT_DAY" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_HEAT_DAY"] elif heat_area_mode == 2: if is_cooling: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_COOL_NIGHT" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_COOL_NIGHT"] else: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_HEAT_NIGHT" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_HEAT_NIGHT"] for update_callback in self._listeners: update_callback() From a7fc1a4d62141ff32e3214def7f09bc6c39d16ee Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 May 2022 13:16:31 -0700 Subject: [PATCH 12/90] Bumped version to 2022.6.0b1 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e437c171c50..ada9a6bbd06 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index c9b915b229a..c62390253dc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b0 +version = 2022.6.0b1 url = https://www.home-assistant.io/ [options] From 370e4c53f37c3ee839a9c4299b9301f4cbf12c36 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 26 May 2022 19:20:05 -0400 Subject: [PATCH 13/90] Add logbook entries for zwave_js events (#72508) * Add logbook entries for zwave_js events * Fix test * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * black * Remove value updated event Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 6 +- homeassistant/components/zwave_js/logbook.py | 115 +++++++++++++++ tests/components/zwave_js/test_events.py | 2 +- tests/components/zwave_js/test_logbook.py | 132 ++++++++++++++++++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/zwave_js/logbook.py create mode 100644 tests/components/zwave_js/test_logbook.py diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 5c583d8321f..4f5756361c8 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -297,8 +297,8 @@ async def setup_driver( # noqa: C901 if not disc_info.assumed_state: return value_updates_disc_info[disc_info.primary_value.value_id] = disc_info - # If this is the first time we found a value we want to watch for updates, - # return early + # If this is not the first time we found a value we want to watch for updates, + # return early because we only need one listener for all values. if len(value_updates_disc_info) != 1: return # add listener for value updated events @@ -503,7 +503,7 @@ async def setup_driver( # noqa: C901 elif isinstance(notification, PowerLevelNotification): event_data.update( { - ATTR_COMMAND_CLASS_NAME: "Power Level", + ATTR_COMMAND_CLASS_NAME: "Powerlevel", ATTR_TEST_NODE_ID: notification.test_node_id, ATTR_STATUS: notification.status, ATTR_ACKNOWLEDGED_FRAMES: notification.acknowledged_frames, diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py new file mode 100644 index 00000000000..1fe1ff79ec6 --- /dev/null +++ b/homeassistant/components/zwave_js/logbook.py @@ -0,0 +1,115 @@ +"""Describe Z-Wave JS logbook events.""" +from __future__ import annotations + +from collections.abc import Callable + +from zwave_js_server.const import CommandClass + +from homeassistant.components.logbook.const import ( + LOGBOOK_ENTRY_MESSAGE, + LOGBOOK_ENTRY_NAME, +) +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_COMMAND_CLASS_NAME, + ATTR_DATA_TYPE, + ATTR_DIRECTION, + ATTR_EVENT_LABEL, + ATTR_EVENT_TYPE, + ATTR_LABEL, + ATTR_VALUE, + DOMAIN, + ZWAVE_JS_NOTIFICATION_EVENT, + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, +) + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + dev_reg = dr.async_get(hass) + + @callback + def async_describe_zwave_js_notification_event( + event: Event, + ) -> dict[str, str]: + """Describe Z-Wave JS notification event.""" + device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] + # Z-Wave JS devices always have a name + device_name = device.name_by_user or device.name + assert device_name + + command_class = event.data[ATTR_COMMAND_CLASS] + command_class_name = event.data[ATTR_COMMAND_CLASS_NAME] + + data: dict[str, str] = {LOGBOOK_ENTRY_NAME: device_name} + prefix = f"fired {command_class_name} CC 'notification' event" + + if command_class == CommandClass.NOTIFICATION: + label = event.data[ATTR_LABEL] + event_label = event.data[ATTR_EVENT_LABEL] + return { + **data, + LOGBOOK_ENTRY_MESSAGE: f"{prefix} '{label}': '{event_label}'", + } + + if command_class == CommandClass.ENTRY_CONTROL: + event_type = event.data[ATTR_EVENT_TYPE] + data_type = event.data[ATTR_DATA_TYPE] + return { + **data, + LOGBOOK_ENTRY_MESSAGE: ( + f"{prefix} for event type '{event_type}' with data type " + f"'{data_type}'" + ), + } + + if command_class == CommandClass.SWITCH_MULTILEVEL: + event_type = event.data[ATTR_EVENT_TYPE] + direction = event.data[ATTR_DIRECTION] + return { + **data, + LOGBOOK_ENTRY_MESSAGE: ( + f"{prefix} for event type '{event_type}': '{direction}'" + ), + } + + return {**data, LOGBOOK_ENTRY_MESSAGE: prefix} + + @callback + def async_describe_zwave_js_value_notification_event( + event: Event, + ) -> dict[str, str]: + """Describe Z-Wave JS value notification event.""" + device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] + # Z-Wave JS devices always have a name + device_name = device.name_by_user or device.name + assert device_name + + command_class = event.data[ATTR_COMMAND_CLASS_NAME] + label = event.data[ATTR_LABEL] + value = event.data[ATTR_VALUE] + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: ( + f"fired {command_class} CC 'value notification' event for '{label}': " + f"'{value}'" + ), + } + + async_describe_event( + DOMAIN, ZWAVE_JS_NOTIFICATION_EVENT, async_describe_zwave_js_notification_event + ) + async_describe_event( + DOMAIN, + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, + async_describe_zwave_js_value_notification_event, + ) diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 72da1fcb915..19f38d4aa57 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -312,7 +312,7 @@ async def test_power_level_notification(hass, hank_binary_switch, integration, c node.receive_event(event) await hass.async_block_till_done() assert len(events) == 1 - assert events[0].data["command_class_name"] == "Power Level" + assert events[0].data["command_class_name"] == "Powerlevel" assert events[0].data["command_class"] == 115 assert events[0].data["test_node_id"] == 1 assert events[0].data["status"] == 0 diff --git a/tests/components/zwave_js/test_logbook.py b/tests/components/zwave_js/test_logbook.py new file mode 100644 index 00000000000..eb02c1bbdcf --- /dev/null +++ b/tests/components/zwave_js/test_logbook.py @@ -0,0 +1,132 @@ +"""The tests for Z-Wave JS logbook.""" +from zwave_js_server.const import CommandClass + +from homeassistant.components.zwave_js.const import ( + ZWAVE_JS_NOTIFICATION_EVENT, + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, +) +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.components.logbook.common import MockRow, mock_humanify + + +async def test_humanifying_zwave_js_notification_event( + hass, client, lock_schlage_be469, integration +): + """Test humanifying Z-Wave JS notification events.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, lock_schlage_be469)} + ) + assert device + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.NOTIFICATION.value, + "command_class_name": "Notification", + "label": "label", + "event_label": "event_label", + }, + ), + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.ENTRY_CONTROL.value, + "command_class_name": "Entry Control", + "event_type": 1, + "data_type": 2, + }, + ), + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.SWITCH_MULTILEVEL.value, + "command_class_name": "Multilevel Switch", + "event_type": 1, + "direction": "up", + }, + ), + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.POWERLEVEL.value, + "command_class_name": "Powerlevel", + }, + ), + ], + ) + + assert events[0]["name"] == "Touchscreen Deadbolt" + assert events[0]["domain"] == "zwave_js" + assert ( + events[0]["message"] + == "fired Notification CC 'notification' event 'label': 'event_label'" + ) + + assert events[1]["name"] == "Touchscreen Deadbolt" + assert events[1]["domain"] == "zwave_js" + assert ( + events[1]["message"] + == "fired Entry Control CC 'notification' event for event type '1' with data type '2'" + ) + + assert events[2]["name"] == "Touchscreen Deadbolt" + assert events[2]["domain"] == "zwave_js" + assert ( + events[2]["message"] + == "fired Multilevel Switch CC 'notification' event for event type '1': 'up'" + ) + + assert events[3]["name"] == "Touchscreen Deadbolt" + assert events[3]["domain"] == "zwave_js" + assert events[3]["message"] == "fired Powerlevel CC 'notification' event" + + +async def test_humanifying_zwave_js_value_notification_event( + hass, client, lock_schlage_be469, integration +): + """Test humanifying Z-Wave JS value notification events.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, lock_schlage_be469)} + ) + assert device + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.SCENE_ACTIVATION.value, + "command_class_name": "Scene Activation", + "label": "Scene ID", + "value": "001", + }, + ), + ], + ) + + assert events[0]["name"] == "Touchscreen Deadbolt" + assert events[0]["domain"] == "zwave_js" + assert ( + events[0]["message"] + == "fired Scene Activation CC 'value notification' event for 'Scene ID': '001'" + ) From f8e300ffbe9318ff66fe977a0594ec85a97ffac7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 May 2022 10:31:48 -0700 Subject: [PATCH 14/90] Include provider type in auth token response (#72560) --- homeassistant/components/auth/__init__.py | 19 +++++++++++++++---- tests/components/auth/test_init.py | 1 + 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 30b36a40f32..10f974faa28 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -19,13 +19,15 @@ Exchange the authorization code retrieved from the login flow for tokens. Return value will be the access and refresh tokens. The access token will have a limited expiration. New access tokens can be requested using the refresh -token. +token. The value ha_auth_provider will contain the auth provider type that was +used to authorize the refresh token. { "access_token": "ABCDEFGH", "expires_in": 1800, "refresh_token": "IJKLMNOPQRST", - "token_type": "Bearer" + "token_type": "Bearer", + "ha_auth_provider": "homeassistant" } ## Grant type refresh_token @@ -342,7 +344,12 @@ class TokenView(HomeAssistantView): "expires_in": int( refresh_token.access_token_expiration.total_seconds() ), - } + "ha_auth_provider": credential.auth_provider_type, + }, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, ) async def _async_handle_refresh_token(self, hass, data, remote_addr): @@ -399,7 +406,11 @@ class TokenView(HomeAssistantView): "expires_in": int( refresh_token.access_token_expiration.total_seconds() ), - } + }, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, ) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index f6d0695d97d..3c90d915966 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -81,6 +81,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): assert ( await hass.auth.async_validate_access_token(tokens["access_token"]) is not None ) + assert tokens["ha_auth_provider"] == "insecure_example" # Use refresh token to get more tokens. resp = await client.post( From 16ab7f2bb1ffbdf248c3a15721cfe1a24f06b278 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 May 2022 05:48:52 +0200 Subject: [PATCH 15/90] Update frontend to 20220526.0 (#72567) --- 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 6c8f568c4d2..48488bc8f47 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220525.0"], + "requirements": ["home-assistant-frontend==20220526.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5a6dc9891a6..309d4b89e9f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220525.0 +home-assistant-frontend==20220526.0 httpx==0.22.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2404e4331a1..c7459285fa0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,7 +822,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220525.0 +home-assistant-frontend==20220526.0 # homeassistant.components.home_connect homeconnect==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0153a1b3323..7233824a3cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -589,7 +589,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220525.0 +home-assistant-frontend==20220526.0 # homeassistant.components.home_connect homeconnect==0.7.0 From bd02c9e5b309b9d9f6078b4e26ea47f9c191e64d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 May 2022 22:15:20 -0700 Subject: [PATCH 16/90] Attach SSL context to SMTP notify and IMAP sensor (#72568) --- .../components/imap_email_content/sensor.py | 26 ++++++++++------ homeassistant/components/smtp/notify.py | 30 +++++++++++++------ tests/components/smtp/test_notify.py | 1 + 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index d0d87e0b2d5..a8bd394a159 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -17,12 +17,14 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.ssl import client_context _LOGGER = logging.getLogger(__name__) @@ -46,6 +48,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, } ) @@ -58,11 +61,12 @@ def setup_platform( ) -> None: """Set up the Email sensor platform.""" reader = EmailReader( - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - config.get(CONF_SERVER), - config.get(CONF_PORT), - config.get(CONF_FOLDER), + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_SERVER], + config[CONF_PORT], + config[CONF_FOLDER], + config[CONF_VERIFY_SSL], ) if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None: @@ -70,8 +74,8 @@ def setup_platform( sensor = EmailContentSensor( hass, reader, - config.get(CONF_NAME) or config.get(CONF_USERNAME), - config.get(CONF_SENDERS), + config.get(CONF_NAME) or config[CONF_USERNAME], + config[CONF_SENDERS], value_template, ) @@ -82,21 +86,25 @@ def setup_platform( class EmailReader: """A class to read emails from an IMAP server.""" - def __init__(self, user, password, server, port, folder): + def __init__(self, user, password, server, port, folder, verify_ssl): """Initialize the Email Reader.""" self._user = user self._password = password self._server = server self._port = port self._folder = folder + self._verify_ssl = verify_ssl self._last_id = None self._unread_ids = deque([]) self.connection = None def connect(self): """Login and setup the connection.""" + ssl_context = client_context() if self._verify_ssl else None try: - self.connection = imaplib.IMAP4_SSL(self._server, self._port) + self.connection = imaplib.IMAP4_SSL( + self._server, self._port, ssl_context=ssl_context + ) self.connection.login(self._user, self._password) return True except imaplib.IMAP4.error: diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 7b8e2dad1ed..866d7980d08 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -25,10 +25,12 @@ from homeassistant.const import ( CONF_SENDER, CONF_TIMEOUT, CONF_USERNAME, + CONF_VERIFY_SSL, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service import homeassistant.util.dt as dt_util +from homeassistant.util.ssl import client_context from . import DOMAIN, PLATFORMS @@ -65,6 +67,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_SENDER_NAME): cv.string, vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, } ) @@ -73,16 +76,17 @@ def get_service(hass, config, discovery_info=None): """Get the mail notification service.""" setup_reload_service(hass, DOMAIN, PLATFORMS) mail_service = MailNotificationService( - config.get(CONF_SERVER), - config.get(CONF_PORT), - config.get(CONF_TIMEOUT), - config.get(CONF_SENDER), - config.get(CONF_ENCRYPTION), + config[CONF_SERVER], + config[CONF_PORT], + config[CONF_TIMEOUT], + config[CONF_SENDER], + config[CONF_ENCRYPTION], config.get(CONF_USERNAME), config.get(CONF_PASSWORD), - config.get(CONF_RECIPIENT), + config[CONF_RECIPIENT], config.get(CONF_SENDER_NAME), - config.get(CONF_DEBUG), + config[CONF_DEBUG], + config[CONF_VERIFY_SSL], ) if mail_service.connection_is_valid(): @@ -106,6 +110,7 @@ class MailNotificationService(BaseNotificationService): recipients, sender_name, debug, + verify_ssl, ): """Initialize the SMTP service.""" self._server = server @@ -118,18 +123,25 @@ class MailNotificationService(BaseNotificationService): self.recipients = recipients self._sender_name = sender_name self.debug = debug + self._verify_ssl = verify_ssl self.tries = 2 def connect(self): """Connect/authenticate to SMTP Server.""" + ssl_context = client_context() if self._verify_ssl else None if self.encryption == "tls": - mail = smtplib.SMTP_SSL(self._server, self._port, timeout=self._timeout) + mail = smtplib.SMTP_SSL( + self._server, + self._port, + timeout=self._timeout, + context=ssl_context, + ) else: mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout) mail.set_debuglevel(self.debug) mail.ehlo_or_helo_if_needed() if self.encryption == "starttls": - mail.starttls() + mail.starttls(context=ssl_context) mail.ehlo() if self.username and self.password: mail.login(self.username, self.password) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 38f48c169ac..ac742e10ea1 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -76,6 +76,7 @@ def message(): ["recip1@example.com", "testrecip@test.com"], "Home Assistant", 0, + True, ) yield mailer From 828afd8c0559af30674fb1e4ec008661dec9e869 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 27 May 2022 05:51:24 +0200 Subject: [PATCH 17/90] fjaraskupan: Don't set hardware filters for service id (#72569) --- homeassistant/components/fjaraskupan/__init__.py | 4 ++-- homeassistant/components/fjaraskupan/config_flow.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 488139b080b..ec4528bc079 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -9,7 +9,7 @@ import logging from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from fjaraskupan import UUID_SERVICE, Device, State, device_filter +from fjaraskupan import DEVICE_NAME, Device, State, device_filter from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -90,7 +90,7 @@ class EntryState: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Fjäråskupan from a config entry.""" - scanner = BleakScanner(filters={"UUIDs": [str(UUID_SERVICE)]}) + scanner = BleakScanner(filters={"Pattern": DEVICE_NAME, "DuplicateData": True}) state = EntryState(scanner, {}) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py index da0a7f1dd2b..3af34c0eef6 100644 --- a/homeassistant/components/fjaraskupan/config_flow.py +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -7,7 +7,7 @@ import async_timeout from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from fjaraskupan import UUID_SERVICE, device_filter +from fjaraskupan import DEVICE_NAME, device_filter from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_flow import register_discovery_flow @@ -27,7 +27,8 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: event.set() async with BleakScanner( - detection_callback=detection, filters={"UUIDs": [str(UUID_SERVICE)]} + detection_callback=detection, + filters={"Pattern": DEVICE_NAME, "DuplicateData": True}, ): try: async with async_timeout.timeout(CONST_WAIT_TIME): From 9b779082d5ffdc31a4139b85a9699d34c49dbfde Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 May 2022 17:54:26 -1000 Subject: [PATCH 18/90] Fix memory leak when firing state_changed events (#72571) --- homeassistant/components/recorder/models.py | 2 +- homeassistant/core.py | 45 +++++++++++++++++---- tests/test_core.py | 45 +++++++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index dff8edde79f..70c816c2af5 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -746,7 +746,7 @@ class LazyState(State): def context(self) -> Context: # type: ignore[override] """State context.""" if self._context is None: - self._context = Context(id=None) # type: ignore[arg-type] + self._context = Context(id=None) return self._context @context.setter diff --git a/homeassistant/core.py b/homeassistant/core.py index 2753b801347..d7cae4e411e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -37,7 +37,6 @@ from typing import ( ) from urllib.parse import urlparse -import attr import voluptuous as vol import yarl @@ -716,14 +715,26 @@ class HomeAssistant: self._stopped.set() -@attr.s(slots=True, frozen=False) class Context: """The context that triggered something.""" - user_id: str | None = attr.ib(default=None) - parent_id: str | None = attr.ib(default=None) - id: str = attr.ib(factory=ulid_util.ulid) - origin_event: Event | None = attr.ib(default=None, eq=False) + __slots__ = ("user_id", "parent_id", "id", "origin_event") + + def __init__( + self, + user_id: str | None = None, + parent_id: str | None = None, + id: str | None = None, # pylint: disable=redefined-builtin + ) -> None: + """Init the context.""" + self.id = id or ulid_util.ulid() + self.user_id = user_id + self.parent_id = parent_id + self.origin_event: Event | None = None + + def __eq__(self, other: Any) -> bool: + """Compare contexts.""" + return bool(self.__class__ == other.__class__ and self.id == other.id) def as_dict(self) -> dict[str, str | None]: """Return a dictionary representation of the context.""" @@ -1163,6 +1174,24 @@ class State: context, ) + def expire(self) -> None: + """Mark the state as old. + + We give up the original reference to the context to ensure + the context can be garbage collected by replacing it with + a new one with the same id to ensure the old state + can still be examined for comparison against the new state. + + Since we are always going to fire a EVENT_STATE_CHANGED event + after we remove a state from the state machine we need to make + sure we don't end up holding a reference to the original context + since it can never be garbage collected as each event would + reference the previous one. + """ + self.context = Context( + self.context.user_id, self.context.parent_id, self.context.id + ) + def __eq__(self, other: Any) -> bool: """Return the comparison of the state.""" return ( # type: ignore[no-any-return] @@ -1303,6 +1332,7 @@ class StateMachine: if old_state is None: return False + old_state.expire() self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": None}, @@ -1396,7 +1426,6 @@ class StateMachine: if context is None: context = Context(id=ulid_util.ulid(dt_util.utc_to_timestamp(now))) - state = State( entity_id, new_state, @@ -1406,6 +1435,8 @@ class StateMachine: context, old_state is None, ) + if old_state is not None: + old_state.expire() self._states[entity_id] = state self._bus.async_fire( EVENT_STATE_CHANGED, diff --git a/tests/test_core.py b/tests/test_core.py index ee1005a60b0..67513ea8b17 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,9 +6,11 @@ import array import asyncio from datetime import datetime, timedelta import functools +import gc import logging import os from tempfile import TemporaryDirectory +from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest @@ -1829,3 +1831,46 @@ async def test_event_context(hass): cancel2() assert dummy_event2.context.origin_event == dummy_event + + +def _get_full_name(obj) -> str: + """Get the full name of an object in memory.""" + objtype = type(obj) + name = objtype.__name__ + if module := getattr(objtype, "__module__", None): + return f"{module}.{name}" + return name + + +def _get_by_type(full_name: str) -> list[Any]: + """Get all objects in memory with a specific type.""" + return [obj for obj in gc.get_objects() if _get_full_name(obj) == full_name] + + +# The logger will hold a strong reference to the event for the life of the tests +# so we must patch it out +@pytest.mark.skipif( + not os.environ.get("DEBUG_MEMORY"), + reason="Takes too long on the CI", +) +@patch.object(ha._LOGGER, "debug", lambda *args: None) +async def test_state_changed_events_to_not_leak_contexts(hass): + """Test state changed events do not leak contexts.""" + gc.collect() + # Other tests can log Contexts which keep them in memory + # so we need to look at how many exist at the start + init_count = len(_get_by_type("homeassistant.core.Context")) + + assert len(_get_by_type("homeassistant.core.Context")) == init_count + for i in range(20): + hass.states.async_set("light.switch", str(i)) + await hass.async_block_till_done() + gc.collect() + + assert len(_get_by_type("homeassistant.core.Context")) == init_count + 2 + + hass.states.async_remove("light.switch") + await hass.async_block_till_done() + gc.collect() + + assert len(_get_by_type("homeassistant.core.Context")) == init_count From 0d03b850293836a055a726b51284b48df2c5ee82 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Fri, 27 May 2022 17:20:37 +1000 Subject: [PATCH 19/90] Bump httpx to 0.23.0 (#72573) Co-authored-by: J. Nick Koston --- homeassistant/package_constraints.txt | 6 +++--- requirements.txt | 2 +- script/gen_requirements_all.py | 4 ++-- setup.cfg | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 309d4b89e9f..f235cc3f02c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 home-assistant-frontend==20220526.0 -httpx==0.22.0 +httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.7 @@ -78,9 +78,9 @@ 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.5.0 +anyio==3.6.1 h11==0.12.0 -httpcore==0.14.7 +httpcore==0.15.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/requirements.txt b/requirements.txt index 0c13b9c319b..8321e70f8de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ awesomeversion==22.5.1 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 -httpx==0.22.0 +httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 369045e5124..f049080b7fa 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -94,9 +94,9 @@ 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.5.0 +anyio==3.6.1 h11==0.12.0 -httpcore==0.14.7 +httpcore==0.15.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/setup.cfg b/setup.cfg index c62390253dc..205fcd96086 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,7 +16,7 @@ install_requires = ciso8601==2.2.0 # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - httpx==0.22.0 + httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 From a35edc67516b0d123d5be9111db9a602ff4ecf2a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 27 May 2022 02:52:24 -0700 Subject: [PATCH 20/90] Reduce the scope of the google calendar track deprecation (#72575) --- homeassistant/components/google/__init__.py | 1 - homeassistant/components/google/calendar.py | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index f034d48b9c5..b7263d2e469 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -117,7 +117,6 @@ CONFIG_SCHEMA = vol.Schema( _SINGLE_CALSEARCH_CONFIG = vol.All( cv.deprecated(CONF_MAX_RESULTS), - cv.deprecated(CONF_TRACK), vol.Schema( { vol.Required(CONF_NAME): cv.string, diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 01780702b7f..ba4368fefae 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -93,6 +93,11 @@ def _async_setup_entities( num_entities = len(disc_info[CONF_ENTITIES]) for data in disc_info[CONF_ENTITIES]: entity_enabled = data.get(CONF_TRACK, True) + if not entity_enabled: + _LOGGER.warning( + "The 'track' option in google_calendars.yaml has been deprecated. The setting " + "has been imported to the UI, and should now be removed from google_calendars.yaml" + ) entity_name = data[CONF_DEVICE_ID] entity_id = generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass) calendar_id = disc_info[CONF_CAL_ID] From 319275bbbd74102274390a843966d1d560dbb433 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 May 2022 22:15:43 -1000 Subject: [PATCH 21/90] Revert "Remove sqlite 3.34.1 downgrade workaround by reverting "Downgrade sqlite-libs on docker image (#55591)" (#72342)" (#72578) --- Dockerfile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Dockerfile b/Dockerfile index 13552d55a3d..1d6ce675e74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,21 @@ RUN \ -e ./homeassistant --use-deprecated=legacy-resolver \ && python3 -m compileall homeassistant/homeassistant +# Fix Bug with Alpine 3.14 and sqlite 3.35 +# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524 +ARG BUILD_ARCH +RUN \ + if [ "${BUILD_ARCH}" = "amd64" ]; then \ + export APK_ARCH=x86_64; \ + elif [ "${BUILD_ARCH}" = "i386" ]; then \ + export APK_ARCH=x86; \ + else \ + export APK_ARCH=${BUILD_ARCH}; \ + fi \ + && curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \ + && apk add --no-cache sqlite-libs-3.34.1-r0.apk \ + && rm -f sqlite-libs-3.34.1-r0.apk + # Home Assistant S6-Overlay COPY rootfs / From cc53ad12b3fbcdc491653d713906ffacaab4e5a0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 15:09:43 +0200 Subject: [PATCH 22/90] Simplify MQTT PLATFORM_CONFIG_SCHEMA_BASE (#72589) --- homeassistant/components/mqtt/__init__.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e8847375584..78f64387435 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -190,26 +190,7 @@ MQTT_WILL_BIRTH_SCHEMA = vol.Schema( ) PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( - { - vol.Optional(Platform.ALARM_CONTROL_PANEL.value): cv.ensure_list, - vol.Optional(Platform.BINARY_SENSOR.value): cv.ensure_list, - vol.Optional(Platform.BUTTON.value): cv.ensure_list, - vol.Optional(Platform.CAMERA.value): cv.ensure_list, - vol.Optional(Platform.CLIMATE.value): cv.ensure_list, - vol.Optional(Platform.COVER.value): cv.ensure_list, - vol.Optional(Platform.DEVICE_TRACKER.value): cv.ensure_list, - vol.Optional(Platform.FAN.value): cv.ensure_list, - vol.Optional(Platform.HUMIDIFIER.value): cv.ensure_list, - vol.Optional(Platform.LIGHT.value): cv.ensure_list, - vol.Optional(Platform.LOCK.value): cv.ensure_list, - vol.Optional(Platform.NUMBER.value): cv.ensure_list, - vol.Optional(Platform.SCENE.value): cv.ensure_list, - vol.Optional(Platform.SELECT.value): cv.ensure_list, - vol.Optional(Platform.SIREN.value): cv.ensure_list, - vol.Optional(Platform.SENSOR.value): cv.ensure_list, - vol.Optional(Platform.SWITCH.value): cv.ensure_list, - vol.Optional(Platform.VACUUM.value): cv.ensure_list, - } + {vol.Optional(platform.value): cv.ensure_list for platform in PLATFORMS} ) CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( From ad6529520160b94c45dbf0e4c2c20ec27ef79a52 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 17:40:55 +0200 Subject: [PATCH 23/90] Require passing target player when resolving media (#72593) --- .../components/media_source/__init__.py | 23 +++++++++++++------ .../components/media_source/local_source.py | 4 ++-- .../components/media_source/models.py | 7 ++++-- .../components/dlna_dms/test_media_source.py | 8 +++---- tests/components/media_source/test_init.py | 19 +++++++++++++++ 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 3c42016f8f7..4818934d1dd 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,10 +18,11 @@ from homeassistant.components.media_player.browse_media import ( ) from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.frame import report from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass from . import local_source @@ -80,15 +81,15 @@ async def _process_media_source_platform( @callback def _get_media_item( - hass: HomeAssistant, media_content_id: str | None + hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None ) -> MediaSourceItem: """Return media item.""" if media_content_id: - item = MediaSourceItem.from_uri(hass, media_content_id) + item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player) else: # We default to our own domain if its only one registered domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN - return MediaSourceItem(hass, domain, "") + return MediaSourceItem(hass, domain, "", target_media_player) if item.domain is not None and item.domain not in hass.data[DOMAIN]: raise ValueError("Unknown media source") @@ -108,7 +109,7 @@ async def async_browse_media( raise BrowseError("Media Source not loaded") try: - item = await _get_media_item(hass, media_content_id).async_browse() + item = await _get_media_item(hass, media_content_id, None).async_browse() except ValueError as err: raise BrowseError(str(err)) from err @@ -124,13 +125,21 @@ async def async_browse_media( @bind_hass -async def async_resolve_media(hass: HomeAssistant, media_content_id: str) -> PlayMedia: +async def async_resolve_media( + hass: HomeAssistant, + media_content_id: str, + target_media_player: str | None | UndefinedType = UNDEFINED, +) -> PlayMedia: """Get info to play media.""" if DOMAIN not in hass.data: raise Unresolvable("Media Source not loaded") + if target_media_player is UNDEFINED: + report("calls media_source.async_resolve_media without passing an entity_id") + target_media_player = None + try: - item = _get_media_item(hass, media_content_id) + item = _get_media_item(hass, media_content_id, target_media_player) except ValueError as err: raise Unresolvable(str(err)) from err diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 89feba5317f..863380b7600 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -264,7 +264,7 @@ class UploadMediaView(http.HomeAssistantView): raise web.HTTPBadRequest() from err try: - item = MediaSourceItem.from_uri(self.hass, data["media_content_id"]) + item = MediaSourceItem.from_uri(self.hass, data["media_content_id"], None) except ValueError as err: LOGGER.error("Received invalid upload data: %s", err) raise web.HTTPBadRequest() from err @@ -328,7 +328,7 @@ async def websocket_remove_media( ) -> None: """Remove media.""" try: - item = MediaSourceItem.from_uri(hass, msg["media_content_id"]) + item = MediaSourceItem.from_uri(hass, msg["media_content_id"], None) except ValueError as err: connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index ceb57ef1fb4..0aee6ad1330 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -50,6 +50,7 @@ class MediaSourceItem: hass: HomeAssistant domain: str | None identifier: str + target_media_player: str | None async def async_browse(self) -> BrowseMediaSource: """Browse this item.""" @@ -94,7 +95,9 @@ class MediaSourceItem: return cast(MediaSource, self.hass.data[DOMAIN][self.domain]) @classmethod - def from_uri(cls, hass: HomeAssistant, uri: str) -> MediaSourceItem: + def from_uri( + cls, hass: HomeAssistant, uri: str, target_media_player: str | None + ) -> MediaSourceItem: """Create an item from a uri.""" if not (match := URI_SCHEME_REGEX.match(uri)): raise ValueError("Invalid media source URI") @@ -102,7 +105,7 @@ class MediaSourceItem: domain = match.group("domain") identifier = match.group("identifier") - return cls(hass, domain, identifier) + return cls(hass, domain, identifier, target_media_player) class MediaSource(ABC): diff --git a/tests/components/dlna_dms/test_media_source.py b/tests/components/dlna_dms/test_media_source.py index f2c3011e274..5f76b061590 100644 --- a/tests/components/dlna_dms/test_media_source.py +++ b/tests/components/dlna_dms/test_media_source.py @@ -49,7 +49,7 @@ async def test_get_media_source(hass: HomeAssistant) -> None: async def test_resolve_media_unconfigured(hass: HomeAssistant) -> None: """Test resolve_media without any devices being configured.""" source = DmsMediaSource(hass) - item = MediaSourceItem(hass, DOMAIN, "source_id/media_id") + item = MediaSourceItem(hass, DOMAIN, "source_id/media_id", None) with pytest.raises(Unresolvable, match="No sources have been configured"): await source.async_resolve_media(item) @@ -116,11 +116,11 @@ async def test_resolve_media_success( async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: """Test browse_media without any devices being configured.""" source = DmsMediaSource(hass) - item = MediaSourceItem(hass, DOMAIN, "source_id/media_id") + item = MediaSourceItem(hass, DOMAIN, "source_id/media_id", None) with pytest.raises(BrowseError, match="No sources have been configured"): await source.async_browse_media(item) - item = MediaSourceItem(hass, DOMAIN, "") + item = MediaSourceItem(hass, DOMAIN, "", None) with pytest.raises(BrowseError, match="No sources have been configured"): await source.async_browse_media(item) @@ -239,7 +239,7 @@ async def test_browse_media_source_id( dms_device_mock.async_browse_metadata.side_effect = UpnpError # Browse by source_id - item = MediaSourceItem(hass, DOMAIN, f"{MOCK_SOURCE_ID}/:media-item-id") + item = MediaSourceItem(hass, DOMAIN, f"{MOCK_SOURCE_ID}/:media-item-id", None) dms_source = DmsMediaSource(hass) with pytest.raises(BrowseError): await dms_source.async_browse_media(item) diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index 491b1972cb6..f2a8ff13533 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -109,6 +109,25 @@ async def test_async_resolve_media(hass): assert media.mime_type == "audio/mpeg" +@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) +async def test_async_resolve_media_no_entity(hass, caplog): + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + media = await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), + ) + assert isinstance(media, media_source.models.PlayMedia) + assert media.url == "/media/local/test.mp3" + assert media.mime_type == "audio/mpeg" + assert ( + "calls media_source.async_resolve_media without passing an entity_id" + in caplog.text + ) + + async def test_async_unresolve_media(hass): """Test browse media.""" assert await async_setup_component(hass, media_source.DOMAIN, {}) From 087c0b59edb4f6233849e2cf6eb9057474251934 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 18:05:06 +0200 Subject: [PATCH 24/90] Update integrations to pass target player when resolving media (#72597) --- .../components/apple_tv/media_player.py | 4 ++- .../components/bluesound/media_player.py | 4 ++- homeassistant/components/cast/media_player.py | 4 ++- .../components/dlna_dmr/media_player.py | 4 ++- .../components/esphome/media_player.py | 4 ++- .../components/forked_daapd/media_player.py | 4 ++- .../components/gstreamer/media_player.py | 4 ++- homeassistant/components/heos/media_player.py | 4 ++- homeassistant/components/kodi/media_player.py | 4 ++- homeassistant/components/mpd/media_player.py | 4 ++- .../components/openhome/media_player.py | 4 ++- .../panasonic_viera/media_player.py | 4 ++- homeassistant/components/roku/media_player.py | 4 ++- .../components/slimproto/media_player.py | 4 ++- .../components/sonos/media_player.py | 4 ++- .../components/soundtouch/media_player.py | 4 ++- .../components/squeezebox/media_player.py | 4 ++- .../components/unifiprotect/media_player.py | 4 ++- homeassistant/components/vlc/media_player.py | 4 ++- .../components/vlc_telnet/media_player.py | 4 ++- .../yamaha_musiccast/media_player.py | 4 ++- tests/components/camera/test_media_source.py | 10 ++++---- .../dlna_dms/test_device_availability.py | 10 ++++---- .../dlna_dms/test_dms_device_source.py | 2 +- .../components/dlna_dms/test_media_source.py | 12 ++++----- tests/components/google_translate/test_tts.py | 2 +- tests/components/marytts/test_tts.py | 2 +- tests/components/media_source/test_init.py | 11 +++++--- .../components/motioneye/test_media_source.py | 15 ++++++++--- tests/components/nest/test_media_source.py | 25 +++++++++++-------- tests/components/netatmo/test_media_source.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/tts/test_media_source.py | 14 +++++++---- tests/components/voicerss/test_tts.py | 2 +- tests/components/yandextts/test_tts.py | 2 +- 35 files changed, 128 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 5a7298dcbee..30a397d953c 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -284,7 +284,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): await self.atv.apps.launch_app(media_id) if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url media_type = MEDIA_TYPE_MUSIC diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 32f74743972..e22606b795f 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -1032,7 +1032,9 @@ class BluesoundPlayer(MediaPlayerEntity): return if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url media_id = async_process_play_media_url(self.hass, media_id) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b64c3372c15..ea21259ccc4 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -605,7 +605,9 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): """Play a piece of media.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = sourced_media.mime_type media_id = sourced_media.url diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index fd1fc9b2bab..9ecf9f8ad40 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -597,7 +597,9 @@ class DlnaDmrEntity(MediaPlayerEntity): # If media is media_source, resolve it to url and MIME type, and maybe metadata if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = sourced_media.mime_type media_id = sourced_media.url _LOGGER.debug("sourced_media is %s", sourced_media) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 6e83d12a427..f9027142ae2 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -95,7 +95,9 @@ class EsphomeMediaPlayer( ) -> None: """Send the play command with media url to the media player.""" if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url media_id = async_process_play_media_url(self.hass, media_id) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index f2c64fa81da..25695dceeb5 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -666,7 +666,9 @@ class ForkedDaapdMaster(MediaPlayerEntity): """Play a URI.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type == MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index 545941f2924..723be2880ff 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -96,7 +96,9 @@ class GstreamerDevice(MediaPlayerEntity): """Play media.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url elif media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index dabe79afb03..4cfbe5fe408 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -192,7 +192,9 @@ class HeosMediaPlayer(MediaPlayerEntity): """Play a piece of media.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_URL - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type in (MEDIA_TYPE_URL, MEDIA_TYPE_MUSIC): diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index cea3adcde00..e19ffc6219c 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -713,7 +713,9 @@ class KodiEntity(MediaPlayerEntity): """Send the play_media command to the media player.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_URL - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url media_type_lower = media_type.lower() diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index d3262a0d5da..ecee057a653 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -453,7 +453,9 @@ class MpdDevice(MediaPlayerEntity): """Send the media player the command for playing a playlist.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = async_process_play_media_url(self.hass, play_item.url) if media_type == MEDIA_TYPE_PLAYLIST: diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index fa9cce1cfb6..b6a0b549c40 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -209,7 +209,9 @@ class OpenhomeDevice(MediaPlayerEntity): """Send the play_media command to the media player.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index fd44c2853f1..7b75809f827 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -188,7 +188,9 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): """Play media.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_URL - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type != MEDIA_TYPE_URL: diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index e6fe0d7dcf5..a47432694dd 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -384,7 +384,9 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = MEDIA_TYPE_URL media_id = sourced_media.url mime_type = sourced_media.mime_type diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 6b1989830e2..2f85aa4b9df 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -180,7 +180,9 @@ class SlimProtoPlayer(MediaPlayerEntity): to_send_media_type: str | None = media_type # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url to_send_media_type = sourced_media.mime_type diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 95834938953..45e11a810ae 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -551,7 +551,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_type = MEDIA_TYPE_MUSIC media_id = ( run_coroutine_threadsafe( - media_source.async_resolve_media(self.hass, media_id), + media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ), self.hass.loop, ) .result() diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 3172eb4aed6..7c9ade3bee1 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -357,7 +357,9 @@ class SoundTouchDevice(MediaPlayerEntity): async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = async_process_play_media_url(self.hass, play_item.url) await self.hass.async_add_executor_job( diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index bd1f29f4e69..d0d1cf89739 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -482,7 +482,9 @@ class SqueezeBoxEntity(MediaPlayerEntity): if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type in MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 0b7c2a2f60d..1acd14be130 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -118,7 +118,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): """Play a piece of media.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = async_process_play_media_url(self.hass, play_item.url) if media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index 7312eacd1c6..88b663e09c6 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -168,7 +168,9 @@ class VlcDevice(MediaPlayerEntity): """Play media from a URL or file.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url elif media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 89fa1a3c323..75305acbb0c 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -296,7 +296,9 @@ class VlcDevice(MediaPlayerEntity): """Play media from a URL or file.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = sourced_media.mime_type media_id = sourced_media.url diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index d0141977f29..954942b2c6b 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -275,7 +275,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Play media.""" if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if self.state == STATE_OFF: diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index b7c273bb23a..4134e9b1151 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -62,7 +62,7 @@ async def test_resolving(hass, mock_camera_hls): return_value="http://example.com/stream", ): item = await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert item is not None assert item.url == "http://example.com/stream" @@ -74,7 +74,7 @@ async def test_resolving_errors(hass, mock_camera_hls): with pytest.raises(media_source.Unresolvable) as exc_info: await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert str(exc_info.value) == "Stream integration not loaded" @@ -82,7 +82,7 @@ async def test_resolving_errors(hass, mock_camera_hls): with pytest.raises(media_source.Unresolvable) as exc_info: await media_source.async_resolve_media( - hass, "media-source://camera/camera.non_existing" + hass, "media-source://camera/camera.non_existing", None ) assert str(exc_info.value) == "Could not resolve media item: camera.non_existing" @@ -91,13 +91,13 @@ async def test_resolving_errors(hass, mock_camera_hls): new_callable=PropertyMock(return_value=StreamType.WEB_RTC), ): await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert str(exc_info.value) == "Camera does not support MJPEG or HLS streaming." with pytest.raises(media_source.Unresolvable) as exc_info: await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert ( str(exc_info.value) == "camera.demo_camera does not support play stream service" diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index 67ad1024709..a3ec5326f00 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -152,15 +152,15 @@ async def test_unavailable_device( ) with pytest.raises(Unresolvable, match="DMS is not connected"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//resolve_path" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//resolve_path", None ) with pytest.raises(Unresolvable, match="DMS is not connected"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:resolve_object" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:resolve_object", None ) with pytest.raises(Unresolvable): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?resolve_search" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?resolve_search", None ) @@ -651,7 +651,7 @@ async def test_become_unavailable( # Check async_resolve_object currently works assert await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id", None ) # Now break the network connection @@ -660,7 +660,7 @@ async def test_become_unavailable( # async_resolve_object should fail with pytest.raises(Unresolvable): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id", None ) # The device should now be unavailable diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 5e4021a5dda..622a3b8a4f9 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -45,7 +45,7 @@ async def async_resolve_media( ) -> DidlPlayMedia: """Call media_source.async_resolve_media with the test source's ID.""" result = await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}", None ) assert isinstance(result, DidlPlayMedia) return result diff --git a/tests/components/dlna_dms/test_media_source.py b/tests/components/dlna_dms/test_media_source.py index 5f76b061590..35f34d0689b 100644 --- a/tests/components/dlna_dms/test_media_source.py +++ b/tests/components/dlna_dms/test_media_source.py @@ -60,31 +60,31 @@ async def test_resolve_media_bad_identifier( """Test trying to resolve an item that has an unresolvable identifier.""" # Empty identifier with pytest.raises(Unresolvable, match="No source ID.*"): - await media_source.async_resolve_media(hass, f"media-source://{DOMAIN}") + await media_source.async_resolve_media(hass, f"media-source://{DOMAIN}", None) # Identifier has media_id but no source_id # media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms with pytest.raises(Unresolvable, match="Invalid media source URI"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}//media_id" + hass, f"media-source://{DOMAIN}//media_id", None ) # Identifier has source_id but no media_id with pytest.raises(Unresolvable, match="No media ID.*"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/source_id/" + hass, f"media-source://{DOMAIN}/source_id/", None ) # Identifier is missing source_id/media_id separator with pytest.raises(Unresolvable, match="No media ID.*"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/source_id" + hass, f"media-source://{DOMAIN}/source_id", None ) # Identifier has an unknown source_id with pytest.raises(Unresolvable, match="Unknown source ID: unknown_source"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/unknown_source/media_id" + hass, f"media-source://{DOMAIN}/unknown_source/media_id", None ) @@ -105,7 +105,7 @@ async def test_resolve_media_success( dms_device_mock.async_browse_metadata.return_value = didl_item result = await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{object_id}" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{object_id}", None ) assert isinstance(result, DidlPlayMedia) assert result.url == f"{MOCK_DEVICE_BASE_URL}/{res_url}" diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index c81cea57090..cc80d9c64b9 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -25,7 +25,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 843b6578746..60211f7dc0c 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -21,7 +21,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index f2a8ff13533..33dd263c46c 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -103,6 +103,7 @@ async def test_async_resolve_media(hass): media = await media_source.async_resolve_media( hass, media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), + None, ) assert isinstance(media, media_source.models.PlayMedia) assert media.url == "/media/local/test.mp3" @@ -135,15 +136,17 @@ async def test_async_unresolve_media(hass): # Test no media content with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "") + await media_source.async_resolve_media(hass, "", None) # Test invalid media content with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "invalid") + await media_source.async_resolve_media(hass, "invalid", None) # Test invalid media source with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "media-source://media_source2") + await media_source.async_resolve_media( + hass, "media-source://media_source2", None + ) async def test_websocket_browse_media(hass, hass_ws_client): @@ -261,4 +264,4 @@ async def test_browse_resolve_without_setup(): await media_source.async_browse_media(Mock(data={}), None) with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(Mock(data={}), None) + await media_source.async_resolve_media(Mock(data={}), None, None) diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index 9b86b783d43..2cf31c21da7 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -367,6 +367,7 @@ async def test_async_resolve_media_success(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#/foo.mp4" ), + None, ) assert media == PlayMedia(url="http://movie-url", mime_type="video/mp4") assert client.get_movie_url.call_args == call(TEST_CAMERA_ID, "/foo.mp4") @@ -379,6 +380,7 @@ async def test_async_resolve_media_success(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#images#/foo.jpg" ), + None, ) assert media == PlayMedia(url="http://image-url", mime_type="image/jpeg") assert client.get_image_url.call_args == call(TEST_CAMERA_ID, "/foo.jpg") @@ -409,18 +411,20 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: # URI doesn't contain necessary components. with pytest.raises(Unresolvable): - await media_source.async_resolve_media(hass, f"{const.URI_SCHEME}{DOMAIN}/foo") + await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/foo", None + ) # Config entry doesn't exist. with pytest.raises(MediaSourceError): await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/1#2#3#4" + hass, f"{const.URI_SCHEME}{DOMAIN}/1#2#3#4", None ) # Device doesn't exist. with pytest.raises(MediaSourceError): await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#2#3#4" + hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#2#3#4", None ) # Device identifiers are incorrect (no camera id) @@ -431,6 +435,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{broken_device_1.id}#images#4" ), + None, ) # Device identifiers are incorrect (non integer camera id) @@ -441,6 +446,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{broken_device_2.id}#images#4" ), + None, ) # Kind is incorrect. @@ -448,6 +454,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#{device.id}#games#moo", + None, ) # Playback URL raises exception. @@ -459,6 +466,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#/foo.mp4" ), + None, ) # Media path does not start with '/' @@ -470,6 +478,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#foo.mp4" ), + None, ) # Media missing path. diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 1536d0bee1e..09a3f9f625c 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -361,7 +361,7 @@ async def test_camera_event(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "image/jpeg" @@ -374,7 +374,7 @@ async def test_camera_event(hass, auth, hass_client): # Resolving the device id points to the most recent event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "image/jpeg" @@ -535,7 +535,7 @@ async def test_multiple_image_events_in_session(hass, auth, hass_client): # Resolve the most recent event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier2}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier2}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier2}" assert media.mime_type == "image/jpeg" @@ -548,7 +548,7 @@ async def test_multiple_image_events_in_session(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" assert media.mime_type == "image/jpeg" @@ -632,7 +632,7 @@ async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): # to the same clip preview media clip object. # Resolve media for the first event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" assert media.mime_type == "video/mp4" @@ -645,7 +645,7 @@ async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): # Resolve media for the second event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" assert media.mime_type == "video/mp4" @@ -712,6 +712,7 @@ async def test_resolve_missing_event_id(hass, auth): await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}", + None, ) @@ -723,6 +724,7 @@ async def test_resolve_invalid_device_id(hass, auth): await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + None, ) @@ -740,6 +742,7 @@ async def test_resolve_invalid_event_id(hass, auth): media = await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + None, ) assert ( media.url == f"/api/nest/event_media/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW..." @@ -835,7 +838,7 @@ async def test_camera_event_clip_preview(hass, auth, hass_client, mp4): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "video/mp4" @@ -921,7 +924,7 @@ async def test_event_media_failure(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "image/jpeg" @@ -1128,7 +1131,7 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): event_identifier = browse.children[0].identifier media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" @@ -1182,7 +1185,7 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): event_identifier = browse.children[0].identifier media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" @@ -1234,7 +1237,7 @@ async def test_media_store_save_filesystem_error(hass, auth, hass_client): event = browse.children[0] media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{event.identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event.identifier}", None ) assert media.url == f"/api/nest/event_media/{event.identifier}" assert media.mime_type == "video/mp4" diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index db1a79145b4..390da95496a 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -79,7 +79,7 @@ async def test_async_browse_media(hass): # Test successful event resolve media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672" + hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672", None ) assert media == PlayMedia( url="http:///files/high/index.m3u8", mime_type="application/x-mpegURL" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 7fd8cc0facb..78fa49a8fc9 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -29,7 +29,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 22edfef5358..8af1ad9d3bb 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -68,7 +68,7 @@ async def test_browsing(hass): async def test_resolving(hass, mock_get_tts_audio): """Test resolving.""" media = await media_source.async_resolve_media( - hass, "media-source://tts/demo?message=Hello%20World" + hass, "media-source://tts/demo?message=Hello%20World", None ) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" @@ -82,7 +82,9 @@ async def test_resolving(hass, mock_get_tts_audio): # Pass language and options mock_get_tts_audio.reset_mock() media = await media_source.async_resolve_media( - hass, "media-source://tts/demo?message=Bye%20World&language=de&voice=Paulus" + hass, + "media-source://tts/demo?message=Bye%20World&language=de&voice=Paulus", + None, ) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" @@ -98,16 +100,18 @@ async def test_resolving_errors(hass): """Test resolving.""" # No message added with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "media-source://tts/demo") + await media_source.async_resolve_media(hass, "media-source://tts/demo", None) # Non-existing provider with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media( - hass, "media-source://tts/non-existing?message=bla" + hass, "media-source://tts/non-existing?message=bla", None ) # Non-existing option with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media( - hass, "media-source://tts/non-existing?message=bla&non_existing_option=bla" + hass, + "media-source://tts/non-existing?message=bla&non_existing_option=bla", + None, ) diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 3e74d9dc815..099b280625f 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -33,7 +33,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index fdc204384a5..8549b51c341 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -27,7 +27,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url From 27908af61eb0c07106fa721c4b20b9b64e988658 Mon Sep 17 00:00:00 2001 From: xLarry Date: Fri, 27 May 2022 18:19:18 +0200 Subject: [PATCH 25/90] Bump laundrify_aio to v1.1.2 (#72605) --- homeassistant/components/laundrify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index 6a61446d31c..a5737b9cf97 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -3,7 +3,7 @@ "name": "laundrify", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/laundrify", - "requirements": ["laundrify_aio==1.1.1"], + "requirements": ["laundrify_aio==1.1.2"], "codeowners": ["@xLarry"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index c7459285fa0..e94929e8d16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -927,7 +927,7 @@ krakenex==2.1.0 lakeside==0.12 # homeassistant.components.laundrify -laundrify_aio==1.1.1 +laundrify_aio==1.1.2 # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7233824a3cc..edcd957246f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -655,7 +655,7 @@ kostal_plenticore==0.2.0 krakenex==2.1.0 # homeassistant.components.laundrify -laundrify_aio==1.1.1 +laundrify_aio==1.1.2 # homeassistant.components.foscam libpyfoscam==1.0 From 07c7081adec21fcaa14038acabe8227ca25dc53f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 May 2022 10:30:40 -0700 Subject: [PATCH 26/90] Revert "Add service entity context (#71558)" (#72610) --- homeassistant/helpers/service.py | 11 ----------- tests/helpers/test_service.py | 16 +--------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9a1e6caa27e..bc3451c24c0 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Iterable -from contextvars import ContextVar import dataclasses from functools import partial, wraps import logging @@ -64,15 +63,6 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" -_current_entity: ContextVar[str | None] = ContextVar("current_entity", default=None) - - -@callback -def async_get_current_entity() -> str | None: - """Get the current entity on which the service is called.""" - return _current_entity.get() - - class ServiceParams(TypedDict): """Type for service call parameters.""" @@ -716,7 +706,6 @@ async def _handle_entity_call( ) -> None: """Handle calling service method.""" entity.async_set_context(context) - _current_entity.set(entity.entity_id) if isinstance(func, str): result = hass.async_run_job(partial(getattr(entity, func), **data)) # type: ignore[arg-type] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 76cf83e31bf..d08477dc917 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -19,12 +19,12 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.helpers import ( - config_validation as cv, device_registry as dev_reg, entity_registry as ent_reg, service, template, ) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import EntityCategory from homeassistant.setup import async_setup_component @@ -1206,17 +1206,3 @@ async def test_async_extract_config_entry_ids(hass): ) assert await service.async_extract_config_entry_ids(hass, call) == {"abc"} - - -async def test_current_entity_context(hass, mock_entities): - """Test we set the current entity context var.""" - - async def mock_service(entity, call): - assert entity.entity_id == service.async_get_current_entity() - - await service.entity_service_call( - hass, - [Mock(entities=mock_entities)], - mock_service, - ha.ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), - ) From 2e2fa208a83c5c543313c6651b61922f177b69ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 07:32:26 -1000 Subject: [PATCH 27/90] Fix recorder system health when the db_url is lacking a hostname (#72612) --- .../recorder/system_health/__init__.py | 5 ++- .../components/recorder/test_system_health.py | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 8ba68a1649b..c4bf2c3bb89 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -2,8 +2,7 @@ from __future__ import annotations from typing import Any - -from yarl import URL +from urllib.parse import urlparse from homeassistant.components import system_health from homeassistant.components.recorder.core import Recorder @@ -60,7 +59,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: instance = get_instance(hass) run_history = instance.run_history - database_name = URL(instance.db_url).path.lstrip("/") + database_name = urlparse(instance.db_url).path.lstrip("/") db_engine_info = _async_get_db_engine_info(instance) db_stats: dict[str, Any] = {} diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index 80997b9df36..b465ee89ebe 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -53,6 +53,37 @@ async def test_recorder_system_health_alternate_dbms(hass, recorder_mock, dialec } +@pytest.mark.parametrize( + "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] +) +async def test_recorder_system_health_db_url_missing_host( + hass, recorder_mock, dialect_name +): + """Test recorder system health with a db_url without a hostname.""" + assert await async_setup_component(hass, "system_health", {}) + await async_wait_recording_done(hass) + + instance = get_instance(hass) + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", dialect_name + ), patch.object( + instance, + "db_url", + "postgresql://homeassistant:blabla@/home_assistant?host=/config/socket", + ), patch( + "sqlalchemy.orm.session.Session.execute", + return_value=Mock(first=Mock(return_value=("1048576",))), + ): + info = await get_system_health_info(hass, "recorder") + assert info == { + "current_recorder_run": instance.run_history.current.start, + "oldest_recorder_run": instance.run_history.first.start, + "estimated_db_size": "1.00 MiB", + "database_engine": dialect_name.value, + "database_version": ANY, + } + + async def test_recorder_system_health_crashed_recorder_runs_table( hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT ): From 38c085f86931e08091296138c4bc1b10a8fa7d01 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 May 2022 11:32:38 -0600 Subject: [PATCH 28/90] Bump regenmaschine to 2022.05.0 (#72613) --- 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 331f191d029..bbe58e263b1 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==2022.01.0"], + "requirements": ["regenmaschine==2022.05.0"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index e94929e8d16..6d7ca5cb6b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2065,7 +2065,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.01.0 +regenmaschine==2022.05.0 # homeassistant.components.renault renault-api==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edcd957246f..78caa172df7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,7 +1364,7 @@ rachiopy==1.0.3 radios==0.1.1 # homeassistant.components.rainmachine -regenmaschine==2022.01.0 +regenmaschine==2022.05.0 # homeassistant.components.renault renault-api==0.1.11 From 13f953f49d3f13069404ed46f921cde9e4fcc034 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 08:11:33 -1000 Subject: [PATCH 29/90] Add explict type casts for postgresql filters (#72615) --- homeassistant/components/recorder/filters.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 7f1d0bc597f..0a383d8ef2b 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable import json from typing import Any -from sqlalchemy import Column, not_, or_ +from sqlalchemy import JSON, Column, Text, cast, not_, or_ from sqlalchemy.sql.elements import ClauseList from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE @@ -110,8 +110,7 @@ class Filters: """Generate the entity filter query.""" _encoder = json.dumps return or_( - (ENTITY_ID_IN_EVENT == _encoder(None)) - & (OLD_ENTITY_ID_IN_EVENT == _encoder(None)), + (ENTITY_ID_IN_EVENT == JSON.NULL) & (OLD_ENTITY_ID_IN_EVENT == JSON.NULL), self._generate_filter_for_columns( (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder ).self_group(), @@ -123,7 +122,7 @@ def _globs_to_like( ) -> ClauseList: """Translate glob to sql.""" return or_( - column.like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) + cast(column, Text()).like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) for glob_str in glob_strs for column in columns ) @@ -133,7 +132,7 @@ def _entity_matcher( entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: return or_( - column.in_([encoder(entity_id) for entity_id in entity_ids]) + cast(column, Text()).in_([encoder(entity_id) for entity_id in entity_ids]) for column in columns ) @@ -142,5 +141,7 @@ def _domain_matcher( domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: return or_( - column.like(encoder(f"{domain}.%")) for domain in domains for column in columns + cast(column, Text()).like(encoder(f"{domain}.%")) + for domain in domains + for column in columns ) From e974a432aa629acc6093106c4e33f713e2d083a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 May 2022 11:38:00 -0700 Subject: [PATCH 30/90] Bumped version to 2022.6.0b2 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ada9a6bbd06..acad8f2675a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index 205fcd96086..3d26396deed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b1 +version = 2022.6.0b2 url = https://www.home-assistant.io/ [options] From afcc8679dd3b9782fd478abb6fe94cbf2ec892ff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 May 2022 20:23:16 -0700 Subject: [PATCH 31/90] Handle OAuth2 rejection (#72040) --- .../helpers/config_entry_oauth2_flow.py | 30 +++++++--- homeassistant/strings.json | 1 + script/scaffold/generate.py | 1 + .../helpers/test_config_entry_oauth2_flow.py | 55 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index d369b872eb9..365ced24929 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -271,9 +271,10 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): ) -> FlowResult: """Create an entry for auth.""" # Flow has been triggered by external data - if user_input: + if user_input is not None: self.external_data = user_input - return self.async_external_step_done(next_step_id="creation") + next_step = "authorize_rejected" if "error" in user_input else "creation" + return self.async_external_step_done(next_step_id=next_step) try: async with async_timeout.timeout(10): @@ -311,6 +312,13 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): {"auth_implementation": self.flow_impl.domain, "token": token} ) + async def async_step_authorize_rejected(self, data: None = None) -> FlowResult: + """Step to handle flow rejection.""" + return self.async_abort( + reason="user_rejected_authorize", + description_placeholders={"error": self.external_data["error"]}, + ) + async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow. @@ -400,10 +408,8 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Receive authorization code.""" - if "code" not in request.query or "state" not in request.query: - return web.Response( - text=f"Missing code or state parameter in {request.url}" - ) + if "state" not in request.query: + return web.Response(text="Missing state parameter") hass = request.app["hass"] @@ -412,9 +418,17 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): if state is None: return web.Response(text="Invalid state") + user_input: dict[str, Any] = {"state": state} + + if "code" in request.query: + user_input["code"] = request.query["code"] + elif "error" in request.query: + user_input["error"] = request.query["error"] + else: + return web.Response(text="Missing code or error parameter") + await hass.config_entries.flow.async_configure( - flow_id=state["flow_id"], - user_input={"state": state, "code": request.query["code"]}, + flow_id=state["flow_id"], user_input=user_input ) return web.Response( diff --git a/homeassistant/strings.json b/homeassistant/strings.json index e4d363c22be..9ae30becaee 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -74,6 +74,7 @@ "oauth2_missing_credentials": "The integration requires application credentials.", "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth2_user_rejected_authorize": "Account linking rejected: {error}", "reauth_successful": "Re-authentication was successful", "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 7f418868463..b7e4c58d1a1 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -188,6 +188,7 @@ def _custom_tasks(template, info: Info) -> None: "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 248f3b8dbb0..e5d220c55df 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -223,6 +223,61 @@ async def test_abort_if_oauth_error( assert result["reason"] == "oauth_error" +async def test_abort_if_oauth_rejected( + hass, + flow_handler, + local_impl, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, +): + """Check bad oauth token.""" + flow_handler.async_register_implementation(hass, local_impl) + config_entry_oauth2_flow.async_register_implementation( + hass, TEST_DOMAIN, MockOAuth2Implementation() + ) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pick_implementation" + + # Pick implementation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"implementation": TEST_DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=read+write" + ) + + client = await hass_client_no_auth() + resp = await client.get( + f"/auth/external/callback?error=access_denied&state={state}" + ) + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "user_rejected_authorize" + assert result["description_placeholders"] == {"error": "access_denied"} + + async def test_step_discovery(hass, flow_handler, local_impl): """Check flow triggers from discovery.""" flow_handler.async_register_implementation(hass, local_impl) From 79340f85d28befe32a6d91695f533a45e5cb7258 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 May 2022 12:51:40 -0700 Subject: [PATCH 32/90] Don't import google calendar user pref for disabling new entities (#72652) --- homeassistant/components/google/__init__.py | 29 ++++---- tests/components/google/test_init.py | 82 ++++++++------------- 2 files changed, 42 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index b7263d2e469..1336e9991e3 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -199,11 +199,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning( "Configuration of Google Calendar in YAML in configuration.yaml is " "is deprecated and will be removed in a future release; Your existing " - "OAuth Application Credentials and other settings have been imported " + "OAuth Application Credentials and access settings have been imported " "into the UI automatically and can be safely removed from your " "configuration.yaml file" ) - + if conf.get(CONF_TRACK_NEW) is False: + # The track_new as False would previously result in new entries + # in google_calendars.yaml with track set to Fasle which is + # handled at calendar entity creation time. + _LOGGER.warning( + "You must manually set the integration System Options in the " + "UI to disable newly discovered entities going forward" + ) return True @@ -260,23 +267,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def async_upgrade_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Upgrade the config entry if needed.""" - if DATA_CONFIG not in hass.data[DOMAIN] and entry.options: + if entry.options: return - - options = ( - entry.options - if entry.options - else { - CONF_CALENDAR_ACCESS: get_feature_access(hass).name, - } - ) - disable_new_entities = ( - not hass.data[DOMAIN].get(DATA_CONFIG, {}).get(CONF_TRACK_NEW, True) - ) hass.config_entries.async_update_entry( entry, - options=options, - pref_disable_new_entities=disable_new_entities, + options={ + CONF_CALENDAR_ACCESS: get_feature_access(hass).name, + }, ) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 93c0642514e..b6f7a6b4cbc 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -154,57 +154,6 @@ async def test_calendar_yaml_error( assert hass.states.get(TEST_API_ENTITY) -@pytest.mark.parametrize( - "google_config_track_new,calendars_config,expected_state", - [ - ( - None, - [], - State( - TEST_API_ENTITY, - STATE_OFF, - attributes={ - "offset_reached": False, - "friendly_name": TEST_API_ENTITY_NAME, - }, - ), - ), - ( - True, - [], - State( - TEST_API_ENTITY, - STATE_OFF, - attributes={ - "offset_reached": False, - "friendly_name": TEST_API_ENTITY_NAME, - }, - ), - ), - (False, [], None), - ], - ids=["default", "True", "False"], -) -async def test_track_new( - hass: HomeAssistant, - component_setup: ComponentSetup, - mock_calendars_list: ApiResult, - test_api_calendar: dict[str, Any], - mock_events_list: ApiResult, - mock_calendars_yaml: None, - expected_state: State, - setup_config_entry: MockConfigEntry, -) -> None: - """Test behavior of configuration.yaml settings for tracking new calendars not in the config.""" - - mock_calendars_list({"items": [test_api_calendar]}) - mock_events_list({}) - assert await component_setup() - - state = hass.states.get(TEST_API_ENTITY) - assert_state(state, expected_state) - - @pytest.mark.parametrize("calendars_config", [[]]) async def test_found_calendar_from_api( hass: HomeAssistant, @@ -263,7 +212,7 @@ async def test_load_application_credentials( @pytest.mark.parametrize( - "calendars_config_track,expected_state", + "calendars_config_track,expected_state,google_config_track_new", [ ( True, @@ -275,8 +224,35 @@ async def test_load_application_credentials( "friendly_name": TEST_YAML_ENTITY_NAME, }, ), + None, ), - (False, None), + ( + True, + State( + TEST_YAML_ENTITY, + STATE_OFF, + attributes={ + "offset_reached": False, + "friendly_name": TEST_YAML_ENTITY_NAME, + }, + ), + True, + ), + ( + True, + State( + TEST_YAML_ENTITY, + STATE_OFF, + attributes={ + "offset_reached": False, + "friendly_name": TEST_YAML_ENTITY_NAME, + }, + ), + False, # Has no effect + ), + (False, None, None), + (False, None, True), + (False, None, False), ], ) async def test_calendar_config_track_new( From 301f7647d1da2316dadc0aa0ac657c096313bc24 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 May 2022 20:28:22 -0700 Subject: [PATCH 33/90] Defer google calendar integration reload to a task to avoid races of reload during setup (#72608) --- homeassistant/components/google/__init__.py | 29 ++++------ homeassistant/components/google/api.py | 12 +++- tests/components/google/test_config_flow.py | 31 +++++----- tests/components/google/test_init.py | 64 +++++++++++++++++++++ 4 files changed, 101 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 1336e9991e3..2a40bfe7043 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -217,7 +217,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google from a config entry.""" hass.data.setdefault(DOMAIN, {}) - async_upgrade_entry(hass, entry) implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -240,10 +239,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - access = FeatureAccess[entry.options[CONF_CALENDAR_ACCESS]] - token_scopes = session.token.get("scope", []) - if access.scope not in token_scopes: - _LOGGER.debug("Scope '%s' not in scopes '%s'", access.scope, token_scopes) + if not async_entry_has_scopes(hass, entry): raise ConfigEntryAuthFailed( "Required scopes are not available, reauth required" ) @@ -254,27 +250,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_setup_services(hass, calendar_service) # Only expose the add event service if we have the correct permissions - if access is FeatureAccess.read_write: + if get_feature_access(hass, entry) is FeatureAccess.read_write: await async_setup_add_event_service(hass, calendar_service) hass.config_entries.async_setup_platforms(entry, PLATFORMS) - # Reload entry when options are updated entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -def async_upgrade_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Upgrade the config entry if needed.""" - if entry.options: - return - hass.config_entries.async_update_entry( - entry, - options={ - CONF_CALENDAR_ACCESS: get_feature_access(hass).name, - }, - ) +def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Verify that the config entry desired scope is present in the oauth token.""" + access = get_feature_access(hass, entry) + token_scopes = entry.data.get("token", {}).get("scope", []) + return access.scope in token_scopes async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -283,8 +273,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) + """Reload config entry if the access options change.""" + if not async_entry_has_scopes(hass, entry): + await hass.config_entries.async_reload(entry.entry_id) async def async_setup_services( diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index eeac854a2ae..4bb9de5d581 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -19,6 +19,7 @@ from oauth2client.client import ( ) from homeassistant.components.application_credentials import AuthImplementation +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.event import async_track_time_interval @@ -127,8 +128,17 @@ class DeviceFlow: ) -def get_feature_access(hass: HomeAssistant) -> FeatureAccess: +def get_feature_access( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> FeatureAccess: """Return the desired calendar feature access.""" + if ( + config_entry + and config_entry.options + and CONF_CALENDAR_ACCESS in config_entry.options + ): + return FeatureAccess[config_entry.options[CONF_CALENDAR_ACCESS]] + # This may be called during config entry setup without integration setup running when there # is no google entry in configuration.yaml return cast( diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index 77ebe1e56cd..8ac017fcba4 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -540,9 +540,15 @@ async def test_options_flow_triggers_reauth( ) -> None: """Test load and unload of a ConfigEntry.""" config_entry.add_to_hass(hass) - await component_setup() + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await component_setup() + mock_setup.assert_called_once() + assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.options == {"calendar_access": "read_write"} + assert config_entry.options == {} # Default is read_write result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == "form" @@ -557,14 +563,7 @@ async def test_options_flow_triggers_reauth( }, ) assert result["type"] == "create_entry" - - await hass.async_block_till_done() assert config_entry.options == {"calendar_access": "read_only"} - # Re-auth flow was initiated because access level changed - assert config_entry.state is ConfigEntryState.SETUP_ERROR - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" async def test_options_flow_no_changes( @@ -574,9 +573,15 @@ async def test_options_flow_no_changes( ) -> None: """Test load and unload of a ConfigEntry.""" config_entry.add_to_hass(hass) - await component_setup() + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await component_setup() + mock_setup.assert_called_once() + assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.options == {"calendar_access": "read_write"} + assert config_entry.options == {} # Default is read_write result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == "form" @@ -589,8 +594,4 @@ async def test_options_flow_no_changes( }, ) assert result["type"] == "create_entry" - - await hass.async_block_till_done() assert config_entry.options == {"calendar_access": "read_write"} - # Re-auth flow was initiated because access level changed - assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index b6f7a6b4cbc..f2cf067f7bb 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -102,6 +102,24 @@ async def test_existing_token_missing_scope( assert flows[0]["step_id"] == "reauth_confirm" +@pytest.mark.parametrize("config_entry_options", [{CONF_CALENDAR_ACCESS: "read_only"}]) +async def test_config_entry_scope_reauth( + hass: HomeAssistant, + token_scopes: list[str], + component_setup: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test setup where the config entry options requires reauth to match the scope.""" + config_entry.add_to_hass(hass) + assert await component_setup() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + @pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]]) async def test_calendar_yaml_missing_required_fields( hass: HomeAssistant, @@ -629,3 +647,49 @@ async def test_calendar_yaml_update( # No yaml config loaded that overwrites the entity name assert not hass.states.get(TEST_YAML_ENTITY) + + +async def test_update_will_reload( + hass: HomeAssistant, + component_setup: ComponentSetup, + setup_config_entry: Any, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + config_entry: MockConfigEntry, +) -> None: + """Test updating config entry options will trigger a reload.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == {} # read_write is default + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=None, + ) as mock_reload: + # No-op does not reload + hass.config_entries.async_update_entry( + config_entry, options={CONF_CALENDAR_ACCESS: "read_write"} + ) + await hass.async_block_till_done() + mock_reload.assert_not_called() + + # Data change does not trigger reload + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + "example": "field", + }, + ) + await hass.async_block_till_done() + mock_reload.assert_not_called() + + # Reload when options changed + hass.config_entries.async_update_entry( + config_entry, options={CONF_CALENDAR_ACCESS: "read_only"} + ) + await hass.async_block_till_done() + mock_reload.assert_called_once() From c45dc492705f92241f63b4fab941eb34cc4500b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 11:38:29 -1000 Subject: [PATCH 34/90] Escape % and _ in history/logbook entity_globs, and use ? as _ (#72623) Co-authored-by: pyos --- homeassistant/components/recorder/filters.py | 11 +- tests/components/history/test_init.py | 12 +- .../components/logbook/test_websocket_api.py | 207 ++++++++++++++++++ 3 files changed, 223 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 0a383d8ef2b..5dd1e4b7884 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -18,8 +18,11 @@ DOMAIN = "history" HISTORY_FILTERS = "history_filters" GLOB_TO_SQL_CHARS = { - 42: "%", # * - 46: "_", # . + ord("*"): "%", + ord("?"): "_", + ord("%"): "\\%", + ord("_"): "\\_", + ord("\\"): "\\\\", } @@ -122,7 +125,9 @@ def _globs_to_like( ) -> ClauseList: """Translate glob to sql.""" return or_( - cast(column, Text()).like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) + cast(column, Text()).like( + encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\" + ) for glob_str in glob_strs for column in columns ) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index a2626ab2004..cbc5e86c37e 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -719,7 +719,7 @@ async def test_fetch_period_api_with_entity_glob_exclude( { "history": { "exclude": { - "entity_globs": ["light.k*"], + "entity_globs": ["light.k*", "binary_sensor.*_?"], "domains": "switch", "entities": "media_player.test", }, @@ -731,6 +731,9 @@ async def test_fetch_period_api_with_entity_glob_exclude( hass.states.async_set("light.match", "on") hass.states.async_set("switch.match", "on") hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.sensor_l", "on") + hass.states.async_set("binary_sensor.sensor_r", "on") + hass.states.async_set("binary_sensor.sensor", "on") await async_wait_recording_done(hass) @@ -740,9 +743,10 @@ async def test_fetch_period_api_with_entity_glob_exclude( ) assert response.status == HTTPStatus.OK response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.cow" - assert response_json[1][0]["entity_id"] == "light.match" + assert len(response_json) == 3 + assert response_json[0][0]["entity_id"] == "binary_sensor.sensor" + assert response_json[1][0]["entity_id"] == "light.cow" + assert response_json[2][0]["entity_id"] == "light.match" async def test_fetch_period_api_with_entity_glob_include_and_exclude( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 02fea4f980f..9d7146ec96c 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -22,6 +22,7 @@ from homeassistant.const import ( CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, @@ -642,6 +643,212 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( assert sum(hass.bus.async_listeners().values()) == init_count +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_unsubscribe_logbook_stream_included_entities( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream with included entities.""" + test_entities = ( + "light.inc", + "switch.any", + "cover.included", + "cover.not_included", + "automation.not_included", + "binary_sensor.is_light", + ) + + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "automation", "script") + ] + ) + await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_INCLUDE: { + CONF_ENTITIES: ["light.inc"], + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: "*.included", + } + }, + }, + ) + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + hass.states.async_remove("light.zulu") + await hass.async_block_till_done() + + hass.states.async_set("light.zulu", "on", {"effect": "help", "color": "blue"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "on", "when": ANY}, + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "on", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "on", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + + for _ in range(3): + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + await async_wait_recording_done(hass) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "on", "when": ANY}, + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "on", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "on", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.included"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.excluded"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + { + ATTR_NAME: "Mock automation switch matching entity", + ATTR_ENTITY_ID: "switch.match_domain", + }, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation switch matching domain", ATTR_DOMAIN: "switch"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation matches nothing"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "light.inc"}, + ) + + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_id": ANY, + "domain": "automation", + "entity_id": "cover.included", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "switch.match_domain", + "message": "triggered", + "name": "Mock automation switch matching entity", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation switch matching domain", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation matches nothing", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "light.inc", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + ] + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( hass, recorder_mock, hass_ws_client From 3a06b5f3202d47bdc5bf2edb6bcf5a075726d4c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 27 May 2022 23:37:19 +0200 Subject: [PATCH 35/90] Bump awesomeversion from 22.5.1 to 22.5.2 (#72624) --- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- setup.cfg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f235cc3f02c..a43b4f99f63 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ async-upnp-client==0.30.1 async_timeout==4.0.2 atomicwrites==1.4.0 attrs==21.2.0 -awesomeversion==22.5.1 +awesomeversion==22.5.2 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 diff --git a/requirements.txt b/requirements.txt index 8321e70f8de..fe2bf87ad25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ astral==2.2 async_timeout==4.0.2 attrs==21.2.0 atomicwrites==1.4.0 -awesomeversion==22.5.1 +awesomeversion==22.5.2 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 diff --git a/setup.cfg b/setup.cfg index 3d26396deed..b7841c53361 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,7 +10,7 @@ install_requires = async_timeout==4.0.2 attrs==21.2.0 atomicwrites==1.4.0 - awesomeversion==22.5.1 + awesomeversion==22.5.2 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 From bd222a1fe00430508816b6a3e4304c67b8ac142f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 22:49:55 -1000 Subject: [PATCH 36/90] Prevent config entries from being reloaded concurrently (#72636) * Prevent config entries being reloaded concurrently - Fixes Config entry has already been setup when two places try to reload the config entry at the same time. - This comes up quite a bit: https://github.com/home-assistant/core/issues?q=is%3Aissue+sort%3Aupdated-desc+%22Config+entry+has+already+been+setup%22+is%3Aclosed * Make sure plex creates mocks in the event loop * drop reload_lock, already inherits --- homeassistant/config_entries.py | 13 ++++++---- tests/components/plex/conftest.py | 2 +- tests/test_config_entries.py | 40 ++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7dfbb131c1b..0ac02adb8d0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -186,6 +186,7 @@ class ConfigEntry: "reason", "_async_cancel_retry_setup", "_on_unload", + "reload_lock", ) def __init__( @@ -275,6 +276,9 @@ class ConfigEntry: # Hold list for functions to call on unload. self._on_unload: list[CALLBACK_TYPE] | None = None + # Reload lock to prevent conflicting reloads + self.reload_lock = asyncio.Lock() + async def async_setup( self, hass: HomeAssistant, @@ -1005,12 +1009,13 @@ class ConfigEntries: if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry - unload_result = await self.async_unload(entry_id) + async with entry.reload_lock: + unload_result = await self.async_unload(entry_id) - if not unload_result or entry.disabled_by: - return unload_result + if not unload_result or entry.disabled_by: + return unload_result - return await self.async_setup(entry_id) + return await self.async_setup(entry_id) async def async_set_disabled_by( self, entry_id: str, disabled_by: ConfigEntryDisabler | None diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 47e7d96d2fe..506aadcce61 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -381,7 +381,7 @@ def hubs_music_library_fixture(): @pytest.fixture(name="entry") -def mock_config_entry(): +async def mock_config_entry(): """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3611c204ba7..2602887d1d5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1497,7 +1497,7 @@ async def test_reload_entry_entity_registry_works(hass): ) await hass.async_block_till_done() - assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_unload_entry.mock_calls) == 2 async def test_unique_id_persisted(hass, manager): @@ -3080,3 +3080,41 @@ async def test_deprecated_disabled_by_str_set(hass, manager, caplog): ) assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER assert " str for config entry disabled_by. This is deprecated " in caplog.text + + +async def test_entry_reload_concurrency(hass, manager): + """Test multiple reload calls do not cause a reload race.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + loaded = 1 + + async def _async_setup_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded += 1 + return loaded == 1 + + async def _async_unload_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded -= 1 + return loaded == 0 + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=_async_setup_entry, + async_unload_entry=_async_unload_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + tasks = [] + for _ in range(15): + tasks.append(asyncio.create_task(manager.async_reload(entry.entry_id))) + await asyncio.gather(*tasks) + assert entry.state is config_entries.ConfigEntryState.LOADED + assert loaded == 1 From 50eaf2f47599cae0b5c8b7e26bd4c3494d841f1c Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 28 May 2022 19:55:50 +0200 Subject: [PATCH 37/90] Bump bimmer_connected to 0.9.2 (#72653) Co-authored-by: rikroe --- 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 d41a87ef2c1..c7130d12698 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.0"], + "requirements": ["bimmer_connected==0.9.2"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 6d7ca5cb6b2..f0cc4f6fb67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -394,7 +394,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.0 +bimmer_connected==0.9.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78caa172df7..783542a1c69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.0 +bimmer_connected==0.9.2 # homeassistant.components.blebox blebox_uniapi==1.3.3 From b360f0280b8b9c6f7057b11fbda457731472c7c4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 28 May 2022 22:31:03 +0200 Subject: [PATCH 38/90] Manage stations via integrations configuration in Tankerkoenig (#72654) --- .../components/tankerkoenig/config_flow.py | 64 +++++++++++++------ .../components/tankerkoenig/strings.json | 2 +- .../tankerkoenig/translations/en.json | 4 +- .../tankerkoenig/test_config_flow.py | 23 +++++-- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 65c367d1ba4..af3b5273b16 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, LENGTH_KILOMETERS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( @@ -29,6 +29,24 @@ from homeassistant.helpers.selector import ( from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES +async def async_get_nearby_stations( + hass: HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: + """Fetch nearby stations.""" + try: + return await hass.async_add_executor_job( + getNearbyStations, + data[CONF_API_KEY], + data[CONF_LOCATION][CONF_LATITUDE], + data[CONF_LOCATION][CONF_LONGITUDE], + data[CONF_RADIUS], + "all", + "dist", + ) + except customException as err: + return {"ok": False, "message": err, "exception": True} + + class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -57,7 +75,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): selected_station_ids: list[str] = [] # add all nearby stations - nearby_stations = await self._get_nearby_stations(config) + nearby_stations = await async_get_nearby_stations(self.hass, config) for station in nearby_stations.get("stations", []): selected_station_ids.append(station["id"]) @@ -91,7 +109,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - data = await self._get_nearby_stations(user_input) + data = await async_get_nearby_stations(self.hass, user_input) if not data.get("ok"): return self._show_form_user( user_input, errors={CONF_API_KEY: "invalid_auth"} @@ -182,21 +200,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): options=options, ) - async def _get_nearby_stations(self, data: dict[str, Any]) -> dict[str, Any]: - """Fetch nearby stations.""" - try: - return await self.hass.async_add_executor_job( - getNearbyStations, - data[CONF_API_KEY], - data[CONF_LOCATION][CONF_LATITUDE], - data[CONF_LOCATION][CONF_LONGITUDE], - data[CONF_RADIUS], - "all", - "dist", - ) - except customException as err: - return {"ok": False, "message": err, "exception": True} - class OptionsFlowHandler(config_entries.OptionsFlow): """Handle an options flow.""" @@ -204,14 +207,36 @@ class OptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry + self._stations: dict[str, str] = {} async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle options flow.""" if user_input is not None: + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_STATIONS: user_input.pop(CONF_STATIONS), + }, + ) return self.async_create_entry(title="", data=user_input) + nearby_stations = await async_get_nearby_stations( + self.hass, dict(self.config_entry.data) + ) + if stations := nearby_stations.get("stations"): + for station in stations: + self._stations[ + station["id"] + ] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)" + + # add possible extra selected stations from import + for selected_station in self.config_entry.data[CONF_STATIONS]: + if selected_station not in self._stations: + self._stations[selected_station] = f"id: {selected_station}" + return self.async_show_form( step_id="init", data_schema=vol.Schema( @@ -220,6 +245,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_SHOW_ON_MAP, default=self.config_entry.options[CONF_SHOW_ON_MAP], ): bool, + vol.Required( + CONF_STATIONS, default=self.config_entry.data[CONF_STATIONS] + ): cv.multi_select(self._stations), } ), ) diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 7c1ba54fcc0..5e0c367c192 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -32,7 +32,7 @@ "init": { "title": "Tankerkoenig options", "data": { - "scan_interval": "Update Interval", + "stations": "Stations", "show_on_map": "Show stations on map" } } diff --git a/homeassistant/components/tankerkoenig/translations/en.json b/homeassistant/components/tankerkoenig/translations/en.json index 399788de8f4..83cc36fd4c8 100644 --- a/homeassistant/components/tankerkoenig/translations/en.json +++ b/homeassistant/components/tankerkoenig/translations/en.json @@ -31,8 +31,8 @@ "step": { "init": { "data": { - "scan_interval": "Update Interval", - "show_on_map": "Show stations on map" + "show_on_map": "Show stations on map", + "stations": "Stations" }, "title": "Tankerkoenig options" } diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index 0a90b424b73..b18df0eed24 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -42,6 +42,15 @@ MOCK_STATIONS_DATA = { ], } +MOCK_OPTIONS_DATA = { + **MOCK_USER_DATA, + CONF_STATIONS: [ + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "36b4b812-xxxx-xxxx-xxxx-c51735325858", + "54e2b642-xxxx-xxxx-xxxx-87cd4e9867f1", + ], +} + MOCK_IMPORT_DATA = { CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", CONF_FUEL_TYPES: ["e5"], @@ -217,7 +226,7 @@ async def test_options_flow(hass: HomeAssistant): mock_config = MockConfigEntry( domain=DOMAIN, - data=MOCK_USER_DATA, + data=MOCK_OPTIONS_DATA, options={CONF_SHOW_ON_MAP: True}, unique_id=f"{DOMAIN}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", ) @@ -225,17 +234,23 @@ async def test_options_flow(hass: HomeAssistant): with patch( "homeassistant.components.tankerkoenig.async_setup_entry" - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", + return_value=MOCK_NEARVY_STATIONS_OK, + ): await mock_config.async_setup(hass) await hass.async_block_till_done() assert mock_setup_entry.called - result = await hass.config_entries.options.async_init(mock_config.entry_id) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SHOW_ON_MAP: False}, + user_input={ + CONF_SHOW_ON_MAP: False, + CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], + }, ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert not mock_config.options[CONF_SHOW_ON_MAP] From da62e2cc23207faec24f2f4cdc44baa66f4390e9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 May 2022 20:46:51 -0700 Subject: [PATCH 39/90] Bumped version to 2022.6.0b3 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index acad8f2675a..8d2bcd33f4e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index b7841c53361..db64c7330ce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b2 +version = 2022.6.0b3 url = https://www.home-assistant.io/ [options] From f33517ef2c0635e601ebc3b1e484ef647211f71b Mon Sep 17 00:00:00 2001 From: Shawn Saenger Date: Sun, 29 May 2022 10:33:33 -0600 Subject: [PATCH 40/90] Incorporate various improvements for the ws66i integration (#71717) * Improve readability and remove unused code * Remove ws66i custom services. Scenes can be used instead. * Unmute WS66i Zone when volume changes * Raise CannotConnect instead of ConnectionError in validation method * Move _verify_connection() method to module level --- homeassistant/components/ws66i/__init__.py | 5 +- homeassistant/components/ws66i/config_flow.py | 48 +- homeassistant/components/ws66i/const.py | 6 +- homeassistant/components/ws66i/coordinator.py | 11 +- .../components/ws66i/media_player.py | 67 +-- homeassistant/components/ws66i/models.py | 2 - homeassistant/components/ws66i/services.yaml | 15 - homeassistant/components/ws66i/strings.json | 3 - .../components/ws66i/translations/en.json | 3 - tests/components/ws66i/test_config_flow.py | 6 +- tests/components/ws66i/test_init.py | 80 ++++ tests/components/ws66i/test_media_player.py | 414 +++++------------- 12 files changed, 251 insertions(+), 409 deletions(-) delete mode 100644 homeassistant/components/ws66i/services.yaml create mode 100644 tests/components/ws66i/test_init.py diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 232c4390f19..dea1b470b9e 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -94,8 +94,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: zones=zones, ) + @callback def shutdown(event): - """Close the WS66i connection to the amplifier and save snapshots.""" + """Close the WS66i connection to the amplifier.""" ws66i.close() entry.async_on_unload(entry.add_update_listener(_update_listener)) @@ -119,6 +120,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index a8f098faadd..b84872da036 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -1,5 +1,6 @@ """Config flow for WS66i 6-Zone Amplifier integration.""" import logging +from typing import Any from pyws66i import WS66i, get_ws66i import voluptuous as vol @@ -50,22 +51,34 @@ def _sources_from_config(data): } -async def validate_input(hass: core.HomeAssistant, input_data): - """Validate the user input allows us to connect. +def _verify_connection(ws66i: WS66i) -> bool: + """Verify a connection can be made to the WS66i.""" + try: + ws66i.open() + except ConnectionError as err: + raise CannotConnect from err + + # Connection successful. Verify correct port was opened + # Test on FIRST_ZONE because this zone will always be valid + ret_val = ws66i.zone_status(FIRST_ZONE) + + ws66i.close() + + return bool(ret_val) + + +async def validate_input( + hass: core.HomeAssistant, input_data: dict[str, Any] +) -> dict[str, Any]: + """Validate the user input. Data has the keys from DATA_SCHEMA with values provided by the user. """ ws66i: WS66i = get_ws66i(input_data[CONF_IP_ADDRESS]) - await hass.async_add_executor_job(ws66i.open) - # No exception. run a simple test to make sure we opened correct port - # Test on FIRST_ZONE because this zone will always be valid - ret_val = await hass.async_add_executor_job(ws66i.zone_status, FIRST_ZONE) - if ret_val is None: - ws66i.close() - raise ConnectionError("Not a valid WS66i connection") - # Validation done. No issues. Close the connection - ws66i.close() + is_valid: bool = await hass.async_add_executor_job(_verify_connection, ws66i) + if not is_valid: + raise CannotConnect("Not a valid WS66i connection") # Return info that you want to store in the config entry. return {CONF_IP_ADDRESS: input_data[CONF_IP_ADDRESS]} @@ -82,17 +95,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: info = await validate_input(self.hass, user_input) - # Data is valid. Add default values for options flow. + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Data is valid. Create a config entry. return self.async_create_entry( title="WS66i Amp", data=info, options={CONF_SOURCES: INIT_OPTIONS_DEFAULT}, ) - except ConnectionError: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/ws66i/const.py b/homeassistant/components/ws66i/const.py index ec4439a690d..f824d991c1d 100644 --- a/homeassistant/components/ws66i/const.py +++ b/homeassistant/components/ws66i/const.py @@ -1,4 +1,5 @@ """Constants for the Soundavo WS66i 6-Zone Amplifier Media Player component.""" +from datetime import timedelta DOMAIN = "ws66i" @@ -20,5 +21,6 @@ INIT_OPTIONS_DEFAULT = { "6": "Source 6", } -SERVICE_SNAPSHOT = "snapshot" -SERVICE_RESTORE = "restore" +POLL_INTERVAL = timedelta(seconds=30) + +MAX_VOL = 38 diff --git a/homeassistant/components/ws66i/coordinator.py b/homeassistant/components/ws66i/coordinator.py index a9a274756b5..be8ae3aad38 100644 --- a/homeassistant/components/ws66i/coordinator.py +++ b/homeassistant/components/ws66i/coordinator.py @@ -1,7 +1,6 @@ """Coordinator for WS66i.""" from __future__ import annotations -from datetime import timedelta import logging from pyws66i import WS66i, ZoneStatus @@ -9,12 +8,12 @@ from pyws66i import WS66i, ZoneStatus from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import POLL_INTERVAL + _LOGGER = logging.getLogger(__name__) -POLL_INTERVAL = timedelta(seconds=30) - -class Ws66iDataUpdateCoordinator(DataUpdateCoordinator): +class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): """DataUpdateCoordinator to gather data for WS66i Zones.""" def __init__( @@ -43,11 +42,9 @@ class Ws66iDataUpdateCoordinator(DataUpdateCoordinator): data.append(data_zone) - # HA will call my entity's _handle_coordinator_update() return data async def _async_update_data(self) -> list[ZoneStatus]: """Fetch data for each of the zones.""" - # HA will call my entity's _handle_coordinator_update() - # The data I pass back here can be accessed through coordinator.data. + # The data that is returned here can be accessed through coordinator.data. return await self.hass.async_add_executor_job(self._update_all_zones) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index c0e62fe773c..7cd897e9c1a 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -1,6 +1,4 @@ """Support for interfacing with WS66i 6 zone home audio controller.""" -from copy import deepcopy - from pyws66i import WS66i, ZoneStatus from homeassistant.components.media_player import ( @@ -10,22 +8,16 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT +from .const import DOMAIN, MAX_VOL from .coordinator import Ws66iDataUpdateCoordinator from .models import Ws66iData PARALLEL_UPDATES = 1 -MAX_VOL = 38 - async def async_setup_entry( hass: HomeAssistant, @@ -48,23 +40,8 @@ async def async_setup_entry( for idx, zone_id in enumerate(ws66i_data.zones) ) - # Set up services - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SNAPSHOT, - {}, - "snapshot", - ) - - platform.async_register_entity_service( - SERVICE_RESTORE, - {}, - "async_restore", - ) - - -class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): +class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity): """Representation of a WS66i amplifier zone.""" def __init__( @@ -82,8 +59,6 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): self._ws66i_data: Ws66iData = ws66i_data self._zone_id: int = zone_id self._zone_id_idx: int = data_idx - self._coordinator = coordinator - self._snapshot: ZoneStatus = None 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}" @@ -131,20 +106,6 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): self._set_attrs_from_status() self.async_write_ha_state() - @callback - def snapshot(self): - """Save zone's current state.""" - self._snapshot = deepcopy(self._status) - - async def async_restore(self): - """Restore saved state.""" - if not self._snapshot: - raise HomeAssistantError("There is no snapshot to restore") - - await self.hass.async_add_executor_job(self._ws66i.restore_zone, self._snapshot) - self._status = self._snapshot - self._async_update_attrs_write_ha_state() - async def async_select_source(self, source): """Set input source.""" idx = self._ws66i_data.sources.name_id[source] @@ -180,24 +141,30 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): async def async_set_volume_level(self, volume): """Set volume level, range 0..1.""" - await self.hass.async_add_executor_job( - self._ws66i.set_volume, self._zone_id, int(volume * MAX_VOL) - ) - self._status.volume = int(volume * MAX_VOL) + await self.hass.async_add_executor_job(self._set_volume, int(volume * MAX_VOL)) self._async_update_attrs_write_ha_state() async def async_volume_up(self): """Volume up the media player.""" await self.hass.async_add_executor_job( - self._ws66i.set_volume, self._zone_id, min(self._status.volume + 1, MAX_VOL) + self._set_volume, min(self._status.volume + 1, MAX_VOL) ) - self._status.volume = min(self._status.volume + 1, MAX_VOL) self._async_update_attrs_write_ha_state() async def async_volume_down(self): """Volume down media player.""" await self.hass.async_add_executor_job( - self._ws66i.set_volume, self._zone_id, max(self._status.volume - 1, 0) + self._set_volume, max(self._status.volume - 1, 0) ) - self._status.volume = max(self._status.volume - 1, 0) self._async_update_attrs_write_ha_state() + + def _set_volume(self, volume: int) -> None: + """Set the volume of the media player.""" + # Can't set a new volume level when this zone is muted. + # Follow behavior of keypads, where zone is unmuted when volume changes. + if self._status.mute: + self._ws66i.set_mute(self._zone_id, False) + self._status.mute = False + + self._ws66i.set_volume(self._zone_id, volume) + self._status.volume = volume diff --git a/homeassistant/components/ws66i/models.py b/homeassistant/components/ws66i/models.py index d84ee56a4a1..84f481b9a4a 100644 --- a/homeassistant/components/ws66i/models.py +++ b/homeassistant/components/ws66i/models.py @@ -7,8 +7,6 @@ from pyws66i import WS66i from .coordinator import Ws66iDataUpdateCoordinator -# A dataclass is basically a struct in C/C++ - @dataclass class SourceRep: diff --git a/homeassistant/components/ws66i/services.yaml b/homeassistant/components/ws66i/services.yaml deleted file mode 100644 index cedd1d3546a..00000000000 --- a/homeassistant/components/ws66i/services.yaml +++ /dev/null @@ -1,15 +0,0 @@ -snapshot: - name: Snapshot - description: Take a snapshot of the media player zone. - target: - entity: - integration: ws66i - domain: media_player - -restore: - name: Restore - description: Restore a snapshot of the media player zone. - target: - entity: - integration: ws66i - domain: media_player diff --git a/homeassistant/components/ws66i/strings.json b/homeassistant/components/ws66i/strings.json index fcfa64d7e22..ec5bc621a89 100644 --- a/homeassistant/components/ws66i/strings.json +++ b/homeassistant/components/ws66i/strings.json @@ -11,9 +11,6 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "options": { diff --git a/homeassistant/components/ws66i/translations/en.json b/homeassistant/components/ws66i/translations/en.json index 30ef1e4205a..fd4b170b378 100644 --- a/homeassistant/components/ws66i/translations/en.json +++ b/homeassistant/components/ws66i/translations/en.json @@ -1,8 +1,5 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" - }, "error": { "cannot_connect": "Failed to connect", "unknown": "Unexpected error" diff --git a/tests/components/ws66i/test_config_flow.py b/tests/components/ws66i/test_config_flow.py index d426e62c012..4fe3554941d 100644 --- a/tests/components/ws66i/test_config_flow.py +++ b/tests/components/ws66i/test_config_flow.py @@ -1,7 +1,7 @@ """Test the WS66i 6-Zone Amplifier config flow.""" from unittest.mock import patch -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import config_entries, data_entry_flow from homeassistant.components.ws66i.const import ( CONF_SOURCE_1, CONF_SOURCE_2, @@ -15,15 +15,15 @@ from homeassistant.components.ws66i.const import ( ) from homeassistant.const import CONF_IP_ADDRESS +from .test_media_player import AttrDict + from tests.common import MockConfigEntry -from tests.components.ws66i.test_media_player import AttrDict CONFIG = {CONF_IP_ADDRESS: "1.1.1.1"} async def test_form(hass): """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/ws66i/test_init.py b/tests/components/ws66i/test_init.py new file mode 100644 index 00000000000..557c53e97aa --- /dev/null +++ b/tests/components/ws66i/test_init.py @@ -0,0 +1,80 @@ +"""Test the WS66i 6-Zone Amplifier init file.""" +from unittest.mock import patch + +from homeassistant.components.ws66i.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState + +from .test_media_player import ( + MOCK_CONFIG, + MOCK_DEFAULT_OPTIONS, + MOCK_OPTIONS, + MockWs66i, +) + +from tests.common import MockConfigEntry + +ZONE_1_ID = "media_player.zone_11" + + +async def test_cannot_connect(hass): + """Test connection error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ws66i.get_ws66i", + new=lambda *a: MockWs66i(fail_open=True), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert hass.states.get(ZONE_1_ID) is None + + +async def test_cannot_connect_2(hass): + """Test connection error pt 2.""" + # Another way to test same case as test_cannot_connect + ws66i = MockWs66i() + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_DEFAULT_OPTIONS + ) + config_entry.add_to_hass(hass) + + with patch.object(MockWs66i, "open", side_effect=ConnectionError): + with patch( + "homeassistant.components.ws66i.get_ws66i", + new=lambda *a: ws66i, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert hass.states.get(ZONE_1_ID) is None + + +async def test_unload_config_entry(hass): + """Test unloading config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ws66i.get_ws66i", + new=lambda *a: MockWs66i(), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN][config_entry.entry_id] + + with patch.object(MockWs66i, "close") as method_call: + await config_entry.async_unload(hass) + await hass.async_block_till_done() + + assert method_call.called + + assert not hass.data[DOMAIN] diff --git a/tests/components/ws66i/test_media_player.py b/tests/components/ws66i/test_media_player.py index 6fc1e00d827..fbe6a7b2782 100644 --- a/tests/components/ws66i/test_media_player.py +++ b/tests/components/ws66i/test_media_player.py @@ -2,28 +2,22 @@ from collections import defaultdict from unittest.mock import patch -import pytest - +from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, - SUPPORT_SELECT_SOURCE, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, - SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, ) from homeassistant.components.ws66i.const import ( CONF_SOURCES, DOMAIN, INIT_OPTIONS_DEFAULT, - SERVICE_RESTORE, - SERVICE_SNAPSHOT, + MAX_VOL, + POLL_INTERVAL, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_IP_ADDRESS, SERVICE_TURN_OFF, @@ -35,10 +29,10 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed MOCK_SOURCE_DIC = { "1": "one", @@ -125,47 +119,52 @@ class MockWs66i: async def test_setup_success(hass): """Test connection success.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + with patch( "homeassistant.components.ws66i.get_ws66i", new=lambda *a: MockWs66i(), ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(ZONE_1_ID) is not None + + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.get(ZONE_1_ID) is not None async def _setup_ws66i(hass, ws66i) -> MockConfigEntry: + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_DEFAULT_OPTIONS + ) + config_entry.add_to_hass(hass) + with patch( "homeassistant.components.ws66i.get_ws66i", new=lambda *a: ws66i, ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_DEFAULT_OPTIONS - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return config_entry + return config_entry async def _setup_ws66i_with_options(hass, ws66i) -> MockConfigEntry: + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + with patch( "homeassistant.components.ws66i.get_ws66i", new=lambda *a: ws66i, ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return config_entry + return config_entry async def _call_media_player_service(hass, name, data): @@ -174,172 +173,10 @@ async def _call_media_player_service(hass, name, data): ) -async def _call_ws66i_service(hass, name, data): - await hass.services.async_call(DOMAIN, name, service_data=data, blocking=True) - - -async def test_cannot_connect(hass): - """Test connection error.""" - with patch( - "homeassistant.components.ws66i.get_ws66i", - new=lambda *a: MockWs66i(fail_open=True), - ): - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get(ZONE_1_ID) is None - - -async def test_cannot_connect_2(hass): - """Test connection error pt 2.""" - # Another way to test same case as test_cannot_connect - ws66i = MockWs66i() - - with patch.object(MockWs66i, "open", side_effect=ConnectionError): - await _setup_ws66i(hass, ws66i) - assert hass.states.get(ZONE_1_ID) is None - - -async def test_service_calls_with_entity_id(hass): - """Test snapshot save/restore service calls.""" - _ = await _setup_ws66i_with_options(hass, MockWs66i()) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - # Saving existing values - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": ZONE_1_ID}) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"} - ) - await hass.async_block_till_done() - - # Restoring other media player to its previous state - # The zone should not be restored - with pytest.raises(HomeAssistantError): - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_2_ID}) - await hass.async_block_till_done() - - # Checking that values were not (!) restored - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 1.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "three" - - # Restoring media player to its previous state - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" - - -async def test_service_calls_with_all_entities(hass): - """Test snapshot save/restore service calls with entity id all.""" - _ = await _setup_ws66i_with_options(hass, MockWs66i()) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - # Saving existing values - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": "all"}) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"} - ) - - # await coordinator.async_refresh() - # await hass.async_block_till_done() - - # Restoring media player to its previous state - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": "all"}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" - - -async def test_service_calls_without_relevant_entities(hass): - """Test snapshot save/restore service calls with bad entity id.""" - config_entry = await _setup_ws66i_with_options(hass, MockWs66i()) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - await coordinator.async_refresh() - await hass.async_block_till_done() - - # Saving existing values - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": "all"}) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"} - ) - - await coordinator.async_refresh() - await hass.async_block_till_done() - - # Restoring media player to its previous state - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": "light.demo"}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 1.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "three" - - -async def test_restore_without_snapshot(hass): - """Test restore when snapshot wasn't called.""" - await _setup_ws66i(hass, MockWs66i()) - - with patch.object(MockWs66i, "restore_zone") as method_call: - with pytest.raises(HomeAssistantError): - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() - - assert not method_call.called - - async def test_update(hass): """Test updating values from ws66i.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) + _ = await _setup_ws66i_with_options(hass, ws66i) # Changing media player to new state await _call_media_player_service( @@ -350,13 +187,10 @@ async def test_update(hass): ) ws66i.set_source(11, 3) - ws66i.set_volume(11, 38) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator + ws66i.set_volume(11, MAX_VOL) with patch.object(MockWs66i, "open") as method_call: - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() assert not method_call.called @@ -371,7 +205,7 @@ async def test_update(hass): async def test_failed_update(hass): """Test updating failure from ws66i.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) + _ = await _setup_ws66i_with_options(hass, ws66i) # Changing media player to new state await _call_media_player_service( @@ -382,26 +216,25 @@ async def test_failed_update(hass): ) ws66i.set_source(11, 3) - ws66i.set_volume(11, 38) - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - await coordinator.async_refresh() + ws66i.set_volume(11, MAX_VOL) + + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # Failed update, close called with patch.object(MockWs66i, "zone_status", return_value=None): - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() assert hass.states.is_state(ZONE_1_ID, STATE_UNAVAILABLE) # A connection re-attempt fails with patch.object(MockWs66i, "zone_status", return_value=None): - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # A connection re-attempt succeeds - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # confirm entity is back on @@ -418,12 +251,12 @@ async def test_supported_features(hass): state = hass.states.get(ZONE_1_ID) assert ( - SUPPORT_VOLUME_MUTE - | SUPPORT_VOLUME_SET - | SUPPORT_VOLUME_STEP - | SUPPORT_TURN_ON - | SUPPORT_TURN_OFF - | SUPPORT_SELECT_SOURCE + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE == state.attributes["supported_features"] ) @@ -462,15 +295,13 @@ async def test_select_source(hass): async def test_source_select(hass): - """Test behavior when device has unknown source.""" + """Test source selection simulated from keypad.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) + _ = await _setup_ws66i_with_options(hass, ws66i) ws66i.set_source(11, 5) - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() state = hass.states.get(ZONE_1_ID) @@ -512,10 +343,7 @@ async def test_mute_volume(hass): async def test_volume_up_down(hass): """Test increasing volume by one.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i(hass, ws66i) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator + _ = await _setup_ws66i(hass, ws66i) await _call_media_player_service( hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} @@ -525,34 +353,89 @@ async def test_volume_up_down(hass): await _call_media_player_service( hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} ) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # should not go below zero assert ws66i.zones[11].volume == 0 await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() assert ws66i.zones[11].volume == 1 await _call_media_player_service( hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} ) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() - assert ws66i.zones[11].volume == 38 + assert ws66i.zones[11].volume == MAX_VOL await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() - # should not go above 38 - assert ws66i.zones[11].volume == 38 + # should not go above 38 (MAX_VOL) + assert ws66i.zones[11].volume == MAX_VOL await _call_media_player_service( hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} ) - assert ws66i.zones[11].volume == 37 + assert ws66i.zones[11].volume == MAX_VOL - 1 + + +async def test_volume_while_mute(hass): + """Test increasing volume by one.""" + ws66i = MockWs66i() + _ = await _setup_ws66i(hass, ws66i) + + # Set vol to a known value + await _call_media_player_service( + hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} + ) + assert ws66i.zones[11].volume == 0 + + # Set mute to a known value, False + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": False} + ) + assert not ws66i.zones[11].mute + + # Mute the zone + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": True} + ) + assert ws66i.zones[11].mute + + # Increase volume. Mute state should go back to unmutted + await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) + assert ws66i.zones[11].volume == 1 + assert not ws66i.zones[11].mute + + # Mute the zone again + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": True} + ) + assert ws66i.zones[11].mute + + # Decrease volume. Mute state should go back to unmutted + await _call_media_player_service( + hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} + ) + assert ws66i.zones[11].volume == 0 + assert not ws66i.zones[11].mute + + # Mute the zone again + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": True} + ) + assert ws66i.zones[11].mute + + # Set to max volume. Mute state should go back to unmutted + await _call_media_player_service( + hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} + ) + assert ws66i.zones[11].volume == MAX_VOL + assert not ws66i.zones[11].mute async def test_first_run_with_available_zones(hass): @@ -611,82 +494,3 @@ async def test_register_entities_in_1_amp_only(hass): entry = registry.async_get(ZONE_7_ID) assert entry is None - - -async def test_unload_config_entry(hass): - """Test unloading config entry.""" - with patch( - "homeassistant.components.ws66i.get_ws66i", - new=lambda *a: MockWs66i(), - ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS - ) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert hass.data[DOMAIN][config_entry.entry_id] - - with patch.object(MockWs66i, "close") as method_call: - await config_entry.async_unload(hass) - await hass.async_block_till_done() - - assert method_call.called - - assert not hass.data[DOMAIN] - - -async def test_restore_snapshot_on_reconnect(hass): - """Test restoring a saved snapshot when reconnecting to amp.""" - ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - # Save a snapshot - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": ZONE_1_ID}) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - - # Failed update, - with patch.object(MockWs66i, "zone_status", return_value=None): - await coordinator.async_refresh() - await hass.async_block_till_done() - - assert hass.states.is_state(ZONE_1_ID, STATE_UNAVAILABLE) - - # A connection re-attempt succeeds - await coordinator.async_refresh() - await hass.async_block_till_done() - - # confirm entity is back on - state = hass.states.get(ZONE_1_ID) - - assert hass.states.is_state(ZONE_1_ID, STATE_ON) - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" - - # Change states - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "six"} - ) - - # Now confirm that the snapshot before the disconnect works - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" From 6bf6a0f7bc292d175807d31b6d80e9b55bb1a76a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 May 2022 13:57:00 -0700 Subject: [PATCH 41/90] Convert media player enqueue to an enum (#72406) --- .../components/bluesound/media_player.py | 14 +------ homeassistant/components/heos/media_player.py | 16 +++++--- .../components/media_player/__init__.py | 37 ++++++++++++++++- .../media_player/reproduce_state.py | 3 +- .../components/media_player/services.yaml | 16 ++++++++ .../components/sonos/media_player.py | 9 ++--- .../components/squeezebox/media_player.py | 16 ++++---- tests/components/media_player/test_init.py | 40 +++++++++++++++++++ .../media_player/test_reproduce_state.py | 4 -- 9 files changed, 119 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index e22606b795f..7f1c6b6553f 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -24,10 +24,7 @@ from homeassistant.components.media_player import ( from homeassistant.components.media_player.browse_media import ( async_process_play_media_url, ) -from homeassistant.components.media_player.const import ( - ATTR_MEDIA_ENQUEUE, - MEDIA_TYPE_MUSIC, -) +from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, @@ -1023,11 +1020,7 @@ class BluesoundPlayer(MediaPlayerEntity): return await self.send_bluesound_command(f"Play?seek={float(position)}") async def async_play_media(self, media_type, media_id, **kwargs): - """ - Send the play_media command to the media player. - - If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. - """ + """Send the play_media command to the media player.""" if self.is_grouped and not self.is_master: return @@ -1041,9 +1034,6 @@ class BluesoundPlayer(MediaPlayerEntity): url = f"Play?url={media_id}" - if kwargs.get(ATTR_MEDIA_ENQUEUE): - return await self.send_bluesound_command(url) - return await self.send_bluesound_command(url) async def async_volume_up(self): diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 4cfbe5fe408..ad9225d9b21 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -12,6 +12,7 @@ from typing_extensions import ParamSpec from homeassistant.components import media_source from homeassistant.components.media_player import ( + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, ) @@ -73,6 +74,14 @@ CONTROL_TO_SUPPORT = { heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, } +HA_HEOS_ENQUEUE_MAP = { + None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, + MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END, + MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, + MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT, + MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW, +} + _LOGGER = logging.getLogger(__name__) @@ -224,11 +233,8 @@ class HeosMediaPlayer(MediaPlayerEntity): playlist = next((p for p in playlists if p.name == media_id), None) if not playlist: raise ValueError(f"Invalid playlist '{media_id}'") - add_queue_option = ( - heos_const.ADD_QUEUE_ADD_TO_END - if kwargs.get(ATTR_MEDIA_ENQUEUE) - else heos_const.ADD_QUEUE_REPLACE_AND_PLAY - ) + add_queue_option = HA_HEOS_ENQUEUE_MAP.get(kwargs.get(ATTR_MEDIA_ENQUEUE)) + await self._player.add_to_queue(playlist, add_queue_option) return diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index bf006e2bd4e..f71f3fc2a1f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -147,6 +147,19 @@ ENTITY_IMAGE_CACHE = {CACHE_IMAGES: collections.OrderedDict(), CACHE_MAXSIZE: 16 SCAN_INTERVAL = dt.timedelta(seconds=10) +class MediaPlayerEnqueue(StrEnum): + """Enqueue types for playing media.""" + + # add given media item to end of the queue + ADD = "add" + # play the given media item next, keep queue + NEXT = "next" + # play the given media item now, keep queue + PLAY = "play" + # play the given media item now, clear queue + REPLACE = "replace" + + class MediaPlayerDeviceClass(StrEnum): """Device class for media players.""" @@ -169,7 +182,9 @@ DEVICE_CLASS_RECEIVER = MediaPlayerDeviceClass.RECEIVER.value MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, - vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean, + vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Any( + cv.boolean, vol.Coerce(MediaPlayerEnqueue) + ), vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict, } @@ -350,10 +365,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_select_sound_mode", [MediaPlayerEntityFeature.SELECT_SOUND_MODE], ) + + # Remove in Home Assistant 2022.9 + def _rewrite_enqueue(value): + """Rewrite the enqueue value.""" + if ATTR_MEDIA_ENQUEUE not in value: + pass + elif value[ATTR_MEDIA_ENQUEUE] is True: + value[ATTR_MEDIA_ENQUEUE] = MediaPlayerEnqueue.ADD + _LOGGER.warning( + "Playing media with enqueue set to True is deprecated. Use 'add' instead" + ) + elif value[ATTR_MEDIA_ENQUEUE] is False: + value[ATTR_MEDIA_ENQUEUE] = MediaPlayerEnqueue.PLAY + _LOGGER.warning( + "Playing media with enqueue set to False is deprecated. Use 'play' instead" + ) + + return value + component.async_register_entity_service( SERVICE_PLAY_MEDIA, vol.All( cv.make_entity_service_schema(MEDIA_PLAYER_PLAY_MEDIA_SCHEMA), + _rewrite_enqueue, _rename_keys( media_type=ATTR_MEDIA_CONTENT_TYPE, media_id=ATTR_MEDIA_CONTENT_ID, diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index 586ac61b4e1..bdfc0bf3acb 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -27,7 +27,6 @@ from .const import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, @@ -118,7 +117,7 @@ async def _async_reproduce_states( if features & MediaPlayerEntityFeature.PLAY_MEDIA: await call_service( SERVICE_PLAY_MEDIA, - [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_ENQUEUE], + [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID], ) already_playing = True diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 2e8585d0127..b2a8ac40262 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -151,6 +151,22 @@ play_media: selector: text: + enqueue: + name: Enqueue + description: If the content should be played now or be added to the queue. + required: false + selector: + select: + options: + - label: "Play now" + value: "play" + - label: "Play next" + value: "next" + - label: "Add to queue" + value: "add" + - label: "Play now and clear queue" + value: "replace" + select_source: name: Select source description: Send the media player the command to change input source. diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 45e11a810ae..fd37e546105 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -18,6 +18,7 @@ import voluptuous as vol from homeassistant.components import media_source, spotify from homeassistant.components.media_player import ( + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, async_process_play_media_url, @@ -537,8 +538,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. - - If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) @@ -575,7 +574,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) if result.shuffle: self.set_shuffle(True) - if kwargs.get(ATTR_MEDIA_ENQUEUE): + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: plex_plugin.add_to_queue(result.media) else: soco.clear_queue() @@ -585,7 +584,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): share_link = self.coordinator.share_link if share_link.is_share_link(media_id): - if kwargs.get(ATTR_MEDIA_ENQUEUE): + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: share_link.add_share_link_to_queue(media_id) else: soco.clear_queue() @@ -595,7 +594,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url(self.hass, media_id) - if kwargs.get(ATTR_MEDIA_ENQUEUE): + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: soco.add_uri_to_queue(media_id) else: soco.play_uri(media_id, force_radio=is_radio) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index d0d1cf89739..cd628a639c5 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, ) @@ -469,16 +470,17 @@ class SqueezeBoxEntity(MediaPlayerEntity): await self._player.async_set_power(True) async def async_play_media(self, media_type, media_id, **kwargs): - """ - Send the play_media command to the media player. - - If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the current playlist. - """ - cmd = "play" + """Send the play_media command to the media player.""" index = None - if kwargs.get(ATTR_MEDIA_ENQUEUE): + enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE) + + if enqueue == MediaPlayerEnqueue.ADD: cmd = "add" + elif enqueue == MediaPlayerEnqueue.NEXT: + cmd = "insert" + else: + cmd = "play" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index aa5e1b164f4..cb095cbcfe0 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -4,6 +4,8 @@ import base64 from http import HTTPStatus from unittest.mock import patch +import pytest + from homeassistant.components import media_player from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.components.websocket_api.const import TYPE_RESULT @@ -251,3 +253,41 @@ async def test_group_members_available_when_off(hass): state = hass.states.get("media_player.bedroom") assert state.state == STATE_OFF assert "group_members" in state.attributes + + +@pytest.mark.parametrize( + "input,expected", + ( + (True, media_player.MediaPlayerEnqueue.ADD), + (False, media_player.MediaPlayerEnqueue.PLAY), + ("play", media_player.MediaPlayerEnqueue.PLAY), + ("next", media_player.MediaPlayerEnqueue.NEXT), + ("add", media_player.MediaPlayerEnqueue.ADD), + ("replace", media_player.MediaPlayerEnqueue.REPLACE), + ), +) +async def test_enqueue_rewrite(hass, input, expected): + """Test that group_members are still available when media_player is off.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + # Fake group support for DemoYoutubePlayer + with patch( + "homeassistant.components.demo.media_player.DemoYoutubePlayer.play_media", + ) as mock_play_media: + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media_content_type": "music", + "media_content_id": "1234", + "enqueue": input, + }, + blocking=True, + ) + + assert len(mock_play_media.mock_calls) == 1 + assert mock_play_media.mock_calls[0][2]["enqueue"] == expected diff --git a/tests/components/media_player/test_reproduce_state.py b/tests/components/media_player/test_reproduce_state.py index f1a243337e1..f880130d4bd 100644 --- a/tests/components/media_player/test_reproduce_state.py +++ b/tests/components/media_player/test_reproduce_state.py @@ -6,7 +6,6 @@ from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, @@ -253,7 +252,6 @@ async def test_play_media(hass): value_1 = "dummy_1" value_2 = "dummy_2" - value_3 = "dummy_3" await async_reproduce_states( hass, @@ -275,7 +273,6 @@ async def test_play_media(hass): { ATTR_MEDIA_CONTENT_TYPE: value_1, ATTR_MEDIA_CONTENT_ID: value_2, - ATTR_MEDIA_ENQUEUE: value_3, }, ) ], @@ -294,5 +291,4 @@ async def test_play_media(hass): "entity_id": ENTITY_1, ATTR_MEDIA_CONTENT_TYPE: value_1, ATTR_MEDIA_CONTENT_ID: value_2, - ATTR_MEDIA_ENQUEUE: value_3, } From ce4825c9e2886081ce8606336c2c7cd07ca918d0 Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 30 May 2022 02:54:23 +0800 Subject: [PATCH 42/90] Fix yolink device unavailable on startup (#72579) * fetch device state on startup * Suggest change * suggest fix * fix * fix * Fix suggest * suggest fix --- homeassistant/components/yolink/__init__.py | 68 ++++++++++++--- .../components/yolink/binary_sensor.py | 44 ++++++---- homeassistant/components/yolink/const.py | 2 +- .../components/yolink/coordinator.py | 86 +++---------------- homeassistant/components/yolink/entity.py | 20 +++-- homeassistant/components/yolink/sensor.py | 38 ++++---- homeassistant/components/yolink/siren.py | 39 +++++---- homeassistant/components/yolink/switch.py | 41 +++++---- 8 files changed, 177 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 2c85344c54b..7eb6b0229f0 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -1,25 +1,28 @@ """The yolink integration.""" from __future__ import annotations +import asyncio from datetime import timedelta -import logging +import async_timeout from yolink.client import YoLinkClient +from yolink.device import YoLinkDevice +from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from yolink.model import BRDP from yolink.mqtt_client import MqttClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import ATTR_CLIENT, ATTR_COORDINATOR, ATTR_MQTT_CLIENT, DOMAIN +from .const import ATTR_CLIENT, ATTR_COORDINATORS, ATTR_DEVICE, ATTR_MQTT_CLIENT, DOMAIN from .coordinator import YoLinkCoordinator SCAN_INTERVAL = timedelta(minutes=5) -_LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SIREN, Platform.SWITCH] @@ -41,18 +44,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: yolink_http_client = YoLinkClient(auth_mgr) yolink_mqtt_client = MqttClient(auth_mgr) - coordinator = YoLinkCoordinator(hass, yolink_http_client, yolink_mqtt_client) - await coordinator.init_coordinator() + + def on_message_callback(message: tuple[str, BRDP]) -> None: + data = message[1] + device_id = message[0] + if data.event is None: + return + event_param = data.event.split(".") + event_type = event_param[len(event_param) - 1] + if event_type not in ( + "Report", + "Alert", + "StatusChange", + "getState", + ): + return + resolved_state = data.data + if resolved_state is None: + return + entry_data = hass.data[DOMAIN].get(entry.entry_id) + if entry_data is None: + return + device_coordinators = entry_data.get(ATTR_COORDINATORS) + if device_coordinators is None: + return + device_coordinator = device_coordinators.get(device_id) + if device_coordinator is None: + return + device_coordinator.async_set_updated_data(resolved_state) + try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady as ex: - _LOGGER.error("Fetching initial data failed: %s", ex) + async with async_timeout.timeout(10): + device_response = await yolink_http_client.get_auth_devices() + home_info = await yolink_http_client.get_general_info() + await yolink_mqtt_client.init_home_connection( + home_info.data["id"], on_message_callback + ) + except YoLinkAuthFailError as yl_auth_err: + raise ConfigEntryAuthFailed from yl_auth_err + except (YoLinkClientError, asyncio.TimeoutError) as err: + raise ConfigEntryNotReady from err hass.data[DOMAIN][entry.entry_id] = { ATTR_CLIENT: yolink_http_client, ATTR_MQTT_CLIENT: yolink_mqtt_client, - ATTR_COORDINATOR: coordinator, } + auth_devices = device_response.data[ATTR_DEVICE] + device_coordinators = {} + for device_info in auth_devices: + device = YoLinkDevice(device_info, yolink_http_client) + device_coordinator = YoLinkCoordinator(hass, device) + try: + await device_coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + # Not failure by fetching device state + device_coordinator.data = {} + device_coordinators[device.device_id] = device_coordinator + hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATORS] = device_coordinators hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 42899e08a2c..cacba484fe9 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from yolink.device import YoLinkDevice @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_COORDINATOR, + ATTR_COORDINATORS, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, @@ -32,7 +33,7 @@ class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True state_key: str = "state" - value: Callable[[str], bool | None] = lambda _: None + value: Callable[[Any], bool | None] = lambda _: None SENSOR_DEVICE_TYPE = [ @@ -47,14 +48,14 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( icon="mdi:door", device_class=BinarySensorDeviceClass.DOOR, name="State", - value=lambda value: value == "open", + value=lambda value: value == "open" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR], ), YoLinkBinarySensorEntityDescription( key="motion_state", device_class=BinarySensorDeviceClass.MOTION, name="Motion", - value=lambda value: value == "alert", + value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_MOTION_SENSOR], ), YoLinkBinarySensorEntityDescription( @@ -62,7 +63,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( name="Leak", icon="mdi:water", device_class=BinarySensorDeviceClass.MOISTURE, - value=lambda value: value == "alert", + value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_LEAK_SENSOR], ), ) @@ -74,18 +75,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - sensor_devices = [ - device - for device in coordinator.yl_devices - if device.device_type in SENSOR_DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + binary_sensor_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in SENSOR_DEVICE_TYPE ] entities = [] - for sensor_device in sensor_devices: + for binary_sensor_device_coordinator in binary_sensor_device_coordinators: for description in SENSOR_TYPES: - if description.exists_fn(sensor_device): + if description.exists_fn(binary_sensor_device_coordinator.device): entities.append( - YoLinkBinarySensorEntity(coordinator, description, sensor_device) + YoLinkBinarySensorEntity( + binary_sensor_device_coordinator, description + ) ) async_add_entities(entities) @@ -99,18 +102,21 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): self, coordinator: YoLinkCoordinator, description: YoLinkBinarySensorEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) @callback - def update_entity_state(self, state: dict) -> None: + def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state[self.entity_description.state_key] + state.get(self.entity_description.state_key) ) self.async_write_ha_state() diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 00d6d6d028e..97252c5c989 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -5,7 +5,7 @@ MANUFACTURER = "YoLink" HOME_ID = "homeId" HOME_SUBSCRIPTION = "home_subscription" ATTR_PLATFORM_SENSOR = "sensor" -ATTR_COORDINATOR = "coordinator" +ATTR_COORDINATORS = "coordinators" ATTR_DEVICE = "devices" ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index e5578eae4b2..68a1aef42f7 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -1,22 +1,18 @@ """YoLink DataUpdateCoordinator.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging import async_timeout -from yolink.client import YoLinkClient from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError -from yolink.model import BRDP -from yolink.mqtt_client import MqttClient from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE, ATTR_DEVICE_STATE, DOMAIN +from .const import ATTR_DEVICE_STATE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -24,9 +20,7 @@ _LOGGER = logging.getLogger(__name__) class YoLinkCoordinator(DataUpdateCoordinator[dict]): """YoLink DataUpdateCoordinator.""" - def __init__( - self, hass: HomeAssistant, yl_client: YoLinkClient, yl_mqtt_client: MqttClient - ) -> None: + def __init__(self, hass: HomeAssistant, device: YoLinkDevice) -> None: """Init YoLink DataUpdateCoordinator. fetch state every 30 minutes base on yolink device heartbeat interval @@ -35,75 +29,17 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) ) - self._client = yl_client - self._mqtt_client = yl_mqtt_client - self.yl_devices: list[YoLinkDevice] = [] - self.data = {} + self.device = device - def on_message_callback(self, message: tuple[str, BRDP]): - """On message callback.""" - data = message[1] - if data.event is None: - return - event_param = data.event.split(".") - event_type = event_param[len(event_param) - 1] - if event_type not in ( - "Report", - "Alert", - "StatusChange", - "getState", - ): - return - resolved_state = data.data - if resolved_state is None: - return - self.data[message[0]] = resolved_state - self.async_set_updated_data(self.data) - - async def init_coordinator(self): - """Init coordinator.""" + async def _async_update_data(self) -> dict: + """Fetch device state.""" try: async with async_timeout.timeout(10): - home_info = await self._client.get_general_info() - await self._mqtt_client.init_home_connection( - home_info.data["id"], self.on_message_callback - ) - async with async_timeout.timeout(10): - device_response = await self._client.get_auth_devices() - - except YoLinkAuthFailError as yl_auth_err: - raise ConfigEntryAuthFailed from yl_auth_err - - except (YoLinkClientError, asyncio.TimeoutError) as err: - raise ConfigEntryNotReady from err - - yl_devices: list[YoLinkDevice] = [] - - for device_info in device_response.data[ATTR_DEVICE]: - yl_devices.append(YoLinkDevice(device_info, self._client)) - - self.yl_devices = yl_devices - - async def fetch_device_state(self, device: YoLinkDevice): - """Fetch Device State.""" - try: - async with async_timeout.timeout(10): - device_state_resp = await device.fetch_state_with_api() - if ATTR_DEVICE_STATE in device_state_resp.data: - self.data[device.device_id] = device_state_resp.data[ - ATTR_DEVICE_STATE - ] + device_state_resp = await self.device.fetch_state_with_api() except YoLinkAuthFailError as yl_auth_err: raise ConfigEntryAuthFailed from yl_auth_err except YoLinkClientError as yl_client_err: - raise UpdateFailed( - f"Error communicating with API: {yl_client_err}" - ) from yl_client_err - - async def _async_update_data(self) -> dict: - fetch_tasks = [] - for yl_device in self.yl_devices: - fetch_tasks.append(self.fetch_device_state(yl_device)) - if fetch_tasks: - await asyncio.gather(*fetch_tasks) - return self.data + raise UpdateFailed from yl_client_err + if ATTR_DEVICE_STATE in device_state_resp.data: + return device_state_resp.data[ATTR_DEVICE_STATE] + return {} diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 6954b117728..5365681739e 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -3,8 +3,6 @@ from __future__ import annotations from abc import abstractmethod -from yolink.device import YoLinkDevice - from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,20 +17,24 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def __init__( self, coordinator: YoLinkCoordinator, - device_info: YoLinkDevice, ) -> None: """Init YoLink Entity.""" super().__init__(coordinator) - self.device = device_info @property def device_id(self) -> str: """Return the device id of the YoLink device.""" - return self.device.device_id + return self.coordinator.device.device_id + + async def async_added_to_hass(self) -> None: + """Update state.""" + await super().async_added_to_hass() + return self._handle_coordinator_update() @callback def _handle_coordinator_update(self) -> None: - data = self.coordinator.data.get(self.device.device_id) + """Update state.""" + data = self.coordinator.data if data is not None: self.update_entity_state(data) @@ -40,10 +42,10 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def device_info(self) -> DeviceInfo: """Return the device info for HA.""" return DeviceInfo( - identifiers={(DOMAIN, self.device.device_id)}, + identifiers={(DOMAIN, self.coordinator.device.device_id)}, manufacturer=MANUFACTURER, - model=self.device.device_type, - name=self.device.device_name, + model=self.coordinator.device.device_type, + name=self.coordinator.device.device_name, ) @callback diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index e33772c24be..463d8b14da4 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import percentage from .const import ( - ATTR_COORDINATOR, + ATTR_COORDINATORS, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, @@ -54,7 +54,9 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value=lambda value: percentage.ordered_list_item_to_percentage( [1, 2, 3, 4], value - ), + ) + if value is not None + else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_MOTION_SENSOR], ), @@ -89,18 +91,21 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - sensor_devices = [ - device - for device in coordinator.yl_devices - if device.device_type in SENSOR_DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + sensor_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in SENSOR_DEVICE_TYPE ] entities = [] - for sensor_device in sensor_devices: + for sensor_device_coordinator in sensor_device_coordinators: for description in SENSOR_TYPES: - if description.exists_fn(sensor_device): + if description.exists_fn(sensor_device_coordinator.device): entities.append( - YoLinkSensorEntity(coordinator, description, sensor_device) + YoLinkSensorEntity( + sensor_device_coordinator, + description, + ) ) async_add_entities(entities) @@ -114,18 +119,21 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): self, coordinator: YoLinkCoordinator, description: YoLinkSensorEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) @callback def update_entity_state(self, state: dict) -> None: """Update HA Entity State.""" self._attr_native_value = self.entity_description.value( - state[self.entity_description.key] + state.get(self.entity_description.key) ) self.async_write_ha_state() diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 7a621db6eca..7e67dfb12f1 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_COORDINATOR, ATTR_DEVICE_SIREN, DOMAIN +from .const import ATTR_COORDINATORS, ATTR_DEVICE_SIREN, DOMAIN from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -28,14 +28,14 @@ class YoLinkSirenEntityDescription(SirenEntityDescription): """YoLink SirenEntityDescription.""" exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - value: Callable[[str], bool | None] = lambda _: None + value: Callable[[Any], bool | None] = lambda _: None DEVICE_TYPES: tuple[YoLinkSirenEntityDescription, ...] = ( YoLinkSirenEntityDescription( key="state", name="State", - value=lambda value: value == "alert", + value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SIREN], ), ) @@ -49,16 +49,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink siren from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - devices = [ - device for device in coordinator.yl_devices if device.device_type in DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + siren_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in DEVICE_TYPE ] entities = [] - for device in devices: + for siren_device_coordinator in siren_device_coordinators: for description in DEVICE_TYPES: - if description.exists_fn(device): + if description.exists_fn(siren_device_coordinator.device): entities.append( - YoLinkSirenEntity(config_entry, coordinator, description, device) + YoLinkSirenEntity( + config_entry, siren_device_coordinator, description + ) ) async_add_entities(entities) @@ -73,23 +77,26 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSirenEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Siren.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.config_entry = config_entry self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) self._attr_supported_features = ( SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF ) @callback - def update_entity_state(self, state: dict) -> None: + def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state[self.entity_description.key] + state.get(self.entity_description.key) ) self.async_write_ha_state() @@ -97,7 +104,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): """Call setState api to change siren state.""" try: # call_device_http_api will check result, fail by raise YoLinkClientError - await self.device.call_device_http_api( + await self.coordinator.device.call_device_http_api( "setState", {"state": {"alarm": state}} ) except YoLinkAuthFailError as yl_auth_err: diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index b3756efb74c..f16dc781a9c 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_COORDINATOR, ATTR_DEVICE_OUTLET, DOMAIN +from .const import ATTR_COORDINATORS, ATTR_DEVICE_OUTLET, DOMAIN from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -28,7 +28,7 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription): """YoLink SwitchEntityDescription.""" exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - value: Callable[[str], bool | None] = lambda _: None + value: Callable[[Any], bool | None] = lambda _: None DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( @@ -36,7 +36,7 @@ DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( key="state", device_class=SwitchDeviceClass.OUTLET, name="State", - value=lambda value: value == "open", + value=lambda value: value == "open" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_OUTLET], ), ) @@ -50,16 +50,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - devices = [ - device for device in coordinator.yl_devices if device.device_type in DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + switch_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in DEVICE_TYPE ] entities = [] - for device in devices: + for switch_device_coordinator in switch_device_coordinators: for description in DEVICE_TYPES: - if description.exists_fn(device): + if description.exists_fn(switch_device_coordinator.device): entities.append( - YoLinkSwitchEntity(config_entry, coordinator, description, device) + YoLinkSwitchEntity( + config_entry, switch_device_coordinator, description + ) ) async_add_entities(entities) @@ -74,20 +78,23 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSwitchEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Outlet.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.config_entry = config_entry self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) @callback - def update_entity_state(self, state: dict) -> None: + def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state[self.entity_description.key] + state.get(self.entity_description.key) ) self.async_write_ha_state() @@ -95,7 +102,9 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): """Call setState api to change outlet state.""" try: # call_device_http_api will check result, fail by raise YoLinkClientError - await self.device.call_device_http_api("setState", {"state": state}) + await self.coordinator.device.call_device_http_api( + "setState", {"state": state} + ) except YoLinkAuthFailError as yl_auth_err: self.config_entry.async_start_reauth(self.hass) raise HomeAssistantError(yl_auth_err) from yl_auth_err From f41b2fa2cf9bda58690507ef08909f32055c934e Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 30 May 2022 23:37:28 +0200 Subject: [PATCH 43/90] Fix homewizard diagnostics and add tests (#72611) --- .coveragerc | 1 - .../components/homewizard/diagnostics.py | 9 ++-- tests/components/homewizard/conftest.py | 49 ++++++++++++++++++- .../components/homewizard/fixtures/data.json | 16 ++++++ .../homewizard/fixtures/device.json | 7 +++ .../components/homewizard/fixtures/state.json | 5 ++ .../components/homewizard/test_diagnostics.py | 47 ++++++++++++++++++ 7 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 tests/components/homewizard/fixtures/data.json create mode 100644 tests/components/homewizard/fixtures/device.json create mode 100644 tests/components/homewizard/fixtures/state.json create mode 100644 tests/components/homewizard/test_diagnostics.py diff --git a/.coveragerc b/.coveragerc index aced69a4714..62e6d3cb94e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -491,7 +491,6 @@ omit = homeassistant/components/homematic/* homeassistant/components/home_plus_control/api.py homeassistant/components/home_plus_control/switch.py - homeassistant/components/homewizard/diagnostics.py homeassistant/components/homeworks/* homeassistant/components/honeywell/__init__.py homeassistant/components/honeywell/climate.py diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index 3dd55933291..a97d2507098 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -1,6 +1,7 @@ """Diagnostics support for P1 Monitor.""" from __future__ import annotations +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -21,10 +22,10 @@ async def async_get_config_entry_diagnostics( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] meter_data = { - "device": coordinator.api.device.todict(), - "data": coordinator.api.data.todict(), - "state": coordinator.api.state.todict() - if coordinator.api.state is not None + "device": asdict(coordinator.data["device"]), + "data": asdict(coordinator.data["data"]), + "state": asdict(coordinator.data["state"]) + if coordinator.data["state"] is not None else None, } diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index 15993aa35ed..1617db35458 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,10 +1,15 @@ """Fixtures for HomeWizard integration tests.""" +import json +from unittest.mock import AsyncMock, patch + +from homewizard_energy.models import Data, Device, State import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture @pytest.fixture @@ -25,6 +30,46 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( title="Product Name (aabbccddeeff)", domain=DOMAIN, - data={}, + data={CONF_IP_ADDRESS: "1.2.3.4"}, unique_id="aabbccddeeff", ) + + +@pytest.fixture +def mock_homewizardenergy(): + """Return a mocked P1 meter.""" + with patch( + "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", + ) as device: + client = device.return_value + client.device = AsyncMock( + return_value=Device.from_dict( + json.loads(load_fixture("homewizard/device.json")) + ) + ) + client.data = AsyncMock( + return_value=Data.from_dict( + json.loads(load_fixture("homewizard/data.json")) + ) + ) + client.state = AsyncMock( + return_value=State.from_dict( + json.loads(load_fixture("homewizard/state.json")) + ) + ) + yield device + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: AsyncMock, +) -> MockConfigEntry: + """Set up the HomeWizard integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/homewizard/fixtures/data.json b/tests/components/homewizard/fixtures/data.json new file mode 100644 index 00000000000..b6eada38038 --- /dev/null +++ b/tests/components/homewizard/fixtures/data.json @@ -0,0 +1,16 @@ +{ + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 100, + "total_power_import_t1_kwh": 1234.111, + "total_power_import_t2_kwh": 5678.222, + "total_power_export_t1_kwh": 4321.333, + "total_power_export_t2_kwh": 8765.444, + "active_power_w": -123, + "active_power_l1_w": -123, + "active_power_l2_w": 456, + "active_power_l3_w": 123.456, + "total_gas_m3": 1122.333, + "gas_timestamp": 210314112233 +} diff --git a/tests/components/homewizard/fixtures/device.json b/tests/components/homewizard/fixtures/device.json new file mode 100644 index 00000000000..493daa12b94 --- /dev/null +++ b/tests/components/homewizard/fixtures/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 Meter", + "serial": "3c39e7aabbcc", + "firmware_version": "2.11", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/state.json b/tests/components/homewizard/fixtures/state.json new file mode 100644 index 00000000000..bbc0242ed58 --- /dev/null +++ b/tests/components/homewizard/fixtures/state.json @@ -0,0 +1,5 @@ +{ + "power_on": true, + "switch_lock": false, + "brightness": 255 +} diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py new file mode 100644 index 00000000000..e477c94d914 --- /dev/null +++ b/tests/components/homewizard/test_diagnostics.py @@ -0,0 +1,47 @@ +"""Tests for diagnostics data.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "entry": {"ip_address": REDACTED}, + "data": { + "device": { + "product_name": "P1 Meter", + "product_type": "HWE-P1", + "serial": REDACTED, + "api_version": "v1", + "firmware_version": "2.11", + }, + "data": { + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "wifi_ssid": REDACTED, + "wifi_strength": 100, + "total_power_import_t1_kwh": 1234.111, + "total_power_import_t2_kwh": 5678.222, + "total_power_export_t1_kwh": 4321.333, + "total_power_export_t2_kwh": 8765.444, + "active_power_w": -123, + "active_power_l1_w": -123, + "active_power_l2_w": 456, + "active_power_l3_w": 123.456, + "total_gas_m3": 1122.333, + "gas_timestamp": "2021-03-14T11:22:33", + }, + "state": {"power_on": True, "switch_lock": False, "brightness": 255}, + }, + } From 4b524c077602db576e15c27ed813dbb0bc6c6774 Mon Sep 17 00:00:00 2001 From: BigMoby Date: Mon, 30 May 2022 08:26:05 +0200 Subject: [PATCH 44/90] iAlarm XR integration refinements (#72616) * fixing after MartinHjelmare review * fixing after MartinHjelmare review conversion alarm state to hass state * fixing after MartinHjelmare review conversion alarm state to hass state * manage the status in the alarm control * simplyfing return function --- .../components/ialarm_xr/__init__.py | 6 ++--- .../ialarm_xr/alarm_control_panel.py | 22 ++++++++++++++++--- .../components/ialarm_xr/config_flow.py | 4 ++-- homeassistant/components/ialarm_xr/const.py | 15 ------------- .../components/ialarm_xr/manifest.json | 4 ++-- .../components/ialarm_xr/strings.json | 1 + .../components/ialarm_xr/translations/en.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/ialarm_xr/test_config_flow.py | 22 ++----------------- tests/components/ialarm_xr/test_init.py | 10 --------- 11 files changed, 32 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/ialarm_xr/__init__.py b/homeassistant/components/ialarm_xr/__init__.py index 9a41b5ebab7..193bbe4fffc 100644 --- a/homeassistant/components/ialarm_xr/__init__.py +++ b/homeassistant/components/ialarm_xr/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, IALARMXR_TO_HASS +from .const import DOMAIN from .utils import async_get_ialarmxr_mac PLATFORMS = [Platform.ALARM_CONTROL_PANEL] @@ -74,7 +74,7 @@ class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, ialarmxr: IAlarmXR, mac: str) -> None: """Initialize global iAlarm data updater.""" self.ialarmxr: IAlarmXR = ialarmxr - self.state: str | None = None + self.state: int | None = None self.host: str = ialarmxr.host self.mac: str = mac @@ -90,7 +90,7 @@ class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator): status: int = self.ialarmxr.get_status() _LOGGER.debug("iAlarmXR status: %s", status) - self.state = IALARMXR_TO_HASS.get(status) + self.state = status async def _async_update_data(self) -> None: """Fetch data from iAlarmXR.""" diff --git a/homeassistant/components/ialarm_xr/alarm_control_panel.py b/homeassistant/components/ialarm_xr/alarm_control_panel.py index 7b47ce3d7fa..b64edb74391 100644 --- a/homeassistant/components/ialarm_xr/alarm_control_panel.py +++ b/homeassistant/components/ialarm_xr/alarm_control_panel.py @@ -1,11 +1,19 @@ """Interfaces with iAlarmXR control panels.""" from __future__ import annotations +from pyialarmxr import IAlarmXR + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry from homeassistant.helpers.entity import DeviceInfo @@ -15,6 +23,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import IAlarmXRDataUpdateCoordinator from .const import DOMAIN +IALARMXR_TO_HASS = { + IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME, + IAlarmXR.DISARMED: STATE_ALARM_DISARMED, + IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED, +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -24,7 +39,9 @@ async def async_setup_entry( async_add_entities([IAlarmXRPanel(coordinator)]) -class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity): +class IAlarmXRPanel( + CoordinatorEntity[IAlarmXRDataUpdateCoordinator], AlarmControlPanelEntity +): """Representation of an iAlarmXR device.""" _attr_supported_features = ( @@ -37,7 +54,6 @@ class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity): def __init__(self, coordinator: IAlarmXRDataUpdateCoordinator) -> None: """Initialize the alarm panel.""" super().__init__(coordinator) - self.coordinator: IAlarmXRDataUpdateCoordinator = coordinator self._attr_unique_id = coordinator.mac self._attr_device_info = DeviceInfo( manufacturer="Antifurto365 - Meian", @@ -48,7 +64,7 @@ class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity): @property def state(self) -> str | None: """Return the state of the device.""" - return self.coordinator.state + return IALARMXR_TO_HASS.get(self.coordinator.state) def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" diff --git a/homeassistant/components/ialarm_xr/config_flow.py b/homeassistant/components/ialarm_xr/config_flow.py index 06509a82eb5..2a9cc406733 100644 --- a/homeassistant/components/ialarm_xr/config_flow.py +++ b/homeassistant/components/ialarm_xr/config_flow.py @@ -72,13 +72,13 @@ class IAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "IAlarmXRGenericException with message: [ %s ]", ialarmxr_exception.message, ) - errors["base"] = "unknown" + errors["base"] = "cannot_connect" except IAlarmXRSocketTimeoutException as ialarmxr_socket_timeout_exception: _LOGGER.debug( "IAlarmXRSocketTimeoutException with message: [ %s ]", ialarmxr_socket_timeout_exception.message, ) - errors["base"] = "unknown" + errors["base"] = "timeout" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/ialarm_xr/const.py b/homeassistant/components/ialarm_xr/const.py index a208f5290b6..12122277340 100644 --- a/homeassistant/components/ialarm_xr/const.py +++ b/homeassistant/components/ialarm_xr/const.py @@ -1,18 +1,3 @@ """Constants for the iAlarmXR integration.""" -from pyialarmxr import IAlarmXR - -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, -) DOMAIN = "ialarm_xr" - -IALARMXR_TO_HASS = { - IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME, - IAlarmXR.DISARMED: STATE_ALARM_DISARMED, - IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED, -} diff --git a/homeassistant/components/ialarm_xr/manifest.json b/homeassistant/components/ialarm_xr/manifest.json index 4861e9c901f..f863f360242 100644 --- a/homeassistant/components/ialarm_xr/manifest.json +++ b/homeassistant/components/ialarm_xr/manifest.json @@ -1,8 +1,8 @@ { "domain": "ialarm_xr", "name": "Antifurto365 iAlarmXR", - "documentation": "https://www.home-assistant.io/integrations/ialarmxr", - "requirements": ["pyialarmxr==1.0.13"], + "documentation": "https://www.home-assistant.io/integrations/ialarm_xr", + "requirements": ["pyialarmxr==1.0.18"], "codeowners": ["@bigmoby"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/ialarm_xr/strings.json b/homeassistant/components/ialarm_xr/strings.json index 1650ae28c84..ea4f91fdbb9 100644 --- a/homeassistant/components/ialarm_xr/strings.json +++ b/homeassistant/components/ialarm_xr/strings.json @@ -12,6 +12,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/ialarm_xr/translations/en.json b/homeassistant/components/ialarm_xr/translations/en.json index bf2bf989dcd..be59a5a1dc4 100644 --- a/homeassistant/components/ialarm_xr/translations/en.json +++ b/homeassistant/components/ialarm_xr/translations/en.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Failed to connect", + "timeout": "Timeout establishing connection", "unknown": "Unexpected error" }, "step": { diff --git a/requirements_all.txt b/requirements_all.txt index f0cc4f6fb67..63f0f929c36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1550,7 +1550,7 @@ pyhomeworks==0.0.6 pyialarm==1.9.0 # homeassistant.components.ialarm_xr -pyialarmxr==1.0.13 +pyialarmxr==1.0.18 # homeassistant.components.icloud pyicloud==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 783542a1c69..cb5f6a242e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1038,7 +1038,7 @@ pyhomematic==0.1.77 pyialarm==1.9.0 # homeassistant.components.ialarm_xr -pyialarmxr==1.0.13 +pyialarmxr==1.0.18 # homeassistant.components.icloud pyicloud==1.0.0 diff --git a/tests/components/ialarm_xr/test_config_flow.py b/tests/components/ialarm_xr/test_config_flow.py index 22a70bda067..804249dd5cb 100644 --- a/tests/components/ialarm_xr/test_config_flow.py +++ b/tests/components/ialarm_xr/test_config_flow.py @@ -56,24 +56,6 @@ async def test_form(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass): - """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.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=ConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} - - async def test_form_exception(hass): """Test we handle unknown exception.""" result = await hass.config_entries.flow.async_init( @@ -125,7 +107,7 @@ async def test_form_cannot_connect_throwing_socket_timeout_exception(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"] == {"base": "timeout"} async def test_form_cannot_connect_throwing_generic_exception(hass): @@ -143,7 +125,7 @@ async def test_form_cannot_connect_throwing_generic_exception(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"] == {"base": "cannot_connect"} async def test_form_already_exists(hass): diff --git a/tests/components/ialarm_xr/test_init.py b/tests/components/ialarm_xr/test_init.py index 8486b7049e6..0898b6bebf8 100644 --- a/tests/components/ialarm_xr/test_init.py +++ b/tests/components/ialarm_xr/test_init.py @@ -48,16 +48,6 @@ async def test_setup_entry(hass, ialarmxr_api, mock_config_entry): assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_setup_not_ready(hass, ialarmxr_api, mock_config_entry): - """Test setup failed because we can't connect to the alarm system.""" - ialarmxr_api.return_value.get_mac = Mock(side_effect=ConnectionError) - - mock_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - async def test_unload_entry(hass, ialarmxr_api, mock_config_entry): """Test being able to unload an entry.""" ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") From c62692dff14103e546fbc182270cff051464dae9 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 15:36:58 -0600 Subject: [PATCH 45/90] Guard against missing data in 1st generation RainMachine controllers (#72632) --- .../components/rainmachine/binary_sensor.py | 16 +++---- .../components/rainmachine/sensor.py | 2 +- .../components/rainmachine/switch.py | 43 +++++++++++-------- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index fb404adb199..730b51c142a 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -158,17 +158,17 @@ class CurrentRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): def update_from_latest_data(self) -> None: """Update the state.""" if self.entity_description.key == TYPE_FREEZE: - self._attr_is_on = self.coordinator.data["freeze"] + self._attr_is_on = self.coordinator.data.get("freeze") elif self.entity_description.key == TYPE_HOURLY: - self._attr_is_on = self.coordinator.data["hourly"] + self._attr_is_on = self.coordinator.data.get("hourly") elif self.entity_description.key == TYPE_MONTH: - self._attr_is_on = self.coordinator.data["month"] + self._attr_is_on = self.coordinator.data.get("month") elif self.entity_description.key == TYPE_RAINDELAY: - self._attr_is_on = self.coordinator.data["rainDelay"] + self._attr_is_on = self.coordinator.data.get("rainDelay") elif self.entity_description.key == TYPE_RAINSENSOR: - self._attr_is_on = self.coordinator.data["rainSensor"] + self._attr_is_on = self.coordinator.data.get("rainSensor") elif self.entity_description.key == TYPE_WEEKDAY: - self._attr_is_on = self.coordinator.data["weekDay"] + self._attr_is_on = self.coordinator.data.get("weekDay") class ProvisionSettingsBinarySensor(RainMachineEntity, BinarySensorEntity): @@ -188,6 +188,6 @@ class UniversalRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): def update_from_latest_data(self) -> None: """Update the state.""" if self.entity_description.key == TYPE_FREEZE_PROTECTION: - self._attr_is_on = self.coordinator.data["freezeProtectEnabled"] + self._attr_is_on = self.coordinator.data.get("freezeProtectEnabled") elif self.entity_description.key == TYPE_HOT_DAYS: - self._attr_is_on = self.coordinator.data["hotDaysExtraWatering"] + self._attr_is_on = self.coordinator.data.get("hotDaysExtraWatering") diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index b825faca7e1..a2b0f7cd539 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -198,7 +198,7 @@ class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): def update_from_latest_data(self) -> None: """Update the state.""" if self.entity_description.key == TYPE_FREEZE_TEMP: - self._attr_native_value = self.coordinator.data["freezeProtectTemp"] + self._attr_native_value = self.coordinator.data.get("freezeProtectTemp") class ZoneTimeRemainingSensor(RainMachineEntity, SensorEntity): diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 007aec97a3e..a220aafa2a5 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -389,23 +389,32 @@ class RainMachineZone(RainMachineActivitySwitch): self._attr_is_on = bool(data["state"]) - self._attr_extra_state_attributes.update( - { - ATTR_AREA: round(data["waterSense"]["area"], 2), - ATTR_CURRENT_CYCLE: data["cycle"], - ATTR_FIELD_CAPACITY: round(data["waterSense"]["fieldCapacity"], 2), - ATTR_ID: data["uid"], - ATTR_NO_CYCLES: data["noOfCycles"], - ATTR_PRECIP_RATE: round(data["waterSense"]["precipitationRate"], 2), - ATTR_RESTRICTIONS: data["restriction"], - ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99), - ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99), - ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99), - ATTR_STATUS: RUN_STATE_MAP[data["state"]], - ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")), - ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data["type"], 99), - } - ) + attrs = { + ATTR_CURRENT_CYCLE: data["cycle"], + ATTR_ID: data["uid"], + ATTR_NO_CYCLES: data["noOfCycles"], + ATTR_RESTRICTIONS: data("restriction"), + ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99), + ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99), + ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99), + ATTR_STATUS: RUN_STATE_MAP[data["state"]], + ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")), + ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data["type"], 99), + } + + if "waterSense" in data: + if "area" in data["waterSense"]: + attrs[ATTR_AREA] = round(data["waterSense"]["area"], 2) + if "fieldCapacity" in data["waterSense"]: + attrs[ATTR_FIELD_CAPACITY] = round( + data["waterSense"]["fieldCapacity"], 2 + ) + if "precipitationRate" in data["waterSense"]: + attrs[ATTR_PRECIP_RATE] = round( + data["waterSense"]["precipitationRate"], 2 + ) + + self._attr_extra_state_attributes.update(attrs) class RainMachineZoneEnabled(RainMachineEnabledSwitch): From f039aac31c106c533c6907828f46fbfb0f3f9633 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 May 2022 12:30:00 -0400 Subject: [PATCH 46/90] Fix zwave_js custom trigger validation bug (#72656) * Fix zwave_js custom trigger validation bug * update comments * Switch to ValueError * Switch to ValueError --- .../components/zwave_js/triggers/event.py | 36 +-- .../zwave_js/triggers/value_updated.py | 12 +- tests/components/zwave_js/test_trigger.py | 217 ++++++++++++++++++ 3 files changed, 241 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 17bb52fb392..784ae74777b 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -8,7 +8,7 @@ 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.node import NODE_EVENT_MODEL_MAP, Node +from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP from homeassistant.components.automation import ( AutomationActionType, @@ -20,7 +20,6 @@ from homeassistant.components.zwave_js.const import ( ATTR_EVENT_DATA, ATTR_EVENT_SOURCE, ATTR_NODE_ID, - ATTR_NODES, ATTR_PARTIAL_DICT_MATCH, DATA_CLIENT, DOMAIN, @@ -116,22 +115,20 @@ async def async_validate_trigger_config( """Validate config.""" config = TRIGGER_SCHEMA(config) + if ATTR_CONFIG_ENTRY_ID in config: + entry_id = config[ATTR_CONFIG_ENTRY_ID] + if hass.config_entries.async_get_entry(entry_id) is None: + raise vol.Invalid(f"Config entry '{entry_id}' not found") + if async_bypass_dynamic_config_validation(hass, config): return config - if config[ATTR_EVENT_SOURCE] == "node": - config[ATTR_NODES] = async_get_nodes_from_targets(hass, config) - if not config[ATTR_NODES]: - raise vol.Invalid( - f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." - ) - - if ATTR_CONFIG_ENTRY_ID not in config: - return config - - entry_id = config[ATTR_CONFIG_ENTRY_ID] - if hass.config_entries.async_get_entry(entry_id) is None: - raise vol.Invalid(f"Config entry '{entry_id}' not found") + if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets( + hass, config + ): + raise vol.Invalid( + f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." + ) return config @@ -145,7 +142,12 @@ async def async_attach_trigger( platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - nodes: set[Node] = config.get(ATTR_NODES, {}) + 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: + raise ValueError( + f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." + ) event_source = config[ATTR_EVENT_SOURCE] event_name = config[ATTR_EVENT] @@ -200,8 +202,6 @@ async def async_attach_trigger( hass.async_run_hass_job(job, {"trigger": payload}) - dev_reg = dr.async_get(hass) - if not nodes: entry_id = config[ATTR_CONFIG_ENTRY_ID] client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 4f15b87a6db..29b4b4d06d6 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -5,7 +5,6 @@ import functools import voluptuous as vol from zwave_js_server.const import CommandClass -from zwave_js_server.model.node import Node from zwave_js_server.model.value import Value, get_value_id from homeassistant.components.automation import ( @@ -20,7 +19,6 @@ from homeassistant.components.zwave_js.const import ( ATTR_CURRENT_VALUE_RAW, ATTR_ENDPOINT, ATTR_NODE_ID, - ATTR_NODES, ATTR_PREVIOUS_VALUE, ATTR_PREVIOUS_VALUE_RAW, ATTR_PROPERTY, @@ -79,8 +77,7 @@ async def async_validate_trigger_config( if async_bypass_dynamic_config_validation(hass, config): return config - config[ATTR_NODES] = async_get_nodes_from_targets(hass, config) - if not config[ATTR_NODES]: + if not async_get_nodes_from_targets(hass, config): raise vol.Invalid( f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." ) @@ -96,7 +93,11 @@ async def async_attach_trigger( platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - nodes: set[Node] = config[ATTR_NODES] + dev_reg = dr.async_get(hass) + if not (nodes := 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." + ) from_value = config[ATTR_FROM] to_value = config[ATTR_TO] @@ -163,7 +164,6 @@ async def async_attach_trigger( hass.async_run_hass_job(job, {"trigger": payload}) - dev_reg = dr.async_get(hass) for node in nodes: driver = node.client.driver assert driver is not None # The node comes from the driver. diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 9758f566d81..48439eede0f 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -269,6 +269,122 @@ async def test_zwave_js_value_updated(hass, client, lock_schlage_be469, integrat await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) +async def test_zwave_js_value_updated_bypass_dynamic_validation( + hass, client, lock_schlage_be469, integration +): + """Test zwave_js.value_updated trigger when bypassing dynamic validation.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + + no_value_filter = async_capture_events(hass, "no_value_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.value_updated.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # no value filter + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + ] + }, + ) + + # Test that no value filter is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + + +async def test_zwave_js_value_updated_bypass_dynamic_validation_no_nodes( + hass, client, lock_schlage_be469, integration +): + """Test value_updated trigger when bypassing dynamic validation with no nodes.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + + no_value_filter = async_capture_events(hass, "no_value_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.value_updated.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # no value filter + { + "trigger": { + "platform": trigger_type, + "entity_id": "sensor.test", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + ] + }, + ) + + # Test that no value filter is NOT triggered because automation failed setup + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 0 + + async def test_zwave_js_event(hass, client, lock_schlage_be469, integration): """Test for zwave_js.event automation trigger.""" trigger_type = f"{DOMAIN}.event" @@ -644,6 +760,107 @@ async def test_zwave_js_event(hass, client, lock_schlage_be469, integration): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) +async def test_zwave_js_event_bypass_dynamic_validation( + hass, client, lock_schlage_be469, integration +): + """Test zwave_js.event trigger when bypassing dynamic config validation.""" + trigger_type = f"{DOMAIN}.event" + node: Node = lock_schlage_be469 + + node_no_event_data_filter = async_capture_events(hass, "node_no_event_data_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.event.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # node filter: no event data + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, + "action": { + "event": "node_no_event_data_filter", + }, + }, + ] + }, + ) + + # Test that `node no event data filter` is triggered and `node event data filter` is not + event = Event( + type="interview stage completed", + data={ + "source": "node", + "event": "interview stage completed", + "stageName": "NodeInfo", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(node_no_event_data_filter) == 1 + + +async def test_zwave_js_event_bypass_dynamic_validation_no_nodes( + hass, client, lock_schlage_be469, integration +): + """Test event trigger when bypassing dynamic validation with no nodes.""" + trigger_type = f"{DOMAIN}.event" + node: Node = lock_schlage_be469 + + node_no_event_data_filter = async_capture_events(hass, "node_no_event_data_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.event.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # node filter: no event data + { + "trigger": { + "platform": trigger_type, + "entity_id": "sensor.fake", + "event_source": "node", + "event": "interview stage completed", + }, + "action": { + "event": "node_no_event_data_filter", + }, + }, + ] + }, + ) + + # Test that `node no event data filter` is NOT triggered because automation failed + # setup + event = Event( + type="interview stage completed", + data={ + "source": "node", + "event": "interview stage completed", + "stageName": "NodeInfo", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(node_no_event_data_filter) == 0 + + async def test_zwave_js_event_invalid_config_entry_id( hass, client, integration, caplog ): From f8b7527bf0f526d2ccb42f1454b9b24a5dc44e06 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 May 2022 03:38:52 -0700 Subject: [PATCH 47/90] Allow removing a ring device (#72665) --- homeassistant/components/ring/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index a1ed1ac017b..0d8f87eef3c 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -16,6 +16,7 @@ from ring_doorbell import Auth, Ring from homeassistant.config_entries import ConfigEntry 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 @@ -146,6 +147,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + class GlobalDataUpdater: """Data storage for single API endpoint.""" From 6f01c13845e53c11498887374460aacbd1469f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 30 May 2022 03:14:43 +0200 Subject: [PATCH 48/90] Switch severity for gesture logging (#72668) --- homeassistant/components/nanoleaf/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 9e9cf1d6ca4..f6fb2f8112b 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -85,9 +85,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Receive touch event.""" gesture_type = TOUCH_GESTURE_TRIGGER_MAP.get(event.gesture_id) if gesture_type is None: - _LOGGER.debug("Received unknown touch gesture ID %s", event.gesture_id) + _LOGGER.warning( + "Received unknown touch gesture ID %s", event.gesture_id + ) return - _LOGGER.warning("Received touch gesture %s", gesture_type) + _LOGGER.debug("Received touch gesture %s", gesture_type) hass.bus.async_fire( NANOLEAF_EVENT, {CONF_DEVICE_ID: device_entry.id, CONF_TYPE: gesture_type}, From 952433d16e4149a6cbb6236d3f491b866e7bb271 Mon Sep 17 00:00:00 2001 From: shbatm Date: Sun, 29 May 2022 11:00:18 -0500 Subject: [PATCH 49/90] Check ISY994 climate for unknown humidity on Z-Wave Thermostat (#72670) --- homeassistant/components/isy994/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 1276207f23c..d68395f14da 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -6,6 +6,7 @@ from typing import Any from pyisy.constants import ( CMD_CLIMATE_FAN_SETTING, CMD_CLIMATE_MODE, + ISY_VALUE_UNKNOWN, PROP_HEAT_COOL_STATE, PROP_HUMIDITY, PROP_SETPOINT_COOL, @@ -116,6 +117,8 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): """Return the current humidity.""" if not (humidity := self._node.aux_properties.get(PROP_HUMIDITY)): return None + if humidity == ISY_VALUE_UNKNOWN: + return None return int(humidity.value) @property From 67ef3229fd11148d4f66712e412ee4102987515d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 May 2022 20:57:47 +0200 Subject: [PATCH 50/90] Address late review comments for Tankerkoenig (#72672) * address late review comment from #72654 * use entry_id instead of unique_id * remove not needed `_hass` property * fix skiping failing stations * remove not neccessary error log * set DeviceEntryType.SERVICE * fix use entry_id instead of unique_id * apply suggestions on tests * add return value also to other tests * invert data check to early return user form --- .../components/tankerkoenig/__init__.py | 45 +++++++++++++------ .../components/tankerkoenig/binary_sensor.py | 18 +++----- .../components/tankerkoenig/config_flow.py | 19 ++++---- .../components/tankerkoenig/sensor.py | 18 ++------ .../tankerkoenig/test_config_flow.py | 9 ++-- 5 files changed, 55 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 08520c8f5cc..e63add83fad 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + ATTR_ID, CONF_API_KEY, CONF_LATITUDE, CONF_LOCATION, @@ -24,8 +25,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( CONF_FUEL_TYPES, @@ -109,9 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][ - entry.unique_id - ] = coordinator = TankerkoenigDataUpdateCoordinator( + hass.data[DOMAIN][entry.entry_id] = coordinator = TankerkoenigDataUpdateCoordinator( hass, entry, _LOGGER, @@ -140,7 +145,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Tankerkoenig config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.unique_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -172,7 +177,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): self._api_key: str = entry.data[CONF_API_KEY] self._selected_stations: list[str] = entry.data[CONF_STATIONS] - self._hass = hass self.stations: dict[str, dict] = {} self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] @@ -195,7 +199,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): station_id, station_data["message"], ) - return False + continue self.add_station(station_data["station"]) if len(self.stations) > 10: _LOGGER.warning( @@ -215,7 +219,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): # The API seems to only return at most 10 results, so split the list in chunks of 10 # and merge it together. for index in range(ceil(len(station_ids) / 10)): - data = await self._hass.async_add_executor_job( + data = await self.hass.async_add_executor_job( pytankerkoenig.getPriceList, self._api_key, station_ids[index * 10 : (index + 1) * 10], @@ -223,13 +227,11 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.debug("Received data: %s", data) if not data["ok"]: - _LOGGER.error( - "Error fetching data from tankerkoenig.de: %s", data["message"] - ) raise UpdateFailed(data["message"]) if "prices" not in data: - _LOGGER.error("Did not receive price information from tankerkoenig.de") - raise UpdateFailed("No prices in data") + raise UpdateFailed( + "Did not receive price information from tankerkoenig.de" + ) prices.update(data["prices"]) return prices @@ -244,3 +246,20 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): self.stations[station_id] = station _LOGGER.debug("add_station called for station: %s", station) + + +class TankerkoenigCoordinatorEntity(CoordinatorEntity): + """Tankerkoenig base entity.""" + + def __init__( + self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict + ) -> None: + """Initialize the Tankerkoenig base entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(ATTR_ID, station["id"])}, + name=f"{station['brand']} {station['street']} {station['houseNumber']}", + model=station["brand"], + configuration_url="https://www.tankerkoenig.de", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 9a2b048e0b8..5f10b54f704 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -8,13 +8,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TankerkoenigDataUpdateCoordinator +from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -25,7 +23,7 @@ async def async_setup_entry( ) -> None: """Set up the tankerkoenig binary sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.unique_id] + coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] stations = coordinator.stations.values() entities = [] @@ -41,7 +39,7 @@ async def async_setup_entry( async_add_entities(entities) -class StationOpenBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): +class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity): """Shows if a station is open or closed.""" _attr_device_class = BinarySensorDeviceClass.DOOR @@ -53,18 +51,12 @@ class StationOpenBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): show_on_map: bool, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, station) self._station_id = station["id"] self._attr_name = ( f"{station['brand']} {station['street']} {station['houseNumber']} status" ) self._attr_unique_id = f"{station['id']}_status" - self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], - configuration_url="https://www.tankerkoenig.de", - ) if show_on_map: self._attr_extra_state_attributes = { ATTR_LATITUDE: station["lat"], diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index af3b5273b16..345b034b027 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Tankerkoenig.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytankerkoenig import customException, getNearbyStations @@ -30,7 +31,7 @@ from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_ async def async_get_nearby_stations( - hass: HomeAssistant, data: dict[str, Any] + hass: HomeAssistant, data: Mapping[str, Any] ) -> dict[str, Any]: """Fetch nearby stations.""" try: @@ -114,14 +115,12 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self._show_form_user( user_input, errors={CONF_API_KEY: "invalid_auth"} ) - if stations := data.get("stations"): - for station in stations: - self._stations[ - station["id"] - ] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)" - - else: + if len(stations := data.get("stations", [])) == 0: return self._show_form_user(user_input, errors={CONF_RADIUS: "no_stations"}) + for station in stations: + self._stations[ + station["id"] + ] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)" self._data = user_input @@ -180,7 +179,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_RADIUS, default=user_input.get(CONF_RADIUS, DEFAULT_RADIUS) ): NumberSelector( NumberSelectorConfig( - min=0.1, + min=1.0, max=25, step=0.1, unit_of_measurement=LENGTH_KILOMETERS, @@ -224,7 +223,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return self.async_create_entry(title="", data=user_input) nearby_stations = await async_get_nearby_stations( - self.hass, dict(self.config_entry.data) + self.hass, self.config_entry.data ) if stations := nearby_stations.get("stations"): for station in stations: diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 898a38c3c14..c63b0ea0e7e 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -7,17 +7,14 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TankerkoenigDataUpdateCoordinator +from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import ( ATTR_BRAND, ATTR_CITY, @@ -39,7 +36,7 @@ async def async_setup_entry( ) -> None: """Set up the tankerkoenig sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.unique_id] + coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] stations = coordinator.stations.values() entities = [] @@ -62,7 +59,7 @@ async def async_setup_entry( async_add_entities(entities) -class FuelPriceSensor(CoordinatorEntity, SensorEntity): +class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): """Contains prices for fuel in a given station.""" _attr_state_class = SensorStateClass.MEASUREMENT @@ -70,19 +67,12 @@ class FuelPriceSensor(CoordinatorEntity, SensorEntity): def __init__(self, fuel_type, station, coordinator, show_on_map): """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, station) self._station_id = station["id"] self._fuel_type = fuel_type self._attr_name = f"{station['brand']} {station['street']} {station['houseNumber']} {FUEL_TYPES[fuel_type]}" self._attr_native_unit_of_measurement = CURRENCY_EURO self._attr_unique_id = f"{station['id']}_{fuel_type}" - self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], - configuration_url="https://www.tankerkoenig.de", - ) - attrs = { ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_BRAND: station["brand"], diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index b18df0eed24..f48a09fd64b 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -95,7 +95,7 @@ async def test_user(hass: HomeAssistant): assert result["step_id"] == "user" with patch( - "homeassistant.components.tankerkoenig.async_setup_entry" + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", return_value=MOCK_NEARVY_STATIONS_OK, @@ -147,6 +147,7 @@ async def test_user_already_configured(hass: HomeAssistant): ) assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_exception_security(hass: HomeAssistant): @@ -193,7 +194,7 @@ async def test_user_no_stations(hass: HomeAssistant): async def test_import(hass: HomeAssistant): """Test starting a flow by import.""" with patch( - "homeassistant.components.tankerkoenig.async_setup_entry" + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", return_value=MOCK_NEARVY_STATIONS_OK, @@ -233,12 +234,12 @@ async def test_options_flow(hass: HomeAssistant): mock_config.add_to_hass(hass) with patch( - "homeassistant.components.tankerkoenig.async_setup_entry" + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", return_value=MOCK_NEARVY_STATIONS_OK, ): - await mock_config.async_setup(hass) + await hass.config_entries.async_setup(mock_config.entry_id) await hass.async_block_till_done() assert mock_setup_entry.called From 2942986a7b08571c3b90e0bece7fb37b02072060 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Mon, 30 May 2022 08:52:58 +0200 Subject: [PATCH 51/90] Bump bimmer_connected to 0.9.3 (#72677) Bump bimmer_connected to 0.9.3, fix retrieved units Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/coordinator.py | 3 ++- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- homeassistant/components/bmw_connected_drive/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index cff532ae3cb..47d1f358686 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -6,7 +6,7 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.vehicle.models import GPSPosition +from bimmer_connected.models import GPSPosition from httpx import HTTPError, TimeoutException from homeassistant.config_entries import ConfigEntry @@ -32,6 +32,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), + use_metric_units=hass.config.units.is_metric, ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c7130d12698..75ac3e982e8 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.2"], + "requirements": ["bimmer_connected==0.9.3"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 3021e180158..9f19673c398 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -6,8 +6,8 @@ from dataclasses import dataclass import logging from typing import cast +from bimmer_connected.models import ValueWithUnit from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.models import ValueWithUnit from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index 63f0f929c36..7ecc9b6769c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -394,7 +394,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.2 +bimmer_connected==0.9.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb5f6a242e5..eaf81f1b07f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.2 +bimmer_connected==0.9.3 # homeassistant.components.blebox blebox_uniapi==1.3.3 From da7446bf520a9d6cfcf282fac5810f6f1b685111 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 May 2022 11:40:36 +0200 Subject: [PATCH 52/90] Bump hatasmota to 0.5.1 (#72696) --- .../components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_cover.py | 45 ++++++++++++------- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 772105043fe..4268c4198b2 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -3,7 +3,7 @@ "name": "Tasmota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tasmota", - "requirements": ["hatasmota==0.5.0"], + "requirements": ["hatasmota==0.5.1"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"], diff --git a/requirements_all.txt b/requirements_all.txt index 7ecc9b6769c..e15aff574d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -795,7 +795,7 @@ hass-nabucasa==0.54.0 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.5.0 +hatasmota==0.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaf81f1b07f..773091643a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -571,7 +571,7 @@ hangups==0.4.18 hass-nabucasa==0.54.0 # homeassistant.components.tasmota -hatasmota==0.5.0 +hatasmota==0.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index 843fd72ecf1..06471e11757 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -111,7 +111,7 @@ async def test_tilt_support(hass, mqtt_mock, setup_tasmota): assert state.attributes["supported_features"] == COVER_SUPPORT -async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): +async def test_controlling_state_via_mqtt_tilt(hass, mqtt_mock, setup_tasmota): """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 3 @@ -281,7 +281,10 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert state.attributes["current_position"] == 100 -async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmota): +@pytest.mark.parametrize("tilt", ("", ',"Tilt":0')) +async def test_controlling_state_via_mqtt_inverted( + hass, mqtt_mock, setup_tasmota, tilt +): """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 3 @@ -310,7 +313,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":54,"Direction":-1}}', + '{"Shutter1":{"Position":54,"Direction":-1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" @@ -319,21 +322,25 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":100,"Direction":1}}', + '{"Shutter1":{"Position":100,"Direction":1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" assert state.attributes["current_position"] == 0 async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"Shutter1":{"Position":0,"Direction":0}}' + hass, + "tasmota_49A3BC/tele/SENSOR", + '{"Shutter1":{"Position":0,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" assert state.attributes["current_position"] == 100 async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"Shutter1":{"Position":99,"Direction":0}}' + hass, + "tasmota_49A3BC/tele/SENSOR", + '{"Shutter1":{"Position":99,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -342,7 +349,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":100,"Direction":0}}', + '{"Shutter1":{"Position":100,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" @@ -352,7 +359,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":54,"Direction":-1}}}', + '{"StatusSNS":{"Shutter1":{"Position":54,"Direction":-1' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" @@ -361,7 +368,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":1}}}', + '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":1' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" @@ -370,7 +377,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":0,"Direction":0}}}', + '{"StatusSNS":{"Shutter1":{"Position":0,"Direction":0' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -379,7 +386,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":99,"Direction":0}}}', + '{"StatusSNS":{"Shutter1":{"Position":99,"Direction":0' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -388,7 +395,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":0}}}', + '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":0' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" @@ -398,7 +405,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":54,"Direction":-1}}', + '{"Shutter1":{"Position":54,"Direction":-1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" @@ -407,21 +414,25 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":100,"Direction":1}}', + '{"Shutter1":{"Position":100,"Direction":1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" assert state.attributes["current_position"] == 0 async_fire_mqtt_message( - hass, "tasmota_49A3BC/stat/RESULT", '{"Shutter1":{"Position":0,"Direction":0}}' + hass, + "tasmota_49A3BC/stat/RESULT", + '{"Shutter1":{"Position":0,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" assert state.attributes["current_position"] == 100 async_fire_mqtt_message( - hass, "tasmota_49A3BC/stat/RESULT", '{"Shutter1":{"Position":1,"Direction":0}}' + hass, + "tasmota_49A3BC/stat/RESULT", + '{"Shutter1":{"Position":1,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -430,7 +441,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":100,"Direction":0}}', + '{"Shutter1":{"Position":100,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" From 2809592e71729473aeca358a5b7678f2a09de213 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 May 2022 14:21:20 +0200 Subject: [PATCH 53/90] Improve handling of MQTT overridden settings (#72698) * Improve handling of MQTT overridden settings * Don't warn unless config entry overrides yaml --- homeassistant/components/mqtt/__init__.py | 13 +++++++------ tests/components/mqtt/test_init.py | 5 ----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 78f64387435..1728dd7f2c7 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -685,14 +685,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # User has configuration.yaml config, warn about config entry overrides elif any(key in conf for key in entry.data): shared_keys = conf.keys() & entry.data.keys() - override = {k: entry.data[k] for k in shared_keys} + override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]} if CONF_PASSWORD in override: override[CONF_PASSWORD] = "********" - _LOGGER.warning( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override: %s", - override, - ) + if override: + _LOGGER.warning( + "Deprecated configuration settings found in configuration.yaml. " + "These settings from your configuration entry will override: %s", + override, + ) # Merge advanced configuration values from configuration.yaml conf = _merge_extended_config(entry, conf) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a370bd67ec1..07c39d70df0 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1715,11 +1715,6 @@ async def test_update_incomplete_entry( "The 'broker' option is deprecated, please remove it from your configuration" in caplog.text ) - assert ( - "Deprecated configuration settings found in configuration.yaml. These settings " - "from your configuration entry will override: {'broker': 'yaml_broker'}" - in caplog.text - ) # Discover a device to verify the entry was setup correctly async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) From 72a79736a6b2cef5e44719e15af886c909308a1f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 May 2022 14:40:55 -0700 Subject: [PATCH 54/90] Bumped version to 2022.6.0b4 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8d2bcd33f4e..fe454dbc8a1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index db64c7330ce..19aefbb6f90 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b3 +version = 2022.6.0b4 url = https://www.home-assistant.io/ [options] From 77e4c86c079c65327c0761e8d703cc9d91fa146f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 May 2022 20:41:05 -0700 Subject: [PATCH 55/90] Add support for announce to play_media (#72566) --- .../components/media_player/__init__.py | 4 +++- .../components/media_player/const.py | 1 + .../components/media_player/services.yaml | 7 ++++++ homeassistant/components/tts/__init__.py | 2 ++ tests/components/media_player/test_init.py | 23 +++++++++++++++++++ tests/components/tts/test_init.py | 2 ++ 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f71f3fc2a1f..dc2f3624a0e 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -76,6 +76,7 @@ from .const import ( # noqa: F401 ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_ARTIST, ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, @@ -182,9 +183,10 @@ DEVICE_CLASS_RECEIVER = MediaPlayerDeviceClass.RECEIVER.value MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, - vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Any( + vol.Exclusive(ATTR_MEDIA_ENQUEUE, "enqueue_announce"): vol.Any( cv.boolean, vol.Coerce(MediaPlayerEnqueue) ), + vol.Exclusive(ATTR_MEDIA_ANNOUNCE, "enqueue_announce"): cv.boolean, vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict, } diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index b12f0c4ae01..4d534467ad6 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -10,6 +10,7 @@ ATTR_ENTITY_PICTURE_LOCAL = "entity_picture_local" ATTR_GROUP_MEMBERS = "group_members" ATTR_INPUT_SOURCE = "source" ATTR_INPUT_SOURCE_LIST = "source_list" +ATTR_MEDIA_ANNOUNCE = "announce" ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist" ATTR_MEDIA_ALBUM_NAME = "media_album_name" ATTR_MEDIA_ARTIST = "media_artist" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index b2a8ac40262..b698b87aec6 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -166,6 +166,13 @@ play_media: value: "add" - label: "Play now and clear queue" value: "replace" + announce: + name: Announce + description: If the media should be played as an announcement. + required: false + example: "true" + selector: + boolean: select_source: name: Select source diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 4628ec8768a..706122c174c 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -21,6 +21,7 @@ import yarl from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP, @@ -224,6 +225,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: str(yarl.URL.build(path=p_type, query=params)), ), ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_ANNOUNCE: True, }, blocking=True, context=service.context, diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index cb095cbcfe0..eceb7e9ec4f 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from unittest.mock import patch import pytest +import voluptuous as vol from homeassistant.components import media_player from homeassistant.components.media_player.browse_media import BrowseMedia @@ -291,3 +292,25 @@ async def test_enqueue_rewrite(hass, input, expected): assert len(mock_play_media.mock_calls) == 1 assert mock_play_media.mock_calls[0][2]["enqueue"] == expected + + +async def test_enqueue_alert_exclusive(hass): + """Test that alert and enqueue cannot be used together.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media_content_type": "music", + "media_content_id": "1234", + "enqueue": "play", + "announce": True, + }, + blocking=True, + ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 78fa49a8fc9..7b348489059 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components import media_source, tts from homeassistant.components.demo.tts import DemoProvider from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP, @@ -91,6 +92,7 @@ async def test_setup_component_and_test_service(hass, empty_cache_dir): ) assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_ANNOUNCE] is True assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) From a202ffe4c10b8092489b584396f142d78cdd757b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 14:34:32 -1000 Subject: [PATCH 56/90] Make logbook inherit the recorder filter (#72728) --- homeassistant/components/logbook/__init__.py | 16 +- homeassistant/components/recorder/filters.py | 46 ++++- .../components/logbook/test_websocket_api.py | 192 +++++++++++++++++- tests/components/recorder/test_filters.py | 114 +++++++++++ 4 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 tests/components/recorder/test_filters.py diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index f66f1d5e920..1abfcaba6ff 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -7,7 +7,10 @@ from typing import Any import voluptuous as vol from homeassistant.components import frontend +from homeassistant.components.recorder.const import DOMAIN as RECORDER_DOMAIN from homeassistant.components.recorder.filters import ( + extract_include_exclude_filter_conf, + merge_include_exclude_filters, sqlalchemy_filter_from_include_exclude_conf, ) from homeassistant.const import ( @@ -115,9 +118,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, "logbook", "logbook", "hass:format-list-bulleted-type" ) - if conf := config.get(DOMAIN, {}): - filters = sqlalchemy_filter_from_include_exclude_conf(conf) - entities_filter = convert_include_exclude_filter(conf) + recorder_conf = config.get(RECORDER_DOMAIN, {}) + logbook_conf = config.get(DOMAIN, {}) + recorder_filter = extract_include_exclude_filter_conf(recorder_conf) + logbook_filter = extract_include_exclude_filter_conf(logbook_conf) + merged_filter = merge_include_exclude_filters(recorder_filter, logbook_filter) + + possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) + if not possible_merged_entities_filter.empty_filter: + filters = sqlalchemy_filter_from_include_exclude_conf(merged_filter) + entities_filter = possible_merged_entities_filter else: filters = None entities_filter = None diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 5dd1e4b7884..0ceb013d8c5 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -25,6 +25,40 @@ GLOB_TO_SQL_CHARS = { ord("\\"): "\\\\", } +FILTER_TYPES = (CONF_EXCLUDE, CONF_INCLUDE) +FITLER_MATCHERS = (CONF_ENTITIES, CONF_DOMAINS, CONF_ENTITY_GLOBS) + + +def extract_include_exclude_filter_conf(conf: ConfigType) -> dict[str, Any]: + """Extract an include exclude filter from configuration. + + This makes a copy so we do not alter the original data. + """ + return { + filter_type: { + matcher: set(conf.get(filter_type, {}).get(matcher, [])) + for matcher in FITLER_MATCHERS + } + for filter_type in FILTER_TYPES + } + + +def merge_include_exclude_filters( + base_filter: dict[str, Any], add_filter: dict[str, Any] +) -> dict[str, Any]: + """Merge two filters. + + This makes a copy so we do not alter the original data. + """ + return { + filter_type: { + matcher: base_filter[filter_type][matcher] + | add_filter[filter_type][matcher] + for matcher in FITLER_MATCHERS + } + for filter_type in FILTER_TYPES + } + def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None: """Build a sql filter from config.""" @@ -46,13 +80,13 @@ class Filters: def __init__(self) -> None: """Initialise the include and exclude filters.""" - self.excluded_entities: list[str] = [] - self.excluded_domains: list[str] = [] - self.excluded_entity_globs: list[str] = [] + self.excluded_entities: Iterable[str] = [] + self.excluded_domains: Iterable[str] = [] + self.excluded_entity_globs: Iterable[str] = [] - self.included_entities: list[str] = [] - self.included_domains: list[str] = [] - self.included_entity_globs: list[str] = [] + self.included_entities: Iterable[str] = [] + self.included_domains: Iterable[str] = [] + self.included_entity_globs: Iterable[str] = [] @property def has_config(self) -> bool: diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 9d7146ec96c..1d35d6d897d 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -483,7 +483,7 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( CONF_EXCLUDE: { CONF_ENTITIES: ["light.exc"], CONF_DOMAINS: ["switch"], - CONF_ENTITY_GLOBS: "*.excluded", + CONF_ENTITY_GLOBS: ["*.excluded"], } }, }, @@ -672,7 +672,7 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( CONF_INCLUDE: { CONF_ENTITIES: ["light.inc"], CONF_DOMAINS: ["switch"], - CONF_ENTITY_GLOBS: "*.included", + CONF_ENTITY_GLOBS: ["*.included"], } }, }, @@ -849,6 +849,194 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( assert sum(hass.bus.async_listeners().values()) == init_count +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream inherts filters from recorder.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "automation", "script") + ] + ) + await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: ["light.additional_excluded"], + } + }, + recorder.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: ["light.exc"], + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: ["*.excluded", "*.no_matches"], + } + }, + }, + ) + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + hass.states.async_set("light.additional_excluded", STATE_ON) + hass.states.async_set("light.additional_excluded", STATE_OFF) + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + state: State = hass.states.get("binary_sensor.is_light") + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": "off", + "when": state.last_updated.timestamp(), + } + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + hass.states.async_set("light.additional_excluded", STATE_ON) + hass.states.async_set("light.additional_excluded", STATE_OFF) + hass.states.async_set("light.alpha", "on") + hass.states.async_set("light.alpha", "off") + alpha_off_state: State = hass.states.get("light.alpha") + hass.states.async_set("light.zulu", "on", {"color": "blue"}) + hass.states.async_set("light.zulu", "off", {"effect": "help"}) + zulu_off_state: State = hass.states.get("light.zulu") + hass.states.async_set( + "light.zulu", "on", {"effect": "help", "color": ["blue", "green"]} + ) + zulu_on_state: State = hass.states.get("light.zulu") + await hass.async_block_till_done() + + hass.states.async_remove("light.zulu") + await hass.async_block_till_done() + + hass.states.async_set("light.zulu", "on", {"effect": "help", "color": "blue"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "light.alpha", + "state": "off", + "when": alpha_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "off", + "when": zulu_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "on", + "when": zulu_on_state.last_updated.timestamp(), + }, + ] + + await async_wait_recording_done(hass) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.excluded"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + { + ATTR_NAME: "Mock automation switch matching entity", + ATTR_ENTITY_ID: "switch.match_domain", + }, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation switch matching domain", ATTR_DOMAIN: "switch"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation matches nothing"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "light.keep"}, + ) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation matches nothing", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "light.keep", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( hass, recorder_mock, hass_ws_client diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py new file mode 100644 index 00000000000..fa80df6e345 --- /dev/null +++ b/tests/components/recorder/test_filters.py @@ -0,0 +1,114 @@ +"""The tests for recorder filters.""" + +from homeassistant.components.recorder.filters import ( + extract_include_exclude_filter_conf, + merge_include_exclude_filters, +) +from homeassistant.helpers.entityfilter import ( + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_GLOBS, + CONF_EXCLUDE, + CONF_INCLUDE, +) + +SIMPLE_INCLUDE_FILTER = { + CONF_INCLUDE: { + CONF_DOMAINS: ["homeassistant"], + CONF_ENTITIES: ["sensor.one"], + CONF_ENTITY_GLOBS: ["climate.*"], + } +} +SIMPLE_INCLUDE_FILTER_DIFFERENT_ENTITIES = { + CONF_INCLUDE: { + CONF_DOMAINS: ["other"], + CONF_ENTITIES: ["not_sensor.one"], + CONF_ENTITY_GLOBS: ["not_climate.*"], + } +} +SIMPLE_EXCLUDE_FILTER = { + CONF_EXCLUDE: { + CONF_DOMAINS: ["homeassistant"], + CONF_ENTITIES: ["sensor.one"], + CONF_ENTITY_GLOBS: ["climate.*"], + } +} +SIMPLE_INCLUDE_EXCLUDE_FILTER = {**SIMPLE_INCLUDE_FILTER, **SIMPLE_EXCLUDE_FILTER} + + +def test_extract_include_exclude_filter_conf(): + """Test we can extract a filter from configuration without altering it.""" + include_filter = extract_include_exclude_filter_conf(SIMPLE_INCLUDE_FILTER) + assert include_filter == { + CONF_EXCLUDE: { + CONF_DOMAINS: set(), + CONF_ENTITIES: set(), + CONF_ENTITY_GLOBS: set(), + }, + CONF_INCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + } + + exclude_filter = extract_include_exclude_filter_conf(SIMPLE_EXCLUDE_FILTER) + assert exclude_filter == { + CONF_INCLUDE: { + CONF_DOMAINS: set(), + CONF_ENTITIES: set(), + CONF_ENTITY_GLOBS: set(), + }, + CONF_EXCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + } + + include_exclude_filter = extract_include_exclude_filter_conf( + SIMPLE_INCLUDE_EXCLUDE_FILTER + ) + assert include_exclude_filter == { + CONF_INCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + CONF_EXCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + } + + include_exclude_filter[CONF_EXCLUDE][CONF_ENTITIES] = {"cover.altered"} + # verify it really is a copy + assert SIMPLE_INCLUDE_EXCLUDE_FILTER[CONF_EXCLUDE][CONF_ENTITIES] != { + "cover.altered" + } + + +def test_merge_include_exclude_filters(): + """Test we can merge two filters together.""" + include_exclude_filter_base = extract_include_exclude_filter_conf( + SIMPLE_INCLUDE_EXCLUDE_FILTER + ) + include_filter_add = extract_include_exclude_filter_conf( + SIMPLE_INCLUDE_FILTER_DIFFERENT_ENTITIES + ) + merged_filter = merge_include_exclude_filters( + include_exclude_filter_base, include_filter_add + ) + assert merged_filter == { + CONF_EXCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + CONF_INCLUDE: { + CONF_DOMAINS: {"other", "homeassistant"}, + CONF_ENTITIES: {"not_sensor.one", "sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*", "not_climate.*"}, + }, + } From a98528c93f52e717b324bb658d379e61cd0ca5a2 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 30 May 2022 22:56:59 -0500 Subject: [PATCH 57/90] Bump plexapi to 4.11.2 (#72729) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 2e2db01de77..912732efe98 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plex", "requirements": [ - "plexapi==4.11.1", + "plexapi==4.11.2", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/requirements_all.txt b/requirements_all.txt index e15aff574d5..544c16a58dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1236,7 +1236,7 @@ pillow==9.1.1 pizzapi==0.0.3 # homeassistant.components.plex -plexapi==4.11.1 +plexapi==4.11.2 # homeassistant.components.plex plexauth==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 773091643a4..58be8ff0459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -838,7 +838,7 @@ pilight==0.1.1 pillow==9.1.1 # homeassistant.components.plex -plexapi==4.11.1 +plexapi==4.11.2 # homeassistant.components.plex plexauth==0.0.6 From b842c76fbd59ed212b6ea85e18d569089c436486 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 31 May 2022 01:05:09 -0400 Subject: [PATCH 58/90] Bump zwave-js-server-python to 0.37.1 (#72731) Co-authored-by: Paulus Schoutsen --- homeassistant/components/zwave_js/api.py | 11 ++++------- homeassistant/components/zwave_js/climate.py | 2 +- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 5d81dc46803..5b8b95e951c 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1231,7 +1231,7 @@ async def websocket_replace_failed_node( try: result = await controller.async_replace_failed_node( - node_id, + controller.nodes[node_id], INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value], force_security=force_security, provisioning=provisioning, @@ -1290,11 +1290,8 @@ async def websocket_remove_failed_node( connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [controller.on("node removed", node_removed)] - result = await controller.async_remove_failed_node(node.node_id) - connection.send_result( - msg[ID], - result, - ) + await controller.async_remove_failed_node(node) + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -1416,7 +1413,7 @@ async def websocket_heal_node( assert driver is not None # The node comes from the driver instance. controller = driver.controller - result = await controller.async_heal_node(node.node_id) + result = await controller.async_heal_node(node) connection.send_result( msg[ID], result, diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 7b07eb09619..d8037643488 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -66,7 +66,7 @@ ZW_HVAC_MODE_MAP: dict[int, HVACMode] = { ThermostatMode.AUTO: HVACMode.HEAT_COOL, ThermostatMode.AUXILIARY: HVACMode.HEAT, ThermostatMode.FAN: HVACMode.FAN_ONLY, - ThermostatMode.FURNANCE: HVACMode.HEAT, + ThermostatMode.FURNACE: HVACMode.HEAT, ThermostatMode.DRY: HVACMode.DRY, ThermostatMode.AUTO_CHANGE_OVER: HVACMode.HEAT_COOL, ThermostatMode.HEATING_ECON: HVACMode.HEAT, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 555da5fe954..1c7eabb4e86 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.37.0"], + "requirements": ["zwave-js-server-python==0.37.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 544c16a58dd..dedebd00dc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2528,7 +2528,7 @@ zigpy==0.45.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.0 +zwave-js-server-python==0.37.1 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58be8ff0459..adbad6fa960 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1665,7 +1665,7 @@ zigpy-znp==0.7.0 zigpy==0.45.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.0 +zwave-js-server-python==0.37.1 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 From 15bdfb2a45a849f243282bc7b6f3b27812552070 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 17:48:42 -0600 Subject: [PATCH 59/90] Fix invalid RainMachine syntax (#72732) --- homeassistant/components/rainmachine/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index a220aafa2a5..8d339682305 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -393,7 +393,7 @@ class RainMachineZone(RainMachineActivitySwitch): ATTR_CURRENT_CYCLE: data["cycle"], ATTR_ID: data["uid"], ATTR_NO_CYCLES: data["noOfCycles"], - ATTR_RESTRICTIONS: data("restriction"), + ATTR_RESTRICTIONS: data["restriction"], ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99), ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99), ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99), From a4e2d31a199a6e5dd7e4fd01d3227911098c648c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 18:58:08 -0600 Subject: [PATCH 60/90] Bump regenmaschine to 2022.05.1 (#72735) --- 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 bbe58e263b1..98dc9a6c877 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==2022.05.0"], + "requirements": ["regenmaschine==2022.05.1"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index dedebd00dc8..464bf2997b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2065,7 +2065,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.05.0 +regenmaschine==2022.05.1 # homeassistant.components.renault renault-api==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index adbad6fa960..0686886aa9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,7 +1364,7 @@ rachiopy==1.0.3 radios==0.1.1 # homeassistant.components.rainmachine -regenmaschine==2022.05.0 +regenmaschine==2022.05.1 # homeassistant.components.renault renault-api==0.1.11 From 48d36e49f0f61608d0d3c379d146620f65fed32b Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 23:04:53 -0600 Subject: [PATCH 61/90] Bump simplisafe-python to 2022.05.2 (#72740) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index cb1b02e37ae..f62da735f92 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==2022.05.1"], + "requirements": ["simplisafe-python==2022.05.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 464bf2997b7..3203dd79f6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2168,7 +2168,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.05.1 +simplisafe-python==2022.05.2 # homeassistant.components.sisyphus sisyphus-control==3.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0686886aa9f..171ae908991 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1425,7 +1425,7 @@ sharkiq==0.0.1 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==2022.05.1 +simplisafe-python==2022.05.2 # homeassistant.components.slack slackclient==2.5.0 From 103f324c52199435eaef382a4c4943c458f6beb5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 May 2022 22:57:22 -0700 Subject: [PATCH 62/90] Bumped version to 2022.6.0b5 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fe454dbc8a1..bd49bb0e1e2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index 19aefbb6f90..3d52bda7738 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b4 +version = 2022.6.0b5 url = https://www.home-assistant.io/ [options] From 6e06b6c9ede838883c14737b7f6d35ef14082caa Mon Sep 17 00:00:00 2001 From: eyager1 <44526531+eyager1@users.noreply.github.com> Date: Mon, 30 May 2022 18:32:52 -0400 Subject: [PATCH 63/90] Add empty string to list of invalid states (#72590) Add null state to list of invalid states --- homeassistant/components/statistics/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index ac62b63e8ca..3f33fa015b9 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -350,7 +350,7 @@ class StatisticsSensor(SensorEntity): if new_state.state == STATE_UNAVAILABLE: self.attributes[STAT_SOURCE_VALUE_VALID] = None return - if new_state.state in (STATE_UNKNOWN, None): + if new_state.state in (STATE_UNKNOWN, None, ""): self.attributes[STAT_SOURCE_VALUE_VALID] = False return From 4bf5132a06fc2adb33a90ede356c22448662e20a Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 31 May 2022 16:35:29 +0200 Subject: [PATCH 64/90] SmartThings issue with unique_id (#72715) Co-authored-by: Jan Bouwhuis --- homeassistant/components/smartthings/smartapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 28b60b57447..fbd63d41373 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -405,7 +405,7 @@ async def _continue_flow( ( flow for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"]["unique_id"] == unique_id + if flow["context"].get("unique_id") == unique_id ), None, ) From 1b2cb4eab73db6164c1bcb82f501d3d3404872fa Mon Sep 17 00:00:00 2001 From: Khole Date: Tue, 31 May 2022 16:55:00 +0100 Subject: [PATCH 65/90] Fix hive authentication process (#72719) * Fix hive authentication process * Update hive test scripts to add new data --- homeassistant/components/hive/__init__.py | 7 ++- homeassistant/components/hive/config_flow.py | 1 + homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hive/test_config_flow.py | 60 +++++++++++++++++++- 6 files changed, 68 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 00c3a327578..292bbe62ae1 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -76,8 +76,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Hive from a config entry.""" websession = aiohttp_client.async_get_clientsession(hass) - hive = Hive(websession) hive_config = dict(entry.data) + hive = Hive( + websession, + deviceGroupKey=hive_config["device_data"][0], + deviceKey=hive_config["device_data"][1], + devicePassword=hive_config["device_data"][2], + ) hive_config["options"] = {} hive_config["options"].update( diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 2632a24e360..9c391f13294 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -103,6 +103,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Setup the config entry self.data["tokens"] = self.tokens + self.data["device_data"] = await self.hive_auth.getDeviceData() if self.context["source"] == config_entries.SOURCE_REAUTH: self.hass.config_entries.async_update_entry( self.entry, title=self.data["username"], data=self.data diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 19958b51bd7..472adc137ba 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -3,7 +3,7 @@ "name": "Hive", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.4.2"], + "requirements": ["pyhiveapi==0.5.4"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/requirements_all.txt b/requirements_all.txt index 3203dd79f6a..1821fb8bbc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1538,7 +1538,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.4.2 +pyhiveapi==0.5.4 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 171ae908991..1f89c83485b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1029,7 +1029,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.4.2 +pyhiveapi==0.5.4 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index ce13e52fe96..bb567b0bdfc 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -13,7 +13,7 @@ USERNAME = "username@home-assistant.com" UPDATED_USERNAME = "updated_username@home-assistant.com" PASSWORD = "test-password" UPDATED_PASSWORD = "updated-password" -INCORRECT_PASSWORD = "incoreect-password" +INCORRECT_PASSWORD = "incorrect-password" SCAN_INTERVAL = 120 UPDATED_SCAN_INTERVAL = 60 MFA_CODE = "1234" @@ -33,6 +33,13 @@ async def test_import_flow(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -57,6 +64,11 @@ async def test_import_flow(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 @@ -81,6 +93,13 @@ async def test_user_flow(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -105,6 +124,11 @@ async def test_user_flow(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(mock_setup.mock_calls) == 1 @@ -148,6 +172,13 @@ async def test_user_flow_2fa(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -171,6 +202,11 @@ async def test_user_flow_2fa(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(mock_setup.mock_calls) == 1 @@ -243,7 +279,15 @@ async def test_option_flow(hass): entry = MockConfigEntry( domain=DOMAIN, title=USERNAME, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], + }, ) entry.add_to_hass(hass) @@ -317,6 +361,13 @@ async def test_user_flow_2fa_send_new_code(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -340,6 +391,11 @@ async def test_user_flow_2fa_send_new_code(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 From 7c2f73ddbaf4ade60be3206e4abcc672a432ec61 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 31 May 2022 13:09:07 -0600 Subject: [PATCH 66/90] Alter RainMachine to not create entities if the underlying data is missing (#72733) --- .coveragerc | 1 + .../components/rainmachine/__init__.py | 3 -- .../components/rainmachine/binary_sensor.py | 44 ++++++++----------- homeassistant/components/rainmachine/model.py | 1 + .../components/rainmachine/sensor.py | 32 ++++++-------- homeassistant/components/rainmachine/util.py | 14 ++++++ 6 files changed, 49 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/rainmachine/util.py diff --git a/.coveragerc b/.coveragerc index 62e6d3cb94e..9e2c1e8c678 100644 --- a/.coveragerc +++ b/.coveragerc @@ -965,6 +965,7 @@ omit = homeassistant/components/rainmachine/model.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py + homeassistant/components/rainmachine/util.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/__init__.py homeassistant/components/recollect_waste/sensor.py diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index d212f1638b4..6d51be9d921 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -50,10 +50,7 @@ from .const import ( LOGGER, ) -DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC" -DEFAULT_ICON = "mdi:water" DEFAULT_SSL = True -DEFAULT_UPDATE_INTERVAL = timedelta(seconds=15) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 730b51c142a..1818222a8f4 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -1,6 +1,5 @@ """This platform provides binary sensors for key RainMachine data.""" from dataclasses import dataclass -from functools import partial from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -21,6 +20,7 @@ from .const import ( DOMAIN, ) from .model import RainMachineDescriptionMixinApiCategory +from .util import key_exists TYPE_FLOW_SENSOR = "flow_sensor" TYPE_FREEZE = "freeze" @@ -46,6 +46,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Flow Sensor", icon="mdi:water-pump", api_category=DATA_PROVISION_SETTINGS, + data_key="useFlowSensor", ), RainMachineBinarySensorDescription( key=TYPE_FREEZE, @@ -53,6 +54,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="freeze", ), RainMachineBinarySensorDescription( key=TYPE_FREEZE_PROTECTION, @@ -60,6 +62,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( icon="mdi:weather-snowy", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_UNIVERSAL, + data_key="freezeProtectEnabled", ), RainMachineBinarySensorDescription( key=TYPE_HOT_DAYS, @@ -67,6 +70,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( icon="mdi:thermometer-lines", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_UNIVERSAL, + data_key="hotDaysExtraWatering", ), RainMachineBinarySensorDescription( key=TYPE_HOURLY, @@ -75,6 +79,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="hourly", ), RainMachineBinarySensorDescription( key=TYPE_MONTH, @@ -83,6 +88,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="month", ), RainMachineBinarySensorDescription( key=TYPE_RAINDELAY, @@ -91,6 +97,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="rainDelay", ), RainMachineBinarySensorDescription( key=TYPE_RAINSENSOR, @@ -99,6 +106,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="rainSensor", ), RainMachineBinarySensorDescription( key=TYPE_WEEKDAY, @@ -107,6 +115,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="weekDay", ), ) @@ -118,35 +127,20 @@ async def async_setup_entry( controller = hass.data[DOMAIN][entry.entry_id][DATA_CONTROLLER] coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - @callback - def async_get_sensor_by_api_category(api_category: str) -> partial: - """Generate the appropriate sensor object for an API category.""" - if api_category == DATA_PROVISION_SETTINGS: - return partial( - ProvisionSettingsBinarySensor, - entry, - coordinators[DATA_PROVISION_SETTINGS], - ) - - if api_category == DATA_RESTRICTIONS_CURRENT: - return partial( - CurrentRestrictionsBinarySensor, - entry, - coordinators[DATA_RESTRICTIONS_CURRENT], - ) - - return partial( - UniversalRestrictionsBinarySensor, - entry, - coordinators[DATA_RESTRICTIONS_UNIVERSAL], - ) + api_category_sensor_map = { + DATA_PROVISION_SETTINGS: ProvisionSettingsBinarySensor, + DATA_RESTRICTIONS_CURRENT: CurrentRestrictionsBinarySensor, + DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsBinarySensor, + } async_add_entities( [ - async_get_sensor_by_api_category(description.api_category)( - controller, description + api_category_sensor_map[description.api_category]( + entry, coordinator, controller, description ) for description in BINARY_SENSOR_DESCRIPTIONS + if (coordinator := coordinators[description.api_category]) is not None + and key_exists(coordinator.data, description.data_key) ] ) diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index 9f638d486aa..680a47c5d42 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -7,6 +7,7 @@ class RainMachineDescriptionMixinApiCategory: """Define an entity description mixin for binary and regular sensors.""" api_category: str + data_key: str @dataclass diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index a2b0f7cd539..522c57cf7a2 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta -from functools import partial from homeassistant.components.sensor import ( SensorDeviceClass, @@ -33,6 +32,7 @@ from .model import ( RainMachineDescriptionMixinApiCategory, RainMachineDescriptionMixinUid, ) +from .util import key_exists DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE = timedelta(seconds=5) @@ -68,6 +68,7 @@ SENSOR_DESCRIPTIONS = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorClicksPerCubicMeter", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, @@ -78,6 +79,7 @@ SENSOR_DESCRIPTIONS = ( entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorWateringClicks", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_START_INDEX, @@ -87,6 +89,7 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement="index", entity_registry_enabled_default=False, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorStartIndex", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_WATERING_CLICKS, @@ -97,6 +100,7 @@ SENSOR_DESCRIPTIONS = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorWateringClicks", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FREEZE_TEMP, @@ -107,6 +111,7 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, api_category=DATA_RESTRICTIONS_UNIVERSAL, + data_key="freezeProtectTemp", ), ) @@ -118,27 +123,18 @@ async def async_setup_entry( controller = hass.data[DOMAIN][entry.entry_id][DATA_CONTROLLER] coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - @callback - def async_get_sensor_by_api_category(api_category: str) -> partial: - """Generate the appropriate sensor object for an API category.""" - if api_category == DATA_PROVISION_SETTINGS: - return partial( - ProvisionSettingsSensor, - entry, - coordinators[DATA_PROVISION_SETTINGS], - ) - - return partial( - UniversalRestrictionsSensor, - entry, - coordinators[DATA_RESTRICTIONS_UNIVERSAL], - ) + api_category_sensor_map = { + DATA_PROVISION_SETTINGS: ProvisionSettingsSensor, + DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsSensor, + } sensors = [ - async_get_sensor_by_api_category(description.api_category)( - controller, description + api_category_sensor_map[description.api_category]( + entry, coordinator, controller, description ) for description in SENSOR_DESCRIPTIONS + if (coordinator := coordinators[description.api_category]) is not None + and key_exists(coordinator.data, description.data_key) ] zone_coordinator = coordinators[DATA_ZONES] diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py new file mode 100644 index 00000000000..27a0636688e --- /dev/null +++ b/homeassistant/components/rainmachine/util.py @@ -0,0 +1,14 @@ +"""Define RainMachine utilities.""" +from __future__ import annotations + +from typing import Any + + +def key_exists(data: dict[str, Any], search_key: str) -> bool: + """Return whether a key exists in a nested dict.""" + for key, value in data.items(): + if key == search_key: + return True + if isinstance(value, dict): + return key_exists(value, search_key) + return False From ca8c750a5aa4bdb2ce52b051314e56b6766f3844 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 22:41:33 -1000 Subject: [PATCH 67/90] Small performance improvement for matching logbook rows (#72750) --- homeassistant/components/logbook/processor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index ea6002cc62c..b3a43c2ca35 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -407,7 +407,8 @@ class ContextAugmenter: def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool: """Check of rows match by using the same method as Events __hash__.""" if ( - (state_id := row.state_id) is not None + row is other_row + or (state_id := row.state_id) is not None and state_id == other_row.state_id or (event_id := row.event_id) is not None and event_id == other_row.event_id From 6b3a284135a9712379b5c11cd3a3c8a3236d3f95 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Wed, 1 Jun 2022 04:34:52 +1000 Subject: [PATCH 68/90] Make zone condition more robust by ignoring unavailable and unknown entities (#72751) * ignore entities with state unavailable or unknown * test for unavailable entity --- homeassistant/helpers/condition.py | 6 +++ tests/components/geo_location/test_trigger.py | 42 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 8985b7b721c..a628cdefff4 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -829,6 +829,12 @@ def zone( else: entity_id = entity.entity_id + if entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + return False + latitude = entity.attributes.get(ATTR_LATITUDE) longitude = entity.attributes.get(ATTR_LONGITUDE) diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index bbf5f42ed60..de6276545b7 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -4,7 +4,12 @@ import logging import pytest from homeassistant.components import automation, zone -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_OFF, + STATE_UNAVAILABLE, +) from homeassistant.core import Context from homeassistant.setup import async_setup_component @@ -189,6 +194,41 @@ async def test_if_fires_on_zone_leave(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_zone_leave_2(hass, calls): + """Test for firing on zone leave for unavailable entity.""" + hass.states.async_set( + "geo_location.entity", + "hello", + {"latitude": 32.880586, "longitude": -117.237564, "source": "test_source"}, + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "geo_location", + "source": "test_source", + "zone": "zone.test", + "event": "enter", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.states.async_set( + "geo_location.entity", + STATE_UNAVAILABLE, + {"source": "test_source"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + async def test_if_not_fires_for_leave_on_zone_enter(hass, calls): """Test for not firing on zone enter.""" hass.states.async_set( From 82ed6869d05bfaa38214b2e6de80f24406928b35 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 May 2022 15:51:38 +0200 Subject: [PATCH 69/90] Improve integration sensor's time unit handling (#72759) --- .../components/integration/sensor.py | 17 +++++-- tests/components/integration/test_sensor.py | 46 ++++++++++++++++++- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 5c982c6ec5e..5d0dde3e4de 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -154,17 +154,26 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._method = integration_method self._attr_name = name if name is not None else f"{source_entity} integral" - self._unit_template = ( - f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}" - ) + self._unit_template = f"{'' if unit_prefix is None else unit_prefix}{{}}" self._unit_of_measurement = None self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] + self._unit_time_str = unit_time self._attr_state_class = SensorStateClass.TOTAL self._attr_icon = "mdi:chart-histogram" self._attr_should_poll = False self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} + def _unit(self, source_unit: str) -> str: + """Derive unit from the source sensor, SI prefix and time unit.""" + unit_time = self._unit_time_str + if source_unit.endswith(f"/{unit_time}"): + integral_unit = source_unit[0 : (-(1 + len(unit_time)))] + else: + integral_unit = f"{source_unit}{unit_time}" + + return self._unit_template.format(integral_unit) + async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() @@ -203,7 +212,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): update_state = False unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if unit is not None: - new_unit_of_measurement = self._unit_template.format(unit) + new_unit_of_measurement = self._unit(unit) if self._unit_of_measurement != new_unit_of_measurement: self._unit_of_measurement = new_unit_of_measurement update_state = True diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index e4173d62eb4..8999c1f8d04 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -5,12 +5,15 @@ from unittest.mock import patch from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + DATA_KILOBYTES, + DATA_RATE_BYTES_PER_SECOND, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, POWER_KILO_WATT, POWER_WATT, STATE_UNAVAILABLE, STATE_UNKNOWN, + TIME_HOURS, TIME_SECONDS, ) from homeassistant.core import HomeAssistant, State @@ -300,7 +303,9 @@ async def test_suffix(hass): assert await async_setup_component(hass, "sensor", config) entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 1000, {ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT}) + hass.states.async_set( + entity_id, 1000, {ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_BYTES_PER_SECOND} + ) await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) @@ -308,7 +313,7 @@ async def test_suffix(hass): hass.states.async_set( entity_id, 1000, - {ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_BYTES_PER_SECOND}, force_update=True, ) await hass.async_block_till_done() @@ -318,6 +323,43 @@ async def test_suffix(hass): # Testing a network speed sensor at 1000 bytes/s over 10s = 10kbytes assert round(float(state.state)) == 10 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == DATA_KILOBYTES + + +async def test_suffix_2(hass): + """Test integration sensor state.""" + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.cubic_meters_per_hour", + "round": 2, + "unit_time": TIME_HOURS, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + hass.states.async_set(entity_id, 1000, {ATTR_UNIT_OF_MEASUREMENT: "m³/h"}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(hours=1) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 1000, + {ATTR_UNIT_OF_MEASUREMENT: "m³/h"}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + + # Testing a flow sensor at 1000 m³/h over 1h = 1000 m³ + assert round(float(state.state)) == 1000 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "m³" async def test_units(hass): From d268c828ee87db85867936c931c5c6e8bf22d78a Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 31 May 2022 15:24:35 -0400 Subject: [PATCH 70/90] Bump ZHA quirks lib to 0.0.75 (#72765) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 8d6e6162d76..4f16b1c113e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,7 +7,7 @@ "bellows==0.30.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.74", + "zha-quirks==0.0.75", "zigpy-deconz==0.16.0", "zigpy==0.45.1", "zigpy-xbee==0.14.0", diff --git a/requirements_all.txt b/requirements_all.txt index 1821fb8bbc9..d9fd7a6a34e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2501,7 +2501,7 @@ zengge==0.2 zeroconf==0.38.6 # homeassistant.components.zha -zha-quirks==0.0.74 +zha-quirks==0.0.75 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f89c83485b..52e6d0a0ea9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1647,7 +1647,7 @@ youless-api==0.16 zeroconf==0.38.6 # homeassistant.components.zha -zha-quirks==0.0.74 +zha-quirks==0.0.75 # homeassistant.components.zha zigpy-deconz==0.16.0 From f4d280b59db28dda196a93b69112368de5e9d24e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 May 2022 20:30:33 +0200 Subject: [PATCH 71/90] Update frontend to 20220531.0 (#72775) --- 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 48488bc8f47..d9e80b4eff8 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220526.0"], + "requirements": ["home-assistant-frontend==20220531.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a43b4f99f63..a3d8a00bcfb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220526.0 +home-assistant-frontend==20220531.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index d9fd7a6a34e..23b333113b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,7 +822,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220526.0 +home-assistant-frontend==20220531.0 # homeassistant.components.home_connect homeconnect==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52e6d0a0ea9..3eabedeb942 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -589,7 +589,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220526.0 +home-assistant-frontend==20220531.0 # homeassistant.components.home_connect homeconnect==0.7.0 From a54a5b2d2098fe962010cc47ebb84beb62e91115 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 09:24:18 -1000 Subject: [PATCH 72/90] Fix queries for logbook context_ids running in the wrong executor (#72778) --- homeassistant/components/logbook/websocket_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 0bb7877b95b..82b1db1081c 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -475,7 +475,7 @@ async def ws_get_events( ) connection.send_message( - await hass.async_add_executor_job( + await get_instance(hass).async_add_executor_job( _ws_formatted_get_events, msg["id"], start_time, From 647df29a0038a225ca57831631baf91a95f5196a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 31 May 2022 22:08:50 +0200 Subject: [PATCH 73/90] Don't set headers kwargs multiple times (#72779) --- homeassistant/helpers/config_entry_oauth2_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 365ced24929..9322d6e9dc1 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -493,13 +493,13 @@ async def async_oauth2_request( This method will not refresh tokens. Use OAuth2 session for that. """ session = async_get_clientsession(hass) - + headers = kwargs.pop("headers", {}) return await session.request( method, url, **kwargs, headers={ - **(kwargs.get("headers") or {}), + **headers, "authorization": f"Bearer {token['access_token']}", }, ) From 9effb78a7fd4c154b931c5c4d412b0038dea4883 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 10:08:04 -1000 Subject: [PATCH 74/90] Prevent live logbook from sending state changed events when we only want device ids (#72780) --- homeassistant/components/logbook/helpers.py | 6 ++++++ tests/components/logbook/test_websocket_api.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index cc9ea238f8b..de021994b8d 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -132,6 +132,12 @@ def async_subscribe_events( if not _is_state_filtered(ent_reg, state): target(event) + if device_ids and not entity_ids: + # No entities to subscribe to but we are filtering + # on device ids so we do not want to get any state + # changed events + return + if entity_ids: subscriptions.append( async_track_state_change_event( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 1d35d6d897d..291c487b35b 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -1743,6 +1743,8 @@ async def test_subscribe_unsubscribe_logbook_stream_device( assert msg["type"] == "event" assert msg["event"]["events"] == [] + hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) + hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) hass.bus.async_fire("mock_event", {"device_id": device.id}) await hass.async_block_till_done() From c3acdcb2c8614c87db11cf4160fa4aab9aa053b2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 May 2022 13:22:38 -0700 Subject: [PATCH 75/90] Bumped version to 2022.6.0b6 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bd49bb0e1e2..1eb39c69f9d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index 3d52bda7738..d7bef63ded2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b5 +version = 2022.6.0b6 url = https://www.home-assistant.io/ [options] From de0c672cc24054ad03709c67172d776f6a9aff21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 11:35:28 -1000 Subject: [PATCH 76/90] Ensure the statistics_meta table is using the dynamic row format (#72784) --- homeassistant/components/recorder/migration.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index eadfc543b59..bc636d34b10 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -712,6 +712,17 @@ def _apply_update( # noqa: C901 elif new_version == 29: # Recreate statistics_meta index to block duplicated statistic_id _drop_index(session_maker, "statistics_meta", "ix_statistics_meta_statistic_id") + if engine.dialect.name == SupportedDialect.MYSQL: + # Ensure the row format is dynamic or the index + # unique will be too large + with session_scope(session=session_maker()) as session: + connection = session.connection() + # This is safe to run multiple times and fast since the table is small + connection.execute( + text( + "ALTER TABLE statistics_meta ENGINE=InnoDB, ROW_FORMAT=DYNAMIC" + ) + ) try: _create_index( session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" From 860644784860a72ef0a312434e348940ee8929af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Jun 2022 05:35:56 +0200 Subject: [PATCH 77/90] Improve cast HLS detection (#72787) --- homeassistant/components/cast/helpers.py | 9 +++++---- tests/components/cast/test_helpers.py | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index dfeb9fce25b..d7419f69563 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -266,10 +266,8 @@ async def parse_m3u(hass, url): hls_content_types = ( # https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-10 "application/vnd.apple.mpegurl", - # Some sites serve these as the informal HLS m3u type. - "application/x-mpegurl", - "audio/mpegurl", - "audio/x-mpegurl", + # Additional informal types used by Mozilla gecko not included as they + # don't reliably indicate HLS streams ) m3u_data = await _fetch_playlist(hass, url, hls_content_types) m3u_lines = m3u_data.splitlines() @@ -292,6 +290,9 @@ async def parse_m3u(hass, url): elif line.startswith("#EXT-X-VERSION:"): # HLS stream, supported by cast devices raise PlaylistSupported("HLS") + elif line.startswith("#EXT-X-STREAM-INF:"): + # HLS stream, supported by cast devices + raise PlaylistSupported("HLS") elif line.startswith("#"): # Ignore other extensions continue diff --git a/tests/components/cast/test_helpers.py b/tests/components/cast/test_helpers.py index d729d36a225..8ae73449b43 100644 --- a/tests/components/cast/test_helpers.py +++ b/tests/components/cast/test_helpers.py @@ -27,6 +27,11 @@ from tests.common import load_fixture "rthkaudio2.m3u8", "application/vnd.apple.mpegurl", ), + ( + "https://rthkaudio2-lh.akamaihd.net/i/radio2_1@355865/master.m3u8", + "rthkaudio2.m3u8", + None, + ), ), ) async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, content_type): @@ -38,11 +43,12 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten @pytest.mark.parametrize( - "url,fixture,expected_playlist", + "url,fixture,content_type,expected_playlist", ( ( "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", "209-hi-mp3.m3u", + "audio/x-mpegurl", [ PlaylistItem( length=["-1"], @@ -54,6 +60,7 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ( "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", "209-hi-mp3_bad_extinf.m3u", + "audio/x-mpegurl", [ PlaylistItem( length=None, @@ -65,6 +72,7 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ( "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", "209-hi-mp3_no_extinf.m3u", + "audio/x-mpegurl", [ PlaylistItem( length=None, @@ -76,6 +84,7 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ( "http://sverigesradio.se/topsy/direkt/164-hi-aac.pls", "164-hi-aac.pls", + "audio/x-mpegurl", [ PlaylistItem( length="-1", @@ -86,9 +95,12 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ), ), ) -async def test_parse_playlist(hass, aioclient_mock, url, fixture, expected_playlist): +async def test_parse_playlist( + hass, aioclient_mock, url, fixture, content_type, expected_playlist +): """Test playlist parsing of HLS playlist.""" - aioclient_mock.get(url, text=load_fixture(fixture, "cast")) + headers = {"content-type": content_type} + aioclient_mock.get(url, text=load_fixture(fixture, "cast"), headers=headers) playlist = await parse_playlist(hass, url) assert expected_playlist == playlist From e60dc1b503963b9b18396d6e793865668133c53d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 May 2022 23:57:16 +0200 Subject: [PATCH 78/90] Stringify mikrotik device_tracker name (#72788) Co-authored-by: J. Nick Koston --- homeassistant/components/mikrotik/device_tracker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index dee4a5de08d..16c3ed233d8 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -102,7 +102,8 @@ class MikrotikHubTracker(ScannerEntity): @property def name(self) -> str: """Return the name of the client.""" - return self.device.name + # Stringify to ensure we return a string + return str(self.device.name) @property def hostname(self) -> str: From 0db986374634a892b1fb3e46662e81eb77ac3a99 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 May 2022 14:58:45 -0700 Subject: [PATCH 79/90] Sync entities when enabling/disabling Google Assistant (#72791) --- homeassistant/components/cloud/google_config.py | 9 ++++++++- homeassistant/components/google_assistant/helpers.py | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 8f190103e87..f30be66cb42 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -195,6 +195,8 @@ class CloudGoogleConfig(AbstractConfig): ): await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) + sync_entities = False + if self.should_report_state != self.is_reporting_state: if self.should_report_state: self.async_enable_report_state() @@ -203,7 +205,7 @@ class CloudGoogleConfig(AbstractConfig): # State reporting is reported as a property on entities. # So when we change it, we need to sync all entities. - await self.async_sync_entities_all() + sync_entities = True # If entity prefs are the same or we have filter in config.yaml, # don't sync. @@ -215,12 +217,17 @@ class CloudGoogleConfig(AbstractConfig): if self.enabled and not self.is_local_sdk_active: self.async_enable_local_sdk() + sync_entities = True elif not self.enabled and self.is_local_sdk_active: self.async_disable_local_sdk() + sync_entities = True self._cur_entity_prefs = prefs.google_entity_configs self._cur_default_expose = prefs.google_default_expose + if sync_entities: + await self.async_sync_entities_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index b425367f5c3..15a8d832403 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -213,6 +213,9 @@ class AbstractConfig(ABC): async def async_sync_entities_all(self): """Sync all entities to Google for all registered agents.""" + if not self._store.agent_user_ids: + return 204 + res = await gather( *( self.async_sync_entities(agent_user_id) From 668f56f103e87d904aeed989b1d58fca1cc1d01b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 1 Jun 2022 04:40:42 +0100 Subject: [PATCH 80/90] Fix #72749 (#72794) --- homeassistant/components/utility_meter/sensor.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 8df86b3e5a8..d2a2d2ba8ca 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -99,6 +99,13 @@ PAUSED = "paused" COLLECTING = "collecting" +def validate_is_number(value): + """Validate value is a number.""" + if is_number(value): + return value + raise vol.Invalid("Value is not a number") + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -167,7 +174,7 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_CALIBRATE_METER, - {vol.Required(ATTR_VALUE): vol.Coerce(Decimal)}, + {vol.Required(ATTR_VALUE): validate_is_number}, "async_calibrate", ) @@ -244,7 +251,7 @@ async def async_setup_platform( platform.async_register_entity_service( SERVICE_CALIBRATE_METER, - {vol.Required(ATTR_VALUE): vol.Coerce(Decimal)}, + {vol.Required(ATTR_VALUE): validate_is_number}, "async_calibrate", ) @@ -446,8 +453,8 @@ class UtilityMeterSensor(RestoreSensor): async def async_calibrate(self, value): """Calibrate the Utility Meter with a given value.""" - _LOGGER.debug("Calibrate %s = %s", self._name, value) - self._state = value + _LOGGER.debug("Calibrate %s = %s type(%s)", self._name, value, type(value)) + self._state = Decimal(str(value)) self.async_write_ha_state() async def async_added_to_hass(self): From 17a3c628217c3a1b9e94181831dfd8b1a0c5160a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 31 May 2022 22:36:45 -0500 Subject: [PATCH 81/90] Support add/next/play/replace enqueue options in Sonos (#72800) --- .../components/sonos/media_player.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index fd37e546105..b970b32b87a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -576,6 +576,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self.set_shuffle(True) if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: plex_plugin.add_to_queue(result.media) + elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = plex_plugin.add_to_queue(result.media, position=pos) + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) else: soco.clear_queue() plex_plugin.add_to_queue(result.media) @@ -586,6 +594,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if share_link.is_share_link(media_id): if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: share_link.add_share_link_to_queue(media_id) + elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = share_link.add_share_link_to_queue(media_id, position=pos) + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) else: soco.clear_queue() share_link.add_share_link_to_queue(media_id) @@ -596,6 +612,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: soco.add_uri_to_queue(media_id) + elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = soco.add_uri_to_queue(media_id, position=pos) + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) else: soco.play_uri(media_id, force_radio=is_radio) elif media_type == MEDIA_TYPE_PLAYLIST: From 354149e43c320409e2e5909fcd39abe4dedba2a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 May 2022 20:41:59 -0700 Subject: [PATCH 82/90] Bumped version to 2022.6.0b7 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1eb39c69f9d..0562776d589 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index d7bef63ded2..88cf80344f6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b6 +version = 2022.6.0b7 url = https://www.home-assistant.io/ [options] From 1274448de13629f33f1f87d7ecd953d6835c623c Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 1 Jun 2022 02:04:35 -0400 Subject: [PATCH 83/90] Add package constraint for pydantic (#72799) Co-authored-by: J. Nick Koston --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a3d8a00bcfb..c1bbe51755f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -106,3 +106,7 @@ authlib<1.0 # Pin backoff for compatibility until most libraries have been updated # https://github.com/home-assistant/core/pull/70817 backoff<2.0 + +# Breaking change in version +# https://github.com/samuelcolvin/pydantic/issues/4092 +pydantic!=1.9.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f049080b7fa..0fd31430aa4 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -122,6 +122,10 @@ authlib<1.0 # Pin backoff for compatibility until most libraries have been updated # https://github.com/home-assistant/core/pull/70817 backoff<2.0 + +# Breaking change in version +# https://github.com/samuelcolvin/pydantic/issues/4092 +pydantic!=1.9.1 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From 384cb44d15e480d9b95382221fa39afba7324da6 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 31 May 2022 23:39:07 -0500 Subject: [PATCH 84/90] Cleanup handling of new enqueue & announce features in Sonos (#72801) --- .../components/sonos/media_player.py | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index b970b32b87a..cd129d82843 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -25,6 +25,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_ALBUM, MEDIA_TYPE_ARTIST, @@ -527,7 +528,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self.coordinator.soco.clear_queue() @soco_error() - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( # noqa: C901 + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """ Send the play_media command to the media player. @@ -539,6 +542,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. """ + # Use 'replace' as the default enqueue option + enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) + if kwargs.get(ATTR_MEDIA_ANNOUNCE): + # Temporary workaround until announce support is added + enqueue = MediaPlayerEnqueue.PLAY + if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) media_id = spotify.spotify_uri_from_media_browser_url(media_id) @@ -574,17 +583,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) if result.shuffle: self.set_shuffle(True) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: + if enqueue == MediaPlayerEnqueue.ADD: plex_plugin.add_to_queue(result.media) - elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 new_pos = plex_plugin.add_to_queue(result.media, position=pos) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) - else: + elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() plex_plugin.add_to_queue(result.media) soco.play_from_queue(0) @@ -592,17 +601,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): share_link = self.coordinator.share_link if share_link.is_share_link(media_id): - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: + if enqueue == MediaPlayerEnqueue.ADD: share_link.add_share_link_to_queue(media_id) - elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 new_pos = share_link.add_share_link_to_queue(media_id, position=pos) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) - else: + elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() share_link.add_share_link_to_queue(media_id) soco.play_from_queue(0) @@ -610,17 +619,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url(self.hass, media_id) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: + if enqueue == MediaPlayerEnqueue.ADD: soco.add_uri_to_queue(media_id) - elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 new_pos = soco.add_uri_to_queue(media_id, position=pos) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) - else: + elif enqueue == MediaPlayerEnqueue.REPLACE: soco.play_uri(media_id, force_radio=is_radio) elif media_type == MEDIA_TYPE_PLAYLIST: if media_id.startswith("S:"): From 9bd2e3ad7c4d614d419547d9cd765527b9fe8315 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Jun 2022 03:12:54 -0700 Subject: [PATCH 85/90] Don't trigger entity sync when Google Assistant gets disabled (#72805) --- homeassistant/components/cloud/google_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index f30be66cb42..a0a68aaf84a 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -220,7 +220,6 @@ class CloudGoogleConfig(AbstractConfig): sync_entities = True elif not self.enabled and self.is_local_sdk_active: self.async_disable_local_sdk() - sync_entities = True self._cur_entity_prefs = prefs.google_entity_configs self._cur_default_expose = prefs.google_default_expose From 9e723f9b6d3ba79687e66df312bf4ffce6c4d782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 22:56:05 -1000 Subject: [PATCH 86/90] Bump sqlalchemy to 1.4.37 (#72809) Fixes a bug where reconnects might fail with MySQL 8.0.24+ Changelog: https://docs.sqlalchemy.org/en/14/changelog/changelog_14.html#change-1.4.37 --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 0fb44f99ae2..38897c42e1a 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -2,7 +2,7 @@ "domain": "recorder", "name": "Recorder", "documentation": "https://www.home-assistant.io/integrations/recorder", - "requirements": ["sqlalchemy==1.4.36", "fnvhash==0.1.0", "lru-dict==1.1.7"], + "requirements": ["sqlalchemy==1.4.37", "fnvhash==0.1.0", "lru-dict==1.1.7"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c779e4567cd..4562b945008 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -2,7 +2,7 @@ "domain": "sql", "name": "SQL", "documentation": "https://www.home-assistant.io/integrations/sql", - "requirements": ["sqlalchemy==1.4.36"], + "requirements": ["sqlalchemy==1.4.37"], "codeowners": ["@dgomes", "@gjohansson-ST"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c1bbe51755f..ad2539233f4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ pyudev==0.22.0 pyyaml==6.0 requests==2.27.1 scapy==2.4.5 -sqlalchemy==1.4.36 +sqlalchemy==1.4.37 typing-extensions>=3.10.0.2,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 23b333113b0..d1bffba6971 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.36 +sqlalchemy==1.4.37 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3eabedeb942..2c819e1ef3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1465,7 +1465,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.36 +sqlalchemy==1.4.37 # homeassistant.components.srp_energy srpenergy==1.3.6 From 1139136365b8624e4f4f242140952e27153ef25b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 1 Jun 2022 12:33:13 +0200 Subject: [PATCH 87/90] Add Motionblinds WoodShutter support (#72814) --- .../components/motion_blinds/cover.py | 58 +++++++++++++++++++ .../components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index a9f6df82ae0..7bac3a5fb20 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -9,6 +9,7 @@ from homeassistant.components.cover import ( ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, + CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -64,6 +65,10 @@ TILT_DEVICE_MAP = { BlindType.VerticalBlindRight: CoverDeviceClass.BLIND, } +TILT_ONLY_DEVICE_MAP = { + BlindType.WoodShutter: CoverDeviceClass.BLIND, +} + TDBU_DEVICE_MAP = { BlindType.TopDownBottomUp: CoverDeviceClass.SHADE, } @@ -108,6 +113,16 @@ async def async_setup_entry( ) ) + elif blind.type in TILT_ONLY_DEVICE_MAP: + entities.append( + MotionTiltOnlyDevice( + coordinator, + blind, + TILT_ONLY_DEVICE_MAP[blind.type], + sw_version, + ) + ) + elif blind.type in TDBU_DEVICE_MAP: entities.append( MotionTDBUDevice( @@ -356,6 +371,49 @@ class MotionTiltDevice(MotionPositionDevice): await self.hass.async_add_executor_job(self._blind.Stop) +class MotionTiltOnlyDevice(MotionTiltDevice): + """Representation of a Motion Blind Device.""" + + _restore_tilt = False + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + ) + + if self.current_cover_tilt_position is not None: + supported_features |= CoverEntityFeature.SET_TILT_POSITION + + return supported_features + + @property + def current_cover_position(self): + """Return current position of cover.""" + return None + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if self._blind.angle is None: + return None + return self._blind.angle == 0 + + async def async_set_absolute_position(self, **kwargs): + """Move the cover to a specific absolute position (see TDBU).""" + angle = kwargs.get(ATTR_TILT_POSITION) + if angle is not None: + angle = angle * 180 / 100 + async with self._api_lock: + await self.hass.async_add_executor_job( + self._blind.Set_angle, + angle, + ) + + class MotionTDBUDevice(MotionPositionDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 1e8ad0eb0a1..bc09d3e9e38 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Blinds", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", - "requirements": ["motionblinds==0.6.7"], + "requirements": ["motionblinds==0.6.8"], "dependencies": ["network"], "dhcp": [ { "registered_devices": true }, diff --git a/requirements_all.txt b/requirements_all.txt index d1bffba6971..6341528b09a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ mitemp_bt==0.0.5 moehlenhoff-alpha2==1.2.1 # homeassistant.components.motion_blinds -motionblinds==0.6.7 +motionblinds==0.6.8 # homeassistant.components.motioneye motioneye-client==0.3.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c819e1ef3c..b5767c1e468 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -715,7 +715,7 @@ minio==5.0.10 moehlenhoff-alpha2==1.2.1 # homeassistant.components.motion_blinds -motionblinds==0.6.7 +motionblinds==0.6.8 # homeassistant.components.motioneye motioneye-client==0.3.12 From 2f3359f376bc9e69e8118ef7e7f63cef8b81964d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 23:56:06 -1000 Subject: [PATCH 88/90] Fix purge of legacy database events that are not state changed (#72815) --- homeassistant/components/recorder/queries.py | 2 +- tests/components/recorder/test_purge.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 5532c5c0703..e27d3d692cc 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -631,7 +631,7 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( lambda: select( Events.event_id, Events.data_id, States.state_id, States.attributes_id ) - .join(States, Events.event_id == States.event_id) + .outerjoin(States, Events.event_id == States.event_id) .filter(Events.time_fired < purge_before) .limit(MAX_ROWS_TO_PURGE) ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c8ba5e9d076..f4e998c5388 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -26,7 +26,7 @@ from homeassistant.components.recorder.services import ( ) from homeassistant.components.recorder.tasks import PurgeTask from homeassistant.components.recorder.util import session_scope -from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON +from homeassistant.const import EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -925,6 +925,15 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( time_fired=timestamp, ) ) + session.add( + Events( + event_id=event_id + 1, + event_type=EVENT_THEMES_UPDATED, + event_data="{}", + origin="LOCAL", + time_fired=timestamp, + ) + ) service_data = {"keep_days": 10} _add_db_entries(hass) From bf47d86d30f374e6e2cd08b3c7e747eb9e30a083 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Jun 2022 00:33:46 -1000 Subject: [PATCH 89/90] Fix logbook spinner never disappearing when all entities are filtered (#72816) --- .../components/logbook/websocket_api.py | 4 +- .../components/logbook/test_websocket_api.py | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 82b1db1081c..1af44440803 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -356,7 +356,7 @@ async def ws_event_stream( ) await _async_wait_for_recorder_sync(hass) - if not subscriptions: + if msg_id not in connection.subscriptions: # Unsubscribe happened while waiting for recorder return @@ -388,6 +388,8 @@ async def ws_event_stream( if not subscriptions: # Unsubscribe happened while waiting for formatted events + # or there are no supported entities (all UOM or state class) + # or devices return live_stream.task = asyncio.create_task( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 291c487b35b..2dd08ec44ce 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2089,3 +2089,52 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo assert msg["success"] assert "Recorder is behind" in caplog.text + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_client): + """Test subscribe/unsubscribe logbook stream with entities that are always filtered.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + await async_wait_recording_done(hass) + + init_count = sum(hass.bus.async_listeners().values()) + hass.states.async_set("sensor.uom", "1", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "2", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "3", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": ["sensor.uom"], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + hass.states.async_set("sensor.uom", "1", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "2", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "3", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + + await websocket_client.close() + await hass.async_block_till_done() + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count From 39da7a93ecc15cb414b0a13c2b0ee48deb17756e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Jun 2022 13:04:12 +0200 Subject: [PATCH 90/90] Bumped version to 2022.6.0 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0562776d589..428aa84d5fb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/setup.cfg b/setup.cfg index 88cf80344f6..7f1e739803b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0b7 +version = 2022.6.0 url = https://www.home-assistant.io/ [options]