diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 9419f00e41e..19e6b5ec7b3 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -21,12 +21,22 @@ from homeassistant.components.recorder import ( DOMAIN as RECORDER_DOMAIN, get_instance as get_recorder_instance, ) +import homeassistant.config as conf_util +from homeassistant.config_entries import ( + SOURCE_IGNORE, +) from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info -from homeassistant.loader import IntegrationNotFound, async_get_integrations +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_integrations, +) from homeassistant.setup import async_get_loaded_integrations from .const import ( @@ -206,8 +216,25 @@ class Analytics: if self.preferences.get(ATTR_USAGE, False) or self.preferences.get( ATTR_STATISTICS, False ): + ent_reg = er.async_get(self.hass) + + try: + yaml_configuration = await conf_util.async_hass_config_yaml(self.hass) + except HomeAssistantError as err: + LOGGER.error(err) + return + + configuration_set = set(yaml_configuration) + er_platforms = { + entity.platform + for entity in ent_reg.entities.values() + if not entity.disabled + } + domains = async_get_loaded_integrations(self.hass) configured_integrations = await async_get_integrations(self.hass, domains) + enabled_domains = set(configured_integrations) + for integration in configured_integrations.values(): if isinstance(integration, IntegrationNotFound): continue @@ -215,7 +242,11 @@ class Analytics: if isinstance(integration, BaseException): raise integration - if integration.disabled: + if not self._async_should_report_integration( + integration=integration, + yaml_domains=configuration_set, + entity_registry_platforms=er_platforms, + ): continue if not integration.is_built_in: @@ -253,12 +284,12 @@ class Analytics: if supervisor_info is not None: payload[ATTR_ADDONS] = addons - if ENERGY_DOMAIN in integrations: + if ENERGY_DOMAIN in enabled_domains: payload[ATTR_ENERGY] = { ATTR_CONFIGURED: await energy_is_configured(self.hass) } - if RECORDER_DOMAIN in integrations: + if RECORDER_DOMAIN in enabled_domains: instance = get_recorder_instance(self.hass) engine = instance.database_engine if engine and engine.version is not None: @@ -306,3 +337,34 @@ class Analytics: LOGGER.error( "Error sending analytics to %s: %r", ANALYTICS_ENDPOINT_URL, err ) + + @callback + def _async_should_report_integration( + self, + integration: Integration, + yaml_domains: set[str], + entity_registry_platforms: set[str], + ) -> bool: + """Return a bool to indicate if this integration should be reported.""" + if integration.disabled: + return False + + # Check if the integration is defined in YAML or in the entity registry + if ( + integration.domain in yaml_domains + or integration.domain in entity_registry_platforms + ): + return True + + # Check if the integration provide a config flow + if not integration.config_flow: + return False + + entries = self.hass.config_entries.async_entries(integration.domain) + + # Filter out ignored and disabled entries + return any( + entry + for entry in entries + if entry.source != SOURCE_IGNORE and entry.disabled_by is None + ) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 412553e81cc..4e51880c754 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1,5 +1,7 @@ """The tests for the analytics .""" -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +import asyncio +from typing import Any +from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch import aiohttp import pytest @@ -14,11 +16,14 @@ from homeassistant.components.analytics.const import ( ATTR_USAGE, ) from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker MOCK_UUID = "abcdefg" @@ -27,6 +32,11 @@ MOCK_VERSION_DEV = "1970.1.0.dev0" MOCK_VERSION_NIGHTLY = "1970.1.0.dev19700101" +def _last_call_payload(aioclient: AiohttpClientMocker) -> dict[str, Any]: + """Return the payload of the last call.""" + return aioclient.mock_calls[-1][2] + + async def test_no_send( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -206,18 +216,36 @@ async def test_send_usage( assert analytics.preferences[ATTR_USAGE] hass.config.components = ["default_config"] - with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): + with patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.config.load_yaml_config_file", + return_value={"default_config": {}}, + ), patch( + "homeassistant.components.analytics.analytics.async_get_system_info", + return_value={"installation_type": "Home Assistant Tests"}, + ): await analytics.send_analytics() - assert "'integrations': ['default_config']" in caplog.text - assert "'integration_count':" not in caplog.text - assert "'certificate': False" in caplog.text + assert ( + "Submitted analytics to Home Assistant servers. Information submitted includes" + in caplog.text + ) + assert _last_call_payload(aioclient_mock) == { + "uuid": ANY, + "version": MOCK_VERSION, + "installation_type": "Home Assistant Tests", + "certificate": False, + "integrations": ["default_config"], + "custom_integrations": [], + } async def test_send_usage_with_supervisor( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send usage with supervisor preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -284,7 +312,12 @@ async def test_send_statistics( assert analytics.preferences[ATTR_STATISTICS] hass.config.components = ["default_config"] - with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): + with patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.config.load_yaml_config_file", + return_value={"default_config": {}}, + ): await analytics.send_analytics() assert ( "'state_count': 0, 'automation_count': 0, 'integration_count': 1," @@ -297,6 +330,7 @@ async def test_send_statistics_one_integration_fails( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -317,10 +351,85 @@ async def test_send_statistics_one_integration_fails( assert post_call[2]["integration_count"] == 0 +async def test_send_statistics_disabled_integration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, +) -> None: + """Test send statistics with disabled integration.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) + assert analytics.preferences[ATTR_BASE] + assert analytics.preferences[ATTR_STATISTICS] + hass.config.components = ["default_config"] + + with patch( + "homeassistant.components.analytics.analytics.async_get_integrations", + return_value={ + "disabled_integration_manifest": mock_integration( + hass, + MockModule( + "disabled_integration", + async_setup=AsyncMock(return_value=True), + partial_manifest={"disabled": "system"}, + ), + ) + }, + ), patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): + await analytics.send_analytics() + + payload = _last_call_payload(aioclient_mock) + assert "uuid" in payload + assert payload["integration_count"] == 0 + + +async def test_send_statistics_ignored_integration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, +) -> None: + """Test send statistics with ignored integration.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) + assert analytics.preferences[ATTR_BASE] + assert analytics.preferences[ATTR_STATISTICS] + + mock_config_entry = MockConfigEntry( + domain="ignored_integration", + state=ConfigEntryState.LOADED, + source="ignore", + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.analytics.analytics.async_get_integrations", + return_value={ + "ignored_integration": mock_integration( + hass, + MockModule( + "ignored_integration", + async_setup=AsyncMock(return_value=True), + partial_manifest={"config_flow": True}, + ), + ), + }, + ), patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): + await analytics.send_analytics() + + payload = _last_call_payload(aioclient_mock) + assert "uuid" in payload + assert payload["integration_count"] == 0 + + async def test_send_statistics_async_get_integration_unknown_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -341,6 +450,7 @@ async def test_send_statistics_with_supervisor( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -421,7 +531,12 @@ async def test_custom_integrations( assert await async_setup_component(hass, "test_package", {"test_package": {}}) await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) - with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): + with patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.config.load_yaml_config_file", + return_value={"test_package": {}}, + ): await analytics.send_analytics() payload = aioclient_mock.mock_calls[0][2] @@ -486,7 +601,9 @@ async def test_nightly_endpoint( async def test_send_with_no_energy( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -510,7 +627,10 @@ async def test_send_with_no_energy( async def test_send_with_no_energy_config( - recorder_mock: Recorder, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + recorder_mock: Recorder, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -534,7 +654,10 @@ async def test_send_with_no_energy_config( async def test_send_with_energy_config( - recorder_mock: Recorder, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + recorder_mock: Recorder, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -561,6 +684,7 @@ async def test_send_usage_with_certificate( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, ) -> None: """Test send usage preferences with certificate.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -570,7 +694,6 @@ async def test_send_usage_with_certificate( assert analytics.preferences[ATTR_BASE] assert analytics.preferences[ATTR_USAGE] - hass.config.components = ["default_config"] with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): await analytics.send_analytics() @@ -590,8 +713,101 @@ async def test_send_with_recorder( await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) - with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): + with patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.config.load_yaml_config_file", + return_value={"recorder": {}}, + ): await analytics.send_analytics() - postdata = aioclient_mock.mock_calls[-1][2] + postdata = _last_call_payload(aioclient_mock) assert postdata["recorder"]["engine"] == "sqlite" + + +async def test_send_with_problems_loading_yaml( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test error loading YAML configuration.""" + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) + with patch( + "homeassistant.config.load_yaml_config_file", + side_effect=HomeAssistantError("Error loading YAML file"), + ): + await analytics.send_analytics() + + assert "Error loading YAML file" in caplog.text + assert len(aioclient_mock.mock_calls) == 0 + + +async def test_timeout_while_sending( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, + mock_hass_config: None, +) -> None: + """Test timeout error while sending analytics.""" + analytics = Analytics(hass) + aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, exc=asyncio.TimeoutError()) + + await analytics.save_preferences({ATTR_BASE: True}) + with patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV + ): + await analytics.send_analytics() + + assert "Timeout sending analytics" in caplog.text + + +async def test_not_check_config_entries_if_yaml( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test skip config entry check if defined in yaml.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + hass.http = Mock(ssl_certificate="/some/path/to/cert.pem") + + await analytics.save_preferences( + {ATTR_BASE: True, ATTR_STATISTICS: True, ATTR_USAGE: True} + ) + assert analytics.preferences[ATTR_BASE] + assert analytics.preferences[ATTR_STATISTICS] + hass.config.components = ["default_config"] + + mock_config_entry = MockConfigEntry( + domain="ignored_integration", + state=ConfigEntryState.LOADED, + source="ignore", + disabled_by="user", + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.analytics.analytics.async_get_integrations", + return_value={ + "default_config": mock_integration( + hass, + MockModule( + "default_config", + async_setup=AsyncMock(return_value=True), + partial_manifest={"config_flow": True}, + ), + ), + }, + ), patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.config.load_yaml_config_file", + return_value={"default_config": {}}, + ): + await analytics.send_analytics() + + payload = _last_call_payload(aioclient_mock) + assert payload["integration_count"] == 1 + assert payload["integrations"] == ["default_config"]