From 1d5ecdd4eae00f7e4e2d657fe3e2c3d74be8920c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 17 Jan 2023 03:34:42 +0100 Subject: [PATCH 01/20] Make API key mandatory for PI-Hole (#85885) add reauth, so make api-key mandatory --- homeassistant/components/pi_hole/__init__.py | 42 +++-- .../components/pi_hole/binary_sensor.py | 14 -- .../components/pi_hole/config_flow.py | 177 ++++++++++-------- homeassistant/components/pi_hole/const.py | 3 - homeassistant/components/pi_hole/strings.json | 17 +- .../components/pi_hole/translations/en.json | 19 +- tests/components/pi_hole/__init__.py | 37 +++- tests/components/pi_hole/test_config_flow.py | 111 ++++++----- tests/components/pi_hole/test_init.py | 83 +++----- 9 files changed, 252 insertions(+), 251 deletions(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 714547ba961..ba7949c0c30 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -17,7 +17,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo @@ -64,6 +65,13 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Pi-hole integration.""" @@ -103,11 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: location = entry.data[CONF_LOCATION] api_key = entry.data.get(CONF_API_KEY) - # For backward compatibility - if CONF_STATISTICS_ONLY not in entry.data: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_STATISTICS_ONLY: not api_key} - ) + # remove obsolet CONF_STATISTICS_ONLY from entry.data + if CONF_STATISTICS_ONLY in entry.data: + entry_data = entry.data.copy() + entry_data.pop(CONF_STATISTICS_ONLY) + hass.config_entries.async_update_entry(entry, data=entry_data) + + # start reauth to force api key is present + if CONF_API_KEY not in entry.data: + raise ConfigEntryAuthFailed _LOGGER.debug("Setting up %s integration with host %s", DOMAIN, host) @@ -125,8 +137,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.get_data() await api.get_versions() + _LOGGER.debug("async_update_data() api.data: %s", api.data) except HoleError as err: raise UpdateFailed(f"Failed to communicate with API: {err}") from err + if not isinstance(api.data, dict): + raise ConfigEntryAuthFailed coordinator = DataUpdateCoordinator( hass, @@ -142,30 +157,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, _async_platforms(entry)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Pi-hole entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - entry, _async_platforms(entry) - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -@callback -def _async_platforms(entry: ConfigEntry) -> list[Platform]: - """Return platforms to be loaded / unloaded.""" - platforms = [Platform.BINARY_SENSOR, Platform.UPDATE, Platform.SENSOR] - if not entry.data[CONF_STATISTICS_ONLY]: - platforms.append(Platform.SWITCH) - return platforms - - class PiHoleEntity(CoordinatorEntity): """Representation of a Pi-hole entity.""" diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index e887f2ea12f..7d0d9034fad 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -15,8 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleEntity from .const import ( BINARY_SENSOR_TYPES, - BINARY_SENSOR_TYPES_STATISTICS_ONLY, - CONF_STATISTICS_ONLY, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN, @@ -42,18 +40,6 @@ async def async_setup_entry( for description in BINARY_SENSOR_TYPES ] - if entry.data[CONF_STATISTICS_ONLY]: - binary_sensors += [ - PiHoleBinarySensor( - hole_data[DATA_KEY_API], - hole_data[DATA_KEY_COORDINATOR], - name, - entry.entry_id, - description, - ) - for description in BINARY_SENSOR_TYPES_STATISTICS_ONLY - ] - async_add_entities(binary_sensors, True) diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index 40f4555e7d2..637f906b9ee 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Pi-hole integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -26,7 +27,6 @@ from .const import ( DEFAULT_LOCATION, DEFAULT_NAME, DEFAULT_SSL, - DEFAULT_STATISTICS_ONLY, DEFAULT_VERIFY_SSL, DOMAIN, ) @@ -47,65 +47,29 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initiated by the user.""" - return await self.async_step_init(user_input) - - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow initiated by import.""" - return await self.async_step_init(user_input, is_import=True) - - async def async_step_init( - self, user_input: dict[str, Any] | None, is_import: bool = False - ) -> FlowResult: - """Handle init step of a flow.""" errors = {} if user_input is not None: - host = ( - user_input[CONF_HOST] - if is_import - else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - ) - name = user_input[CONF_NAME] - location = user_input[CONF_LOCATION] - tls = user_input[CONF_SSL] - verify_tls = user_input[CONF_VERIFY_SSL] - endpoint = f"{host}/{location}" + self._config = { + CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + CONF_NAME: user_input[CONF_NAME], + CONF_LOCATION: user_input[CONF_LOCATION], + CONF_SSL: user_input[CONF_SSL], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_API_KEY: user_input[CONF_API_KEY], + } - if await self._async_endpoint_existed(endpoint): - return self.async_abort(reason="already_configured") - - try: - await self._async_try_connect(host, location, tls, verify_tls) - except HoleError as ex: - _LOGGER.debug("Connection failed: %s", ex) - if is_import: - _LOGGER.error("Failed to import: %s", ex) - return self.async_abort(reason="cannot_connect") - errors["base"] = "cannot_connect" - else: - self._config = { - CONF_HOST: host, - CONF_NAME: name, - CONF_LOCATION: location, - CONF_SSL: tls, - CONF_VERIFY_SSL: verify_tls, + self._async_abort_entries_match( + { + CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + CONF_LOCATION: user_input[CONF_LOCATION], } - if is_import: - api_key = user_input.get(CONF_API_KEY) - return self.async_create_entry( - title=name, - data={ - **self._config, - CONF_STATISTICS_ONLY: api_key is None, - CONF_API_KEY: api_key, - }, - ) - self._config[CONF_STATISTICS_ONLY] = user_input[CONF_STATISTICS_ONLY] - if self._config[CONF_STATISTICS_ONLY]: - return self.async_create_entry(title=name, data=self._config) - return await self.async_step_api_key() + ) + + if not (errors := await self._async_try_connect()): + return self.async_create_entry( + title=user_input[CONF_NAME], data=self._config + ) user_input = user_input or {} return self.async_show_form( @@ -116,6 +80,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): vol.Required( CONF_PORT, default=user_input.get(CONF_PORT, 80) ): vol.Coerce(int), + vol.Required(CONF_API_KEY): str, vol.Required( CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) ): str, @@ -123,12 +88,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_LOCATION, default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION), ): str, - vol.Required( - CONF_STATISTICS_ONLY, - default=user_input.get( - CONF_STATISTICS_ONLY, DEFAULT_STATISTICS_ONLY - ), - ): bool, vol.Required( CONF_SSL, default=user_input.get(CONF_SSL, DEFAULT_SSL), @@ -142,24 +101,94 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_api_key( - self, user_input: dict[str, Any] | None = None + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle a flow initiated by import.""" + + host = user_input[CONF_HOST] + name = user_input[CONF_NAME] + location = user_input[CONF_LOCATION] + tls = user_input[CONF_SSL] + verify_tls = user_input[CONF_VERIFY_SSL] + endpoint = f"{host}/{location}" + + if await self._async_endpoint_existed(endpoint): + return self.async_abort(reason="already_configured") + + try: + await self._async_try_connect_legacy(host, location, tls, verify_tls) + except HoleError as ex: + _LOGGER.debug("Connection failed: %s", ex) + _LOGGER.error("Failed to import: %s", ex) + return self.async_abort(reason="cannot_connect") + self._config = { + CONF_HOST: host, + CONF_NAME: name, + CONF_LOCATION: location, + CONF_SSL: tls, + CONF_VERIFY_SSL: verify_tls, + } + api_key = user_input.get(CONF_API_KEY) + return self.async_create_entry( + title=name, + data={ + **self._config, + CONF_STATISTICS_ONLY: api_key is None, + CONF_API_KEY: api_key, + }, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._config = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, + user_input: dict[str, Any] | None = None, ) -> FlowResult: - """Handle step to setup API key.""" + """Perform reauth confirm upon an API authentication error.""" + errors = {} if user_input is not None: - return self.async_create_entry( - title=self._config[CONF_NAME], - data={ - **self._config, - CONF_API_KEY: user_input.get(CONF_API_KEY, ""), - }, - ) + self._config = {**self._config, CONF_API_KEY: user_input[CONF_API_KEY]} + if not (errors := await self._async_try_connect()): + entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert entry + self.hass.config_entries.async_update_entry(entry, data=self._config) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.context["entry_id"]) + ) + return self.async_abort(reason="reauth_successful") return self.async_show_form( - step_id="api_key", - data_schema=vol.Schema({vol.Optional(CONF_API_KEY): str}), + step_id="reauth_confirm", + description_placeholders={ + CONF_HOST: self._config[CONF_HOST], + CONF_LOCATION: self._config[CONF_LOCATION], + }, + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, ) + async def _async_try_connect(self) -> dict[str, str]: + session = async_get_clientsession(self.hass, self._config[CONF_VERIFY_SSL]) + pi_hole = Hole( + self._config[CONF_HOST], + session, + location=self._config[CONF_LOCATION], + tls=self._config[CONF_SSL], + api_token=self._config[CONF_API_KEY], + ) + try: + await pi_hole.get_data() + except HoleError as ex: + _LOGGER.debug("Connection failed: %s", ex) + return {"base": "cannot_connect"} + if not isinstance(pi_hole.data, dict): + return {CONF_API_KEY: "invalid_auth"} + return {} + async def _async_endpoint_existed(self, endpoint: str) -> bool: existing_endpoints = [ f"{entry.data.get(CONF_HOST)}/{entry.data.get(CONF_LOCATION)}" @@ -167,7 +196,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ] return endpoint in existing_endpoints - async def _async_try_connect( + async def _async_try_connect_legacy( self, host: str, location: str, tls: bool, verify_tls: bool ) -> None: session = async_get_clientsession(self.hass, verify_tls) diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index c73660faedb..a9bc5824ad9 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -154,9 +154,6 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( }, state_value=lambda api: bool(api.versions["FTL_update"]), ), -) - -BINARY_SENSOR_TYPES_STATISTICS_ONLY: tuple[PiHoleBinarySensorEntityDescription, ...] = ( PiHoleBinarySensorEntityDescription( key="status", name="Status", diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index e911779d5d7..120ab8cb80a 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -8,28 +8,25 @@ "name": "[%key:common::config_flow::data::name%]", "location": "[%key:common::config_flow::data::location%]", "api_key": "[%key:common::config_flow::data::api_key%]", - "statistics_only": "Statistics Only", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, - "api_key": { + "reauth_confirm": { + "title": "PI-Hole [%key:common::config_flow::title::reauth%]", + "description": "Please enter a new api key for PI-Hole at {host}/{location}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - } - }, - "issues": { - "deprecated_yaml": { - "title": "The PI-Hole YAML configuration is being removed", - "description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/pi_hole/translations/en.json b/homeassistant/components/pi_hole/translations/en.json index 4333838ae64..815182731c2 100644 --- a/homeassistant/components/pi_hole/translations/en.json +++ b/homeassistant/components/pi_hole/translations/en.json @@ -1,16 +1,20 @@ { "config": { "abort": { - "already_configured": "Service is already configured" + "already_configured": "Service is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" }, "step": { - "api_key": { + "reauth_confirm": { "data": { "api_key": "API Key" - } + }, + "description": "Please enter a new api key for PI-Hole at {host}/{location}", + "title": "PI-Hole Reauthenticate Integration" }, "user": { "data": { @@ -20,16 +24,9 @@ "name": "Name", "port": "Port", "ssl": "Uses an SSL certificate", - "statistics_only": "Statistics Only", "verify_ssl": "Verify SSL certificate" } } } - }, - "issues": { - "deprecated_yaml": { - "description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", - "title": "The PI-Hole YAML configuration is being removed" - } } } \ No newline at end of file diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index 57ea89fc7e0..49e15391f8c 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch from hole.exceptions import HoleError -from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY +from homeassistant.components.pi_hole.const import ( + CONF_STATISTICS_ONLY, + DEFAULT_LOCATION, + DEFAULT_NAME, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, +) from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -47,7 +53,16 @@ API_KEY = "apikey" SSL = False VERIFY_SSL = True -CONF_DATA = { +CONFIG_DATA_DEFAULTS = { + CONF_HOST: f"{HOST}:{PORT}", + CONF_LOCATION: DEFAULT_LOCATION, + CONF_NAME: DEFAULT_NAME, + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_API_KEY: API_KEY, +} + +CONFIG_DATA = { CONF_HOST: f"{HOST}:{PORT}", CONF_LOCATION: LOCATION, CONF_NAME: NAME, @@ -56,34 +71,35 @@ CONF_DATA = { CONF_VERIFY_SSL: VERIFY_SSL, } -CONF_CONFIG_FLOW_USER = { +CONFIG_FLOW_USER = { CONF_HOST: HOST, CONF_PORT: PORT, + CONF_API_KEY: API_KEY, CONF_LOCATION: LOCATION, CONF_NAME: NAME, - CONF_STATISTICS_ONLY: False, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, } -CONF_CONFIG_FLOW_API_KEY = { +CONFIG_FLOW_API_KEY = { CONF_API_KEY: API_KEY, } -CONF_CONFIG_ENTRY = { +CONFIG_ENTRY = { CONF_HOST: f"{HOST}:{PORT}", CONF_LOCATION: LOCATION, CONF_NAME: NAME, - CONF_STATISTICS_ONLY: False, CONF_API_KEY: API_KEY, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, } +CONFIG_ENTRY_IMPORTED = {**CONFIG_ENTRY, CONF_STATISTICS_ONLY: False} + SWITCH_ENTITY_ID = "switch.pi_hole" -def _create_mocked_hole(raise_exception=False, has_versions=True): +def _create_mocked_hole(raise_exception=False, has_versions=True, has_data=True): mocked_hole = MagicMock() type(mocked_hole).get_data = AsyncMock( side_effect=HoleError("") if raise_exception else None @@ -93,7 +109,10 @@ def _create_mocked_hole(raise_exception=False, has_versions=True): ) type(mocked_hole).enable = AsyncMock() type(mocked_hole).disable = AsyncMock() - mocked_hole.data = ZERO_DATA + if has_data: + mocked_hole.data = ZERO_DATA + else: + mocked_hole.data = [] if has_versions: mocked_hole.versions = SAMPLE_VERSIONS else: diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index bc86922c89f..65f21418bad 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -2,28 +2,26 @@ import logging from unittest.mock import patch -from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY, DOMAIN +from homeassistant.components.pi_hole.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( - CONF_CONFIG_ENTRY, - CONF_CONFIG_FLOW_API_KEY, - CONF_CONFIG_FLOW_USER, - CONF_DATA, + CONFIG_DATA, + CONFIG_DATA_DEFAULTS, + CONFIG_ENTRY, + CONFIG_ENTRY_IMPORTED, + CONFIG_FLOW_USER, NAME, + ZERO_DATA, _create_mocked_hole, _patch_config_flow_hole, + _patch_init_hole, ) - -def _flow_next(hass, flow_id): - return next( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["flow_id"] == flow_id - ) +from tests.common import MockConfigEntry def _patch_setup(): @@ -33,41 +31,41 @@ def _patch_setup(): ) -async def test_flow_import(hass, caplog): +async def test_flow_import(hass: HomeAssistant, caplog): """Test import flow.""" mocked_hole = _create_mocked_hole() with _patch_config_flow_hole(mocked_hole), _patch_setup(): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == NAME - assert result["data"] == CONF_CONFIG_ENTRY + assert result["data"] == CONFIG_ENTRY_IMPORTED # duplicated server result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_flow_import_invalid(hass, caplog): +async def test_flow_import_invalid(hass: HomeAssistant, caplog): """Test import flow with invalid server.""" mocked_hole = _create_mocked_hole(True) with _patch_config_flow_hole(mocked_hole), _patch_setup(): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "cannot_connect" assert len([x for x in caplog.records if x.levelno == logging.ERROR]) == 1 -async def test_flow_user(hass): +async def test_flow_user(hass: HomeAssistant): """Test user initialized flow.""" - mocked_hole = _create_mocked_hole() - with _patch_config_flow_hole(mocked_hole), _patch_setup(): + mocked_hole = _create_mocked_hole(has_data=False) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -75,69 +73,68 @@ async def test_flow_user(hass): assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - _flow_next(hass, result["flow_id"]) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONF_CONFIG_FLOW_USER, + user_input=CONFIG_FLOW_USER, ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "api_key" - assert result["errors"] is None - _flow_next(hass, result["flow_id"]) + assert result["step_id"] == "user" + assert result["errors"] == {CONF_API_KEY: "invalid_auth"} + mocked_hole.data = ZERO_DATA result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONF_CONFIG_FLOW_API_KEY, + user_input=CONFIG_FLOW_USER, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == NAME - assert result["data"] == CONF_CONFIG_ENTRY + assert result["data"] == CONFIG_ENTRY # duplicated server result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=CONF_CONFIG_FLOW_USER, + data=CONFIG_FLOW_USER, ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_flow_statistics_only(hass): - """Test user initialized flow with statistics only.""" - mocked_hole = _create_mocked_hole() - with _patch_config_flow_hole(mocked_hole), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - _flow_next(hass, result["flow_id"]) - - user_input = {**CONF_CONFIG_FLOW_USER} - user_input[CONF_STATISTICS_ONLY] = True - config_entry_data = {**CONF_CONFIG_ENTRY} - config_entry_data[CONF_STATISTICS_ONLY] = True - config_entry_data.pop(CONF_API_KEY) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=user_input, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == NAME - assert result["data"] == config_entry_data - - async def test_flow_user_invalid(hass): """Test user initialized flow with invalid server.""" mocked_hole = _create_mocked_hole(True) with _patch_config_flow_hole(mocked_hole), _patch_setup(): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW_USER + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_reauth(hass: HomeAssistant): + """Test reauth flow.""" + mocked_hole = _create_mocked_hole(has_data=False) + entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA_DEFAULTS) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole), _patch_config_flow_hole(mocked_hole): + assert not await hass.config_entries.async_setup(entry.entry_id) + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + assert flows[0]["context"]["entry_id"] == entry.entry_id + + mocked_hole.data = ZERO_DATA + + result = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + user_input={CONF_API_KEY: "newkey"}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_API_KEY] == "newkey" diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index dce3773acdc..75d9dd27aee 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -7,27 +7,16 @@ from hole.exceptions import HoleError from homeassistant.components import pi_hole, switch from homeassistant.components.pi_hole.const import ( CONF_STATISTICS_ONLY, - DEFAULT_LOCATION, - DEFAULT_NAME, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_API_KEY, - CONF_HOST, - CONF_LOCATION, - CONF_NAME, - CONF_SSL, - CONF_VERIFY_SSL, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( - CONF_CONFIG_ENTRY, - CONF_DATA, + CONFIG_DATA_DEFAULTS, SWITCH_ENTITY_ID, _create_mocked_hole, _patch_config_flow_hole, @@ -37,7 +26,7 @@ from . import ( from tests.common import MockConfigEntry -async def test_setup_minimal_config(hass): +async def test_setup_minimal_config(hass: HomeAssistant): """Tests component setup with minimal config.""" mocked_hole = _create_mocked_hole() with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): @@ -88,7 +77,7 @@ async def test_setup_minimal_config(hass): assert state.state == "off" -async def test_setup_name_config(hass): +async def test_setup_name_config(hass: HomeAssistant): """Tests component setup with a custom name.""" mocked_hole = _create_mocked_hole() with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): @@ -106,7 +95,7 @@ async def test_setup_name_config(hass): ) -async def test_switch(hass, caplog): +async def test_switch(hass: HomeAssistant, caplog): """Test Pi-hole switch.""" mocked_hole = _create_mocked_hole() with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): @@ -154,7 +143,7 @@ async def test_switch(hass, caplog): assert errors[-1].message == "Unable to disable Pi-hole: Error2" -async def test_disable_service_call(hass): +async def test_disable_service_call(hass: HomeAssistant): """Test disable service call with no Pi-hole named.""" mocked_hole = _create_mocked_hole() with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): @@ -180,21 +169,14 @@ async def test_disable_service_call(hass): await hass.async_block_till_done() - mocked_hole.disable.assert_called_once_with(1) + mocked_hole.disable.assert_called_with(1) -async def test_unload(hass): +async def test_unload(hass: HomeAssistant): """Test unload entities.""" entry = MockConfigEntry( domain=pi_hole.DOMAIN, - data={ - CONF_NAME: DEFAULT_NAME, - CONF_HOST: "pi.hole", - CONF_LOCATION: DEFAULT_LOCATION, - CONF_SSL: DEFAULT_SSL, - CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, - CONF_STATISTICS_ONLY: True, - }, + data={**CONFIG_DATA_DEFAULTS, CONF_HOST: "pi.hole"}, ) entry.add_to_hass(hass) mocked_hole = _create_mocked_hole() @@ -208,32 +190,25 @@ async def test_unload(hass): assert entry.entry_id not in hass.data[pi_hole.DOMAIN] -async def test_migrate(hass): - """Test migrate from old config entry.""" - entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONF_DATA) - entry.add_to_hass(hass) - +async def test_remove_obsolete(hass: HomeAssistant): + """Test removing obsolete config entry parameters.""" mocked_hole = _create_mocked_hole() - with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.data == CONF_CONFIG_ENTRY - - -async def test_migrate_statistics_only(hass): - """Test migrate from old config entry with statistics only.""" - conf_data = {**CONF_DATA} - conf_data[CONF_API_KEY] = "" - entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=conf_data) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + ) entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + assert CONF_STATISTICS_ONLY not in entry.data + +async def test_missing_api_key(hass: HomeAssistant): + """Tests start reauth flow if api key is missing.""" mocked_hole = _create_mocked_hole() - with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - config_entry_data = {**CONF_CONFIG_ENTRY} - config_entry_data[CONF_STATISTICS_ONLY] = True - config_entry_data[CONF_API_KEY] = "" - assert entry.data == config_entry_data + data = CONFIG_DATA_DEFAULTS.copy() + data.pop(CONF_API_KEY) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=data) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_ERROR From cb27cfe7dda21b381d5aa14cbb70140be7e754c8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 13 Jan 2023 20:07:09 +0100 Subject: [PATCH 02/20] Reolink check for admin (#85570) Co-authored-by: Martin Hjelmare fixes undefined --- homeassistant/components/reolink/__init__.py | 7 +- .../components/reolink/config_flow.py | 62 ++++++++++--- .../components/reolink/exceptions.py | 6 ++ homeassistant/components/reolink/host.py | 7 ++ homeassistant/components/reolink/strings.json | 13 ++- .../components/reolink/translations/en.json | 15 +++- tests/components/reolink/test_config_flow.py | 88 +++++++++++++++++-- 7 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/reolink/exceptions.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index a4daba45ba7..6f7ab9d68b7 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -14,10 +14,11 @@ from reolink_aio.exceptions import ApiError, InvalidContentTypeError from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .exceptions import UserNotAdmin from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) @@ -40,16 +41,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: if not await host.async_init(): + await host.stop() raise ConfigEntryNotReady( f"Error while trying to setup {host.api.host}:{host.api.port}: " "failed to obtain data from device." ) + except UserNotAdmin as err: + raise ConfigEntryAuthFailed(err) from UserNotAdmin except ( ClientConnectorError, asyncio.TimeoutError, ApiError, InvalidContentTypeError, ) as err: + await host.stop() raise ConfigEntryNotReady( f'Error while trying to setup {host.api.host}:{host.api.port}: "{str(err)}".' ) from err diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 31f1a10dc1e..fdbbf201756 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -1,19 +1,21 @@ """Config flow for the Reolink camera component.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from reolink_aio.exceptions import ApiError, CredentialsInvalidError import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, exceptions from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DOMAIN +from .exceptions import UserNotAdmin from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) @@ -53,6 +55,13 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize.""" + self._host: str | None = None + self._username: str = "admin" + self._password: str | None = None + self._reauth: bool = False + @staticmethod @callback def async_get_options_flow( @@ -61,16 +70,37 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Options callback for Reolink.""" return ReolinkOptionsFlowHandler(config_entry) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an authentication error or no admin privileges.""" + self._host = entry_data[CONF_HOST] + self._username = entry_data[CONF_USERNAME] + self._password = entry_data[CONF_PASSWORD] + self._reauth = True + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is not None: + return await self.async_step_user() + return self.async_show_form(step_id="reauth_confirm") + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" errors = {} - placeholders = {} + placeholders = {"error": ""} if user_input is not None: + host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS) try: - host = await async_obtain_host_settings(self.hass, user_input) + await async_obtain_host_settings(host) + except UserNotAdmin: + errors[CONF_USERNAME] = "not_admin" + placeholders["username"] = host.api.username + placeholders["userlevel"] = host.api.user_level except CannotConnect: errors[CONF_HOST] = "cannot_connect" except CredentialsInvalidError: @@ -87,7 +117,17 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https - await self.async_set_unique_id(host.unique_id, raise_on_progress=False) + existing_entry = await self.async_set_unique_id( + host.unique_id, raise_on_progress=False + ) + if existing_entry and self._reauth: + if self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ): + await self.hass.config_entries.async_reload( + existing_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( @@ -98,9 +138,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema = vol.Schema( { - vol.Required(CONF_USERNAME, default="admin"): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default=self._username): str, + vol.Required(CONF_PASSWORD, default=self._password): str, + vol.Required(CONF_HOST, default=self._host): str, } ) if errors: @@ -119,20 +159,14 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -async def async_obtain_host_settings( - hass: core.HomeAssistant, user_input: dict -) -> ReolinkHost: +async def async_obtain_host_settings(host: ReolinkHost) -> None: """Initialize the Reolink host and get the host information.""" - host = ReolinkHost(hass, user_input, DEFAULT_OPTIONS) - try: if not await host.async_init(): raise CannotConnect finally: await host.stop() - return host - class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/reolink/exceptions.py b/homeassistant/components/reolink/exceptions.py new file mode 100644 index 00000000000..ad95625cfa7 --- /dev/null +++ b/homeassistant/components/reolink/exceptions.py @@ -0,0 +1,6 @@ +"""Exceptions for the Reolink Camera integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class UserNotAdmin(HomeAssistantError): + """Raised when user is not admin.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index fc5e4947afa..5c744f0c5fd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_TIMEOUT +from .exceptions import UserNotAdmin _LOGGER = logging.getLogger(__name__) @@ -68,6 +69,12 @@ class ReolinkHost: if self._api.mac_address is None: return False + if not self._api.is_admin: + await self.stop() + raise UserNotAdmin( + f"User '{self._api.username}' has authorization level '{self._api.user_level}', only admin users can change camera settings" + ) + enable_onvif = None enable_rtmp = None enable_rtsp = None diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 88211774240..1c82a43c8a2 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -2,6 +2,7 @@ "config": { "step": { "user": { + "description": "{error}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -9,16 +10,22 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Reolink integration needs to re-authenticate your connection details" } }, "error": { - "api_error": "API error occurred: {error}", + "api_error": "API error occurred", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]: {error}" + "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/reolink/translations/en.json b/homeassistant/components/reolink/translations/en.json index 028f61ed8c7..beb366e8b39 100644 --- a/homeassistant/components/reolink/translations/en.json +++ b/homeassistant/components/reolink/translations/en.json @@ -1,15 +1,21 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { - "api_error": "API error occurred: {error}", + "api_error": "API error occurred", "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error: {error}" + "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", + "unknown": "Unexpected error" }, "step": { + "reauth_confirm": { + "description": "The Reolink integration needs to re-authenticate your connection details", + "title": "Reauthenticate Integration" + }, "user": { "data": { "host": "Host", @@ -17,7 +23,8 @@ "port": "Port", "use_https": "Enable HTTPS", "username": "Username" - } + }, + "description": "{error}" } } }, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index b69fab9797f..fc6672718b9 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -24,7 +24,7 @@ TEST_NVR_NAME = "test_reolink_name" TEST_USE_HTTPS = True -def get_mock_info(error=None, host_data_return=True): +def get_mock_info(error=None, host_data_return=True, user_level="admin"): """Return a mock gateway info instance.""" host_mock = Mock() if error is None: @@ -40,6 +40,8 @@ def get_mock_info(error=None, host_data_return=True): host_mock.nvr_name = TEST_NVR_NAME host_mock.port = TEST_PORT host_mock.use_https = TEST_USE_HTTPS + host_mock.is_admin = user_level == "admin" + host_mock.user_level = user_level return host_mock @@ -110,7 +112,22 @@ async def test_config_flow_errors(hass): assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"host": "cannot_connect"} + assert result["errors"] == {CONF_HOST: "cannot_connect"} + + host_mock = get_mock_info(user_level="guest") + with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_USERNAME: "not_admin"} host_mock = get_mock_info(error=json.JSONDecodeError("test_error", "test", 1)) with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): @@ -125,7 +142,7 @@ async def test_config_flow_errors(hass): assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"host": "unknown"} + assert result["errors"] == {CONF_HOST: "unknown"} host_mock = get_mock_info(error=CredentialsInvalidError("Test error")) with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): @@ -140,7 +157,7 @@ async def test_config_flow_errors(hass): assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"host": "invalid_auth"} + assert result["errors"] == {CONF_HOST: "invalid_auth"} host_mock = get_mock_info(error=ApiError("Test error")) with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): @@ -155,7 +172,7 @@ async def test_config_flow_errors(hass): assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"host": "api_error"} + assert result["errors"] == {CONF_HOST: "api_error"} result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -261,3 +278,64 @@ async def test_change_connection_settings(hass): assert config_entry.data[CONF_HOST] == TEST_HOST2 assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2 assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 + + +async def test_reauth(hass): + """Test a reauth flow.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + const.CONF_PROTOCOL: const.DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + "title_placeholders": {"name": TEST_NVR_NAME}, + "unique_id": format_mac(TEST_MAC), + }, + data=config_entry.data, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST2, + CONF_USERNAME: TEST_USERNAME2, + CONF_PASSWORD: TEST_PASSWORD2, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_HOST] == TEST_HOST2 + assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2 + assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 From 8beb043d62483c29dc81e4a2dce78b40a2d77ac2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 16 Jan 2023 09:25:06 +0100 Subject: [PATCH 03/20] Remove sky connect config entry if USB stick is not plugged in (#85765) * Remove sky connect config entry if USB stick is not plugged in * Tweak cleanup * Give some stuff more cromulent names * Do the needful * Add tests * Tweak --- .../homeassistant_sky_connect/__init__.py | 48 ++++++++++++---- homeassistant/components/usb/__init__.py | 42 +++++++++++++- .../test_hardware.py | 8 ++- .../homeassistant_sky_connect/test_init.py | 29 +++++++++- tests/components/usb/test_init.py | 56 +++++++++++++++++++ 5 files changed, 164 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 08d54bdef12..af6df6b519d 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -16,7 +16,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN @@ -25,12 +25,10 @@ from .util import get_usb_service_info _LOGGER = logging.getLogger(__name__) -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" +async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Wait for multi-PAN info to be available.""" if not is_hassio(hass): - return None + return addon_manager: AddonManager = get_addon_manager(hass) try: @@ -50,7 +48,18 @@ async def _multi_pan_addon_info( ) raise ConfigEntryNotReady - if addon_info.state == AddonState.NOT_INSTALLED: + +async def _multi_pan_addon_info( + hass: HomeAssistant, entry: ConfigEntry +) -> AddonInfo | None: + """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" + if not is_hassio(hass): + return None + + addon_manager: AddonManager = get_addon_manager(hass) + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + + if addon_info.state != AddonState.RUNNING: return None usb_dev = entry.data["device"] @@ -62,8 +71,8 @@ async def _multi_pan_addon_info( return addon_info -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up a Home Assistant Sky Connect config entry.""" +async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Finish Home Assistant Sky Connect config entry setup.""" matcher = usb.USBCallbackMatcher( domain=DOMAIN, vid=entry.data["vid"].upper(), @@ -74,8 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) if not usb.async_is_plugged_in(hass, matcher): - # The USB dongle is not plugged in - raise ConfigEntryNotReady + # The USB dongle is not plugged in, remove the config entry + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return addon_info = await _multi_pan_addon_info(hass, entry) @@ -86,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: context={"source": "usb"}, data=usb_info, ) - return True + return hw_discovery_data = { "name": "Sky Connect Multi-PAN", @@ -101,6 +111,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data=hw_discovery_data, ) + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Home Assistant Sky Connect config entry.""" + + await _wait_multi_pan_addon(hass, entry) + + @callback + def async_usb_scan_done() -> None: + """Handle usb discovery started.""" + hass.async_create_task(_async_usb_scan_done(hass, entry)) + + unsub_usb = usb.async_register_initial_scan_callback(hass, async_usb_scan_done) + entry.async_on_unload(unsub_usb) + return True diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 0f81d2e42d6..17d6f679cf0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -61,6 +61,18 @@ def async_register_scan_request_callback( return discovery.async_register_scan_request_callback(callback) +@hass_callback +def async_register_initial_scan_callback( + hass: HomeAssistant, callback: CALLBACK_TYPE +) -> CALLBACK_TYPE: + """Register to receive a callback when the initial USB scan is done. + + If the initial scan is already done, the callback is called immediately. + """ + discovery: USBDiscovery = hass.data[DOMAIN] + return discovery.async_register_initial_scan_callback(callback) + + @hass_callback def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool: """Return True is a USB device is present.""" @@ -186,6 +198,8 @@ class USBDiscovery: self.observer_active = False self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_callbacks: list[CALLBACK_TYPE] = [] + self.initial_scan_done = False + self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] async def async_setup(self) -> None: """Set up USB Discovery.""" @@ -249,7 +263,7 @@ class USBDiscovery: self, _callback: CALLBACK_TYPE, ) -> CALLBACK_TYPE: - """Register a callback.""" + """Register a scan request callback.""" self._request_callbacks.append(_callback) @hass_callback @@ -258,6 +272,26 @@ class USBDiscovery: return _async_remove_callback + @hass_callback + def async_register_initial_scan_callback( + self, + callback: CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register an initial scan callback.""" + if self.initial_scan_done: + callback() + return lambda: None + + self._initial_scan_callbacks.append(callback) + + @hass_callback + def _async_remove_callback() -> None: + if callback not in self._initial_scan_callbacks: + return + self._initial_scan_callbacks.remove(callback) + + return _async_remove_callback + @hass_callback def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" @@ -307,6 +341,12 @@ class USBDiscovery: async def _async_scan_serial(self) -> None: """Scan serial ports.""" self._async_process_ports(await self.hass.async_add_executor_job(comports)) + if self.initial_scan_done: + return + + self.initial_scan_done = True + while self._initial_scan_callbacks: + self._initial_scan_callbacks.pop()() async def _async_scan(self) -> None: """Scan for USB devices and notify callbacks to scan as well.""" diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index 01f0e6ac5d7..09e650388c5 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -2,9 +2,10 @@ from unittest.mock import patch from homeassistant.components.homeassistant_sky_connect.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.common import MockConfigEntry CONFIG_ENTRY_DATA = { "device": "bla_device", @@ -29,7 +30,8 @@ async def test_hardware_info( hass: HomeAssistant, hass_ws_client, addon_store_info ) -> None: """Test we can get the board info.""" - mock_integration(hass, MockModule("usb")) + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) # Setup the config entry config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index ebf1c74d9e0..c47066e8bc9 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -9,7 +9,8 @@ from homeassistant.components import zha from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -55,6 +56,9 @@ async def test_setup_entry( num_flows, ) -> None: """Test setup of a config entry, including setup of zha.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # Setup the config entry config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -100,6 +104,9 @@ async def test_setup_zha( mock_zha_config_flow_setup, hass: HomeAssistant, addon_store_info ) -> None: """Test zha gets the right config.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # Setup the config entry config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -146,6 +153,9 @@ async def test_setup_zha_multipan( hass: HomeAssistant, addon_info, addon_running ) -> None: """Test zha gets the right config.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + addon_info.return_value["options"]["device"] = CONFIG_ENTRY_DATA["device"] # Setup the config entry @@ -197,6 +207,9 @@ async def test_setup_zha_multipan_other_device( mock_zha_config_flow_setup, hass: HomeAssistant, addon_info, addon_running ) -> None: """Test zha gets the right config.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + addon_info.return_value["options"]["device"] = "/dev/not_our_sky_connect" # Setup the config entry @@ -258,16 +271,23 @@ async def test_setup_entry_wait_usb(hass: HomeAssistant) -> None: "homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in", return_value=False, ) as mock_is_plugged_in: - assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + # USB discovery starts, config entry should be removed + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(mock_is_plugged_in.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 async def test_setup_entry_addon_info_fails( hass: HomeAssistant, addon_store_info ) -> None: """Test setup of a config entry when fetching addon info fails.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + addon_store_info.side_effect = HassioAPIError("Boom") # Setup the config entry @@ -296,6 +316,9 @@ async def test_setup_entry_addon_not_running( hass: HomeAssistant, addon_installed, start_addon ) -> None: """Test the addon is started if it is not running.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # Setup the config entry config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index c7196fed0c5..ab9f00a6a5b 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -936,3 +936,59 @@ async def test_web_socket_triggers_discovery_request_callbacks(hass, hass_ws_cli assert response["success"] await hass.async_block_till_done() assert len(mock_callback.mock_calls) == 1 + + +async def test_initial_scan_callback(hass, hass_ws_client): + """Test it's possible to register a callback when the initial scan is done.""" + mock_callback_1 = Mock() + mock_callback_2 = Mock() + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=[] + ), patch("homeassistant.components.usb.comports", return_value=[]), patch.object( + hass.config_entries.flow, "async_init" + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + cancel_1 = usb.async_register_initial_scan_callback(hass, mock_callback_1) + assert len(mock_callback_1.mock_calls) == 0 + + await hass.async_block_till_done() + assert len(mock_callback_1.mock_calls) == 0 + + # This triggers the initial scan + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(mock_callback_1.mock_calls) == 1 + + # A callback registered now should be called immediately. The old callback + # should not be called again + cancel_2 = usb.async_register_initial_scan_callback(hass, mock_callback_2) + assert len(mock_callback_1.mock_calls) == 1 + assert len(mock_callback_2.mock_calls) == 1 + + # Calling the cancels should be allowed even if the callback has been called + cancel_1() + cancel_2() + + +async def test_cancel_initial_scan_callback(hass, hass_ws_client): + """Test it's possible to cancel an initial scan callback.""" + mock_callback = Mock() + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=[] + ), patch("homeassistant.components.usb.comports", return_value=[]), patch.object( + hass.config_entries.flow, "async_init" + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + cancel = usb.async_register_initial_scan_callback(hass, mock_callback) + assert len(mock_callback.mock_calls) == 0 + + await hass.async_block_till_done() + assert len(mock_callback.mock_calls) == 0 + cancel() + + # This triggers the initial scan + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(mock_callback.mock_calls) == 0 From caa8f9e49b2e001600b7aef6ca1a61097df3f3d5 Mon Sep 17 00:00:00 2001 From: Yuval Aboulafia Date: Thu, 12 Jan 2023 21:10:45 +0200 Subject: [PATCH 04/20] Remove WAQI unsupported UOM (#85768) fixes undefined --- homeassistant/components/waqi/sensor.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index c9cc527387a..e91e3da5aa5 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -24,7 +24,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -53,7 +52,6 @@ KEY_TO_ATTR = { ATTRIBUTION = "Data provided by the World Air Quality Index project" ATTR_ICON = "mdi:cloud" -ATTR_UNIT = "AQI" CONF_LOCATIONS = "locations" CONF_STATIONS = "stations" @@ -62,7 +60,7 @@ SCAN_INTERVAL = timedelta(minutes=5) TIMEOUT = 10 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( { vol.Optional(CONF_STATIONS): cv.ensure_list, vol.Required(CONF_TOKEN): cv.string, @@ -110,7 +108,6 @@ class WaqiSensor(SensorEntity): """Implementation of a WAQI sensor.""" _attr_icon = ATTR_ICON - _attr_native_unit_of_measurement = ATTR_UNIT _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT From 502fea5f95897379db1c231e6016b2496540686d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Jan 2023 12:17:00 -1000 Subject: [PATCH 05/20] Bump pySwitchbot to 0.36.4 (#85777) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index b543e7f15e7..05dca82b3f9 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -2,7 +2,7 @@ "domain": "switchbot", "name": "SwitchBot", "documentation": "https://www.home-assistant.io/integrations/switchbot", - "requirements": ["PySwitchbot==0.36.3"], + "requirements": ["PySwitchbot==0.36.4"], "config_flow": true, "dependencies": ["bluetooth"], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 8e6e063ed8c..e3c774edd87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.36.3 +PySwitchbot==0.36.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6e06374bcb..64bf47763b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.36.3 +PySwitchbot==0.36.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 2447e246779828d7349d1b2908eff9b972aaecab Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 12 Jan 2023 15:20:16 -0800 Subject: [PATCH 06/20] Remove oauth2client dependency in Google Assistant SDK (#85785) Remove import oauth2client, inline 2 constants --- .../application_credentials.py | 6 ++---- .../google_assistant_sdk/test_config_flow.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/application_credentials.py b/homeassistant/components/google_assistant_sdk/application_credentials.py index 74cefb14b65..c8a7922bc7a 100644 --- a/homeassistant/components/google_assistant_sdk/application_credentials.py +++ b/homeassistant/components/google_assistant_sdk/application_credentials.py @@ -1,6 +1,4 @@ """application_credentials platform for Google Assistant SDK.""" -import oauth2client - from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant @@ -8,8 +6,8 @@ from homeassistant.core import HomeAssistant async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: """Return authorization server.""" return AuthorizationServer( - oauth2client.GOOGLE_AUTH_URI, - oauth2client.GOOGLE_TOKEN_URI, + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", ) diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py index af5f0e73c75..56386df6824 100644 --- a/tests/components/google_assistant_sdk/test_config_flow.py +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -1,8 +1,6 @@ """Test the Google Assistant SDK config flow.""" from unittest.mock import patch -import oauth2client - from homeassistant import config_entries from homeassistant.components.google_assistant_sdk.const import DOMAIN from homeassistant.core import HomeAssistant @@ -12,6 +10,8 @@ from .conftest import CLIENT_ID, ComponentSetup from tests.common import MockConfigEntry +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" TITLE = "Google Assistant SDK" @@ -35,7 +35,7 @@ async def test_full_flow( ) assert result["url"] == ( - f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" "&access_type=offline&prompt=consent" @@ -47,7 +47,7 @@ async def test_full_flow( assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( - oauth2client.GOOGLE_TOKEN_URI, + GOOGLE_TOKEN_URI, json={ "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -112,7 +112,7 @@ async def test_reauth( }, ) assert result["url"] == ( - f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" "&access_type=offline&prompt=consent" @@ -123,7 +123,7 @@ async def test_reauth( assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( - oauth2client.GOOGLE_TOKEN_URI, + GOOGLE_TOKEN_URI, json={ "refresh_token": "mock-refresh-token", "access_token": "updated-access-token", @@ -181,7 +181,7 @@ async def test_single_instance_allowed( ) assert result["url"] == ( - f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" "&access_type=offline&prompt=consent" @@ -193,7 +193,7 @@ async def test_single_instance_allowed( assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( - oauth2client.GOOGLE_TOKEN_URI, + GOOGLE_TOKEN_URI, json={ "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", From b0153c7debcf2371f3d65f00ea89882ae3f3f8ff Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 13 Jan 2023 23:50:02 +0200 Subject: [PATCH 07/20] Fix WebOS TV image fetch SSL verify failure (#85841) --- .../components/webostv/media_player.py | 27 ++++++++ tests/components/webostv/test_media_player.py | 66 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 339124142b1..1d7c92741a8 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -1,14 +1,18 @@ """Support for interface with an LG webOS Smart TV.""" from __future__ import annotations +import asyncio from collections.abc import Awaitable, Callable, Coroutine from contextlib import suppress from datetime import timedelta from functools import wraps +from http import HTTPStatus import logging +from ssl import SSLContext from typing import Any, TypeVar, cast from aiowebostv import WebOsClient, WebOsTvPairError +import async_timeout from typing_extensions import Concatenate, ParamSpec from homeassistant import util @@ -28,6 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -466,3 +471,25 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): async def async_command(self, command: str, **kwargs: Any) -> None: """Send a command.""" await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) + + async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]: + """Retrieve an image. + + webOS uses self-signed certificates, thus we need to use an empty + SSLContext to bypass validation errors if url starts with https. + """ + content = None + ssl_context = None + if url.startswith("https"): + ssl_context = SSLContext() + + websession = async_get_clientsession(self.hass) + with suppress(asyncio.TimeoutError), async_timeout.timeout(10): + response = await websession.get(url, ssl=ssl_context) + if response.status == HTTPStatus.OK: + content = await response.read() + + if content is None: + _LOGGER.warning("Error retrieving proxied image from %s", url) + + return content, None diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 70549f5d4e8..e4e2e2ba45f 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -1,6 +1,7 @@ """The tests for the LG webOS media player platform.""" import asyncio from datetime import timedelta +from http import HTTPStatus from unittest.mock import Mock import pytest @@ -697,3 +698,68 @@ async def test_supported_features_ignore_cache(hass, client): attrs = hass.states.get(ENTITY_ID).attributes assert attrs[ATTR_SUPPORTED_FEATURES] == supported + + +async def test_get_image_http( + hass, client, hass_client_no_auth, aioclient_mock, monkeypatch +): + """Test get image via http.""" + url = "http://something/valid_icon" + monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + await setup_webostv(hass) + await client.mock_state_update() + + attrs = hass.states.get(ENTITY_ID).attributes + assert "entity_picture_local" not in attrs + + aioclient_mock.get(url, text="image") + client = await hass_client_no_auth() + + resp = await client.get(attrs["entity_picture"]) + content = await resp.read() + + assert content == b"image" + + +async def test_get_image_http_error( + hass, client, hass_client_no_auth, aioclient_mock, caplog, monkeypatch +): + """Test get image via http error.""" + url = "http://something/icon_error" + monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + await setup_webostv(hass) + await client.mock_state_update() + + attrs = hass.states.get(ENTITY_ID).attributes + assert "entity_picture_local" not in attrs + + aioclient_mock.get(url, exc=asyncio.TimeoutError()) + client = await hass_client_no_auth() + + resp = await client.get(attrs["entity_picture"]) + content = await resp.read() + + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert f"Error retrieving proxied image from {url}" in caplog.text + assert content == b"" + + +async def test_get_image_https( + hass, client, hass_client_no_auth, aioclient_mock, monkeypatch +): + """Test get image via http.""" + url = "https://something/valid_icon_https" + monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + await setup_webostv(hass) + await client.mock_state_update() + + attrs = hass.states.get(ENTITY_ID).attributes + assert "entity_picture_local" not in attrs + + aioclient_mock.get(url, text="https_image") + client = await hass_client_no_auth() + + resp = await client.get(attrs["entity_picture"]) + content = await resp.read() + + assert content == b"https_image" From d61b9152860f94315ccd24c8e485c9e75e61b58f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Jan 2023 14:03:16 -1000 Subject: [PATCH 08/20] Bump aiohomekit to 2.4.4 (#85853) fixes https://github.com/home-assistant/core/issues/85400 fixes https://github.com/home-assistant/core/issues/84023 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 195e3330c7c..aa343b045ce 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==2.4.3"], + "requirements": ["aiohomekit==2.4.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }], "dependencies": ["bluetooth", "zeroconf"], diff --git a/requirements_all.txt b/requirements_all.txt index e3c774edd87..d68f8b67459 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -177,7 +177,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.4.3 +aiohomekit==2.4.4 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64bf47763b7..a7e9747c28d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.4.3 +aiohomekit==2.4.4 # homeassistant.components.emulated_hue # homeassistant.components.http From fcf53668c50fa64dcd0002df101302b9f79993c7 Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:12:27 +0100 Subject: [PATCH 09/20] Skip over files without mime type in Jellyfin (#85874) * Skip over files without mime type * Skip over tracks without mime type --- .../components/jellyfin/media_source.py | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index dfb5bd82924..b81b5d81445 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -1,7 +1,9 @@ """The Media Source implementation for the Jellyfin integration.""" from __future__ import annotations +import logging import mimetypes +import os from typing import Any from jellyfin_apiclient_python.api import jellyfin_url @@ -41,6 +43,8 @@ from .const import ( ) from .models import JellyfinData +_LOGGER = logging.getLogger(__name__) + async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Jellyfin media source.""" @@ -75,6 +79,9 @@ class JellyfinSource(MediaSource): stream_url = self._get_stream_url(media_item) mime_type = _media_mime_type(media_item) + # Media Sources without a mime type have been filtered out during library creation + assert mime_type is not None + return PlayMedia(stream_url, mime_type) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: @@ -240,7 +247,11 @@ class JellyfinSource(MediaSource): k.get(ITEM_KEY_INDEX_NUMBER, None), ), ) - return [self._build_track(track) for track in tracks] + return [ + self._build_track(track) + for track in tracks + if _media_mime_type(track) is not None + ] def _build_track(self, track: dict[str, Any]) -> BrowseMediaSource: """Return a single track as a browsable media source.""" @@ -289,7 +300,11 @@ class JellyfinSource(MediaSource): """Return all movies in the movie library.""" movies = await self._get_children(library_id, ITEM_TYPE_MOVIE) movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] - return [self._build_movie(movie) for movie in movies] + return [ + self._build_movie(movie) + for movie in movies + if _media_mime_type(movie) is not None + ] def _build_movie(self, movie: dict[str, Any]) -> BrowseMediaSource: """Return a single movie as a browsable media source.""" @@ -349,20 +364,24 @@ class JellyfinSource(MediaSource): raise BrowseError(f"Unsupported media type {media_type}") -def _media_mime_type(media_item: dict[str, Any]) -> str: +def _media_mime_type(media_item: dict[str, Any]) -> str | None: """Return the mime type of a media item.""" if not media_item.get(ITEM_KEY_MEDIA_SOURCES): - raise BrowseError("Unable to determine mime type for item without media source") + _LOGGER.debug("Unable to determine mime type for item without media source") + return None media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0] if MEDIA_SOURCE_KEY_PATH not in media_source: - raise BrowseError("Unable to determine mime type for media source without path") + _LOGGER.debug("Unable to determine mime type for media source without path") + return None path = media_source[MEDIA_SOURCE_KEY_PATH] mime_type, _ = mimetypes.guess_type(path) if mime_type is None: - raise BrowseError(f"Unable to determine mime type for path {path}") + _LOGGER.debug( + "Unable to determine mime type for path %s", os.path.basename(path) + ) return mime_type From fa09eba165a98c374af4628f906bc197c7656ab7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 1 Jan 2023 06:19:31 -0800 Subject: [PATCH 10/20] Bump google-nest-sdm to 2.1.2 (#84926) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index f0e86456fd7..58db88599fa 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -5,7 +5,7 @@ "dependencies": ["ffmpeg", "http", "application_credentials"], "after_dependencies": ["media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.1.0"], + "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.1.2"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index d68f8b67459..252b436ba2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -794,7 +794,7 @@ google-cloud-pubsub==2.13.11 google-cloud-texttospeech==2.12.3 # homeassistant.components.nest -google-nest-sdm==2.1.0 +google-nest-sdm==2.1.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7e9747c28d..1737d0202c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -601,7 +601,7 @@ goodwe==0.2.18 google-cloud-pubsub==2.13.11 # homeassistant.components.nest -google-nest-sdm==2.1.0 +google-nest-sdm==2.1.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 627ded42f5994f39c96696fc87a9e793555214b1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 14 Jan 2023 19:30:21 -0800 Subject: [PATCH 11/20] Bump google-nest-sdm to 2.2.2 (#85899) * Bump google-nest-sdm to 2.2.0 * Bump nest to 2.2.1 * Bump google-nest-sdm to 2.2.2 --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 58db88599fa..0d02e00dbbf 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -5,7 +5,7 @@ "dependencies": ["ffmpeg", "http", "application_credentials"], "after_dependencies": ["media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.1.2"], + "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.2"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 252b436ba2c..b310f080b08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -794,7 +794,7 @@ google-cloud-pubsub==2.13.11 google-cloud-texttospeech==2.12.3 # homeassistant.components.nest -google-nest-sdm==2.1.2 +google-nest-sdm==2.2.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1737d0202c8..3746bb667f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -601,7 +601,7 @@ goodwe==0.2.18 google-cloud-pubsub==2.13.11 # homeassistant.components.nest -google-nest-sdm==2.1.2 +google-nest-sdm==2.2.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 9a6aaea9db6e8480b3a21d2b6295889a81a48546 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 16 Jan 2023 10:52:43 -0800 Subject: [PATCH 12/20] Add a timeout during OAuth token exchange and additional debug logging (#85911) --- homeassistant/components/nest/config_flow.py | 2 + .../helpers/config_entry_oauth2_flow.py | 23 +++++-- homeassistant/strings.json | 1 + .../helpers/test_config_entry_oauth2_flow.py | 61 ++++++++++++++++++- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index a837290249e..6c1768d9855 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -211,6 +211,7 @@ class NestFlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Complete OAuth setup and finish pubsub or finish.""" + _LOGGER.debug("Finishing post-oauth configuration") assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" self._data.update(data) if self.source == SOURCE_REAUTH: @@ -459,6 +460,7 @@ class NestFlowHandler( async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult: """Create an entry for the SDM flow.""" + _LOGGER.debug("Creating/updating configuration entry") assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" # Update existing config entry when in the reauth flow. if entry := self._async_reauth_entry(): diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 4b135ae6a2f..0a6356d310d 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -41,6 +41,9 @@ MY_AUTH_CALLBACK_PATH = "https://my.home-assistant.io/redirect/oauth" CLOCK_OUT_OF_SYNC_MAX_SEC = 20 +OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 +OAUTH_TOKEN_TIMEOUT_SEC = 30 + class AbstractOAuth2Implementation(ABC): """Base class to abstract OAuth2 authentication.""" @@ -194,6 +197,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): if self.client_secret is not None: data["client_secret"] = self.client_secret + _LOGGER.debug("Sending token request to %s", self.token_url) resp = await session.post(self.token_url, data=data) if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG): body = await resp.text() @@ -283,9 +287,10 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): return self.async_external_step_done(next_step_id=next_step) try: - async with async_timeout.timeout(10): + async with async_timeout.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC): url = await self.async_generate_authorize_url() - except asyncio.TimeoutError: + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout generating authorize url: %s", err) return self.async_abort(reason="authorize_url_timeout") except NoURLAvailableError: return self.async_abort( @@ -303,7 +308,17 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Create config entry from external data.""" - token = await self.flow_impl.async_resolve_external_data(self.external_data) + _LOGGER.debug("Creating config entry from external data") + + try: + async with async_timeout.timeout(OAUTH_TOKEN_TIMEOUT_SEC): + token = await self.flow_impl.async_resolve_external_data( + self.external_data + ) + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout resolving OAuth token: %s", err) + return self.async_abort(reason="oauth2_timeout") + # Force int for non-compliant oauth2 providers try: token["expires_in"] = int(token["expires_in"]) @@ -436,7 +451,7 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): await hass.config_entries.flow.async_configure( flow_id=state["flow_id"], user_input=user_input ) - + _LOGGER.debug("Resumed OAuth configuration flow") return web.Response( headers={"content-type": "text/html"}, text="", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 1c38fb6d064..c00b51ed6cf 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -71,6 +71,7 @@ "no_devices_found": "No devices found on the network", "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.", "oauth2_error": "Received invalid token data.", + "oauth2_timeout": "Timeout resolving OAuth token.", "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", "oauth2_missing_credentials": "The integration requires application credentials.", "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 652ce69e57d..f64525ecdd3 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -134,8 +134,9 @@ async def test_abort_if_authorization_timeout( flow = flow_handler() flow.hass = hass - with patch.object( - local_impl, "async_generate_authorize_url", side_effect=asyncio.TimeoutError + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout", + side_effect=asyncio.TimeoutError, ): result = await flow.async_step_user() @@ -278,6 +279,62 @@ async def test_abort_if_oauth_rejected( assert result["description_placeholders"] == {"error": "access_denied"} +async def test_abort_on_oauth_timeout_error( + hass, + flow_handler, + local_impl, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, +): + """Check timeout during oauth token exchange.""" + 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.FlowResultType.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.FlowResultType.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?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout", + side_effect=asyncio.TimeoutError, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "oauth2_timeout" + + async def test_step_discovery(hass, flow_handler, local_impl): """Check flow triggers from discovery.""" flow_handler.async_register_implementation(hass, local_impl) From a318576c4f7a2eb820f351e55c4dbfcc4d3341bf Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 15 Jan 2023 13:26:28 +0200 Subject: [PATCH 13/20] Bump aiowebostv to 0.3.1 to fix support for older devices (#85916) Bump aiowebostv to 0.3.1 --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index b4e761b067a..da05a974710 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -3,7 +3,7 @@ "name": "LG webOS Smart TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/webostv", - "requirements": ["aiowebostv==0.3.0"], + "requirements": ["aiowebostv==0.3.1"], "codeowners": ["@bendavid", "@thecode"], "ssdp": [{ "st": "urn:lge-com:service:webos-second-screen:1" }], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index b310f080b08..4698678cb61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -297,7 +297,7 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.3.0 +aiowebostv==0.3.1 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3746bb667f0..f7f024c6863 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.3.0 +aiowebostv==0.3.1 # homeassistant.components.yandex_transport aioymaps==1.2.2 From b459261ef2cad523ffc8455f9858e26333eb021d Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 15 Jan 2023 13:29:11 +0200 Subject: [PATCH 14/20] Fix webOS TV SSDP discovery missing friendly name (#85917) --- homeassistant/components/webostv/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 85da9250539..d04e8a54121 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -116,7 +116,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): host = urlparse(discovery_info.ssdp_location).hostname assert host self._host = host - self._name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME) uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] assert uuid From aa43acb443f9c1980c73d357675431a6fe5d0539 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 15 Jan 2023 20:14:36 +0200 Subject: [PATCH 15/20] Update webOS TV codeowners (#85959) --- CODEOWNERS | 4 ++-- homeassistant/components/webostv/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 464bb252a2e..8fe0ce4e831 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1293,8 +1293,8 @@ build.json @home-assistant/supervisor /tests/components/weather/ @home-assistant/core /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core -/homeassistant/components/webostv/ @bendavid @thecode -/tests/components/webostv/ @bendavid @thecode +/homeassistant/components/webostv/ @thecode +/tests/components/webostv/ @thecode /homeassistant/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core /homeassistant/components/wemo/ @esev diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index da05a974710..8c957bd3a09 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/webostv", "requirements": ["aiowebostv==0.3.1"], - "codeowners": ["@bendavid", "@thecode"], + "codeowners": ["@thecode"], "ssdp": [{ "st": "urn:lge-com:service:webos-second-screen:1" }], "quality_scale": "platinum", "iot_class": "local_push", From 4138e518ef6ae50ba49dc1fc5667296a603b556f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 16 Jan 2023 20:51:00 +0200 Subject: [PATCH 16/20] Bump aiowebostv to 0.3.2 (#86031) fixes undefined --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 8c957bd3a09..26fca61efe0 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -3,7 +3,7 @@ "name": "LG webOS Smart TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/webostv", - "requirements": ["aiowebostv==0.3.1"], + "requirements": ["aiowebostv==0.3.2"], "codeowners": ["@thecode"], "ssdp": [{ "st": "urn:lge-com:service:webos-second-screen:1" }], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 4698678cb61..9305f61cebb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -297,7 +297,7 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.3.1 +aiowebostv==0.3.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7f024c6863..c4a8886141c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.3.1 +aiowebostv==0.3.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 From 96578f3f892721bdefb2ed31464e8f6645ae4448 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Jan 2023 11:21:49 -1000 Subject: [PATCH 17/20] Handle ignored shelly entries when discovering via zeroconf (#86039) fixes https://github.com/home-assistant/core/issues/85879 --- .../components/shelly/config_flow.py | 2 +- tests/components/shelly/test_config_flow.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 70d2c2492e8..f6be4a254c6 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -217,7 +217,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Abort and reconnect soon if the device with the mac address is already configured.""" if ( current_entry := await self.async_set_unique_id(mac) - ) and current_entry.data[CONF_HOST] == host: + ) and current_entry.data.get(CONF_HOST) == host: await async_reconnect_soon(self.hass, current_entry) if host == INTERNAL_WIFI_AP_IP: # If the device is broadcasting the internal wifi ap ip diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 1c0a32853e1..7338747cbaf 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -706,6 +706,30 @@ async def test_zeroconf_already_configured(hass): assert entry.data["host"] == "1.1.1.1" +async def test_zeroconf_ignored(hass): + """Test zeroconf when the device was previously ignored.""" + + entry = MockConfigEntry( + domain="shelly", + unique_id="test-mac", + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + with patch( + "aioshelly.common.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_zeroconf_with_wifi_ap_ip(hass): """Test we ignore the Wi-FI AP IP.""" From 5656129b604c51b6ef82047ba8d636b0aec2fcde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 16 Jan 2023 22:16:34 +0100 Subject: [PATCH 18/20] Update pyTibber to 0.26.8 (#86044) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 115f3ed7d2e..403b0f2b4fc 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,7 +3,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.26.8"], + "requirements": ["pyTibber==0.26.9"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 9305f61cebb..155cc3a9830 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1439,7 +1439,7 @@ pyRFXtrx==0.30.0 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.26.8 +pyTibber==0.26.9 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4a8886141c..33da328a6da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1039,7 +1039,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.0 # homeassistant.components.tibber -pyTibber==0.26.8 +pyTibber==0.26.9 # homeassistant.components.nextbus py_nextbusnext==0.1.5 From 6a7e6ad0fdbcccfd761f5b4e09a15c9c1f545866 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Jan 2023 21:47:10 -0500 Subject: [PATCH 19/20] Bumped version to 2023.1.5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2e8992e14b1..d9eb4bb8534 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __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/pyproject.toml b/pyproject.toml index ec14caa7515..c1d8bb8caa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.1.4" +version = "2023.1.5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 669e6202adb134943a8a9ae785750ac7c27d36e0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 10 Jan 2023 09:44:53 +0100 Subject: [PATCH 20/20] bump reolink-aio to 0.2.1 (#85571) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index dfa8dfe8e6b..6fb26ea60fe 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink IP NVR/camera", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/reolink", - "requirements": ["reolink-aio==0.1.3"], + "requirements": ["reolink-aio==0.2.1"], "codeowners": ["@starkillerOG"], "iot_class": "local_polling", "loggers": ["reolink-aio"] diff --git a/requirements_all.txt b/requirements_all.txt index 155cc3a9830..5e812d80058 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2190,7 +2190,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.1.3 +reolink-aio==0.2.1 # homeassistant.components.python_script restrictedpython==5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33da328a6da..214c868043c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1529,7 +1529,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.1.3 +reolink-aio==0.2.1 # homeassistant.components.python_script restrictedpython==5.2