From bd0ab4d1fe72908661fad1447b71ffd229bf82de Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 23 Nov 2025 13:47:33 +0100 Subject: [PATCH] Add snapshot device analytics url config option (#156984) --- .../components/analytics/__init__.py | 29 ++- .../components/analytics/analytics.py | 93 +++++---- homeassistant/components/analytics/const.py | 10 +- tests/components/analytics/test_analytics.py | 190 ++++++++++-------- tests/components/analytics/test_init.py | 4 +- 5 files changed, 191 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 3237bb37716f..b4422ea367d3 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -30,14 +29,36 @@ __all__ = [ "async_devices_payload", ] -CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +CONF_SNAPSHOTS_URL = "snapshots_url" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_SNAPSHOTS_URL): vol.Any(str, None), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN) -async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the analytics integration.""" - analytics = Analytics(hass) + analytics_config = config.get(DOMAIN, {}) + + # For now we want to enable device analytics only if the url option + # is explicitly listed in YAML. + if CONF_SNAPSHOTS_URL in analytics_config: + disable_snapshots = False + snapshots_url = analytics_config[CONF_SNAPSHOTS_URL] + else: + disable_snapshots = True + snapshots_url = None + + analytics = Analytics(hass, snapshots_url, disable_snapshots) # Load stored data await analytics.load() diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index d87dc34c27b3..2895818b5288 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -59,9 +59,6 @@ from homeassistant.loader import ( from homeassistant.setup import async_get_loaded_integrations from .const import ( - ANALYTICS_ENDPOINT_URL, - ANALYTICS_ENDPOINT_URL_DEV, - ANALYTICS_SNAPSHOT_ENDPOINT_URL, ATTR_ADDON_COUNT, ATTR_ADDONS, ATTR_ARCH, @@ -91,10 +88,14 @@ from .const import ( ATTR_USER_COUNT, ATTR_UUID, ATTR_VERSION, + BASIC_ENDPOINT_URL, + BASIC_ENDPOINT_URL_DEV, DOMAIN, INTERVAL, LOGGER, PREFERENCE_SCHEMA, + SNAPSHOT_DEFAULT_URL, + SNAPSHOT_URL_PATH, SNAPSHOT_VERSION, STORAGE_KEY, STORAGE_VERSION, @@ -236,10 +237,18 @@ class AnalyticsData: class Analytics: """Analytics helper class for the analytics integration.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, + hass: HomeAssistant, + snapshots_url: str | None = None, + disable_snapshots: bool = False, + ) -> None: """Initialize the Analytics class.""" - self.hass: HomeAssistant = hass - self.session = async_get_clientsession(hass) + self._hass: HomeAssistant = hass + self._snapshots_url = snapshots_url + self._disable_snapshots = disable_snapshots + + self._session = async_get_clientsession(hass) self._data = AnalyticsData(False, {}) self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) self._basic_scheduled: CALLBACK_TYPE | None = None @@ -249,13 +258,15 @@ class Analytics: def preferences(self) -> dict: """Return the current active preferences.""" preferences = self._data.preferences - return { + result = { ATTR_BASE: preferences.get(ATTR_BASE, False), - ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False), ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False), ATTR_USAGE: preferences.get(ATTR_USAGE, False), ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False), } + if not self._disable_snapshots: + result[ATTR_SNAPSHOTS] = preferences.get(ATTR_SNAPSHOTS, False) + return result @property def onboarded(self) -> bool: @@ -272,13 +283,13 @@ class Analytics: """Return the endpoint that will receive the payload.""" if RELEASE_CHANNEL is ReleaseChannel.DEV: # dev installations will contact the dev analytics environment - return ANALYTICS_ENDPOINT_URL_DEV - return ANALYTICS_ENDPOINT_URL + return BASIC_ENDPOINT_URL_DEV + return BASIC_ENDPOINT_URL @property def supervisor(self) -> bool: """Return bool if a supervisor is present.""" - return is_hassio(self.hass) + return is_hassio(self._hass) async def load(self) -> None: """Load preferences.""" @@ -288,7 +299,7 @@ class Analytics: if ( self.supervisor - and (supervisor_info := hassio.get_supervisor_info(self.hass)) is not None + and (supervisor_info := hassio.get_supervisor_info(self._hass)) is not None ): if not self.onboarded: # User have not configured analytics, get this setting from the supervisor @@ -315,7 +326,7 @@ class Analytics: if self.supervisor: await hassio.async_update_diagnostics( - self.hass, self.preferences.get(ATTR_DIAGNOSTICS, False) + self._hass, self.preferences.get(ATTR_DIAGNOSTICS, False) ) async def send_analytics(self, _: datetime | None = None) -> None: @@ -323,7 +334,7 @@ class Analytics: if not self.onboarded or not self.preferences.get(ATTR_BASE, False): return - hass = self.hass + hass = self._hass supervisor_info = None operating_system_info: dict[str, Any] = {} @@ -463,7 +474,7 @@ class Analytics: try: async with timeout(30): - response = await self.session.post(self.endpoint_basic, json=payload) + response = await self._session.post(self.endpoint_basic, json=payload) if response.status == 200: LOGGER.info( ( @@ -479,11 +490,9 @@ class Analytics: self.endpoint_basic, ) except TimeoutError: - LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL) + LOGGER.error("Timeout sending analytics to %s", BASIC_ENDPOINT_URL) except aiohttp.ClientError as err: - LOGGER.error( - "Error sending analytics to %s: %r", ANALYTICS_ENDPOINT_URL, err - ) + LOGGER.error("Error sending analytics to %s: %r", BASIC_ENDPOINT_URL, err) @callback def _async_should_report_integration( @@ -507,7 +516,7 @@ class Analytics: if not integration.config_flow: return False - entries = self.hass.config_entries.async_entries(integration.domain) + entries = self._hass.config_entries.async_entries(integration.domain) # Filter out ignored and disabled entries return any( @@ -521,7 +530,7 @@ class Analytics: if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False): return - payload = await _async_snapshot_payload(self.hass) + payload = await _async_snapshot_payload(self._hass) headers = { "Content-Type": "application/json", @@ -532,11 +541,16 @@ class Analytics: self._data.submission_identifier ) + url = ( + self._snapshots_url + if self._snapshots_url is not None + else SNAPSHOT_DEFAULT_URL + ) + url += SNAPSHOT_URL_PATH + try: async with timeout(30): - response = await self.session.post( - ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers - ) + response = await self._session.post(url, json=payload, headers=headers) if response.status == 200: # OK response_data = await response.json() @@ -562,7 +576,7 @@ class Analytics: # Clear the invalid identifier and retry on next cycle LOGGER.warning( "Invalid submission identifier to %s, clearing: %s", - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + url, error_message, ) self._data.submission_identifier = None @@ -571,7 +585,7 @@ class Analytics: LOGGER.warning( "Malformed snapshot analytics submission (%s) to %s: %s", error_kind, - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + url, error_message, ) @@ -579,7 +593,7 @@ class Analytics: response_text = await response.text() LOGGER.warning( "Snapshot analytics service %s unavailable: %s", - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + url, response_text, ) @@ -587,18 +601,18 @@ class Analytics: LOGGER.warning( "Unexpected status code %s when submitting snapshot analytics to %s", response.status, - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + url, ) except TimeoutError: LOGGER.error( "Timeout sending snapshot analytics to %s", - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + url, ) except aiohttp.ClientError as err: LOGGER.error( "Error sending snapshot analytics to %s: %r", - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + url, err, ) @@ -622,7 +636,7 @@ class Analytics: elif self._basic_scheduled is None: # Wait 15 min after started for basic analytics self._basic_scheduled = async_call_later( - self.hass, + self._hass, 900, HassJob( self._async_schedule_basic, @@ -631,10 +645,7 @@ class Analytics: ), ) - if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in ( - ReleaseChannel.DEV, - ReleaseChannel.NIGHTLY, - ): + if not self.preferences.get(ATTR_SNAPSHOTS, False) or self._disable_snapshots: LOGGER.debug("Snapshot analytics not scheduled") if self._snapshot_scheduled: self._snapshot_scheduled() @@ -642,9 +653,11 @@ class Analytics: elif self._snapshot_scheduled is None: snapshot_submission_time = self._data.snapshot_submission_time + interval_seconds = INTERVAL.total_seconds() + if snapshot_submission_time is None: # Randomize the submission time within the 24 hours - snapshot_submission_time = random.uniform(0, 86400) + snapshot_submission_time = random.uniform(0, interval_seconds) self._data.snapshot_submission_time = snapshot_submission_time await self._save() LOGGER.debug( @@ -654,10 +667,10 @@ class Analytics: # Calculate delay until next submission current_time = time.time() - delay = (snapshot_submission_time - current_time) % 86400 + delay = (snapshot_submission_time - current_time) % interval_seconds self._snapshot_scheduled = async_call_later( - self.hass, + self._hass, delay, HassJob( self._async_schedule_snapshots, @@ -672,7 +685,7 @@ class Analytics: # Send basic analytics every day self._basic_scheduled = async_track_time_interval( - self.hass, + self._hass, self.send_analytics, INTERVAL, name="basic analytics daily", @@ -685,7 +698,7 @@ class Analytics: # Send snapshot analytics every day self._snapshot_scheduled = async_track_time_interval( - self.hass, + self._hass, self.send_snapshot, INTERVAL, name="snapshot analytics daily", diff --git a/homeassistant/components/analytics/const.py b/homeassistant/components/analytics/const.py index 44659763c176..0396399811a7 100644 --- a/homeassistant/components/analytics/const.py +++ b/homeassistant/components/analytics/const.py @@ -5,15 +5,17 @@ import logging import voluptuous as vol -ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1" -ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1" -SNAPSHOT_VERSION = "1" -ANALYTICS_SNAPSHOT_ENDPOINT_URL = f"https://device-database.eco-dev-aws.openhomefoundation.com/api/v1/snapshot/{SNAPSHOT_VERSION}" DOMAIN = "analytics" INTERVAL = timedelta(days=1) STORAGE_KEY = "core.analytics" STORAGE_VERSION = 1 +BASIC_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1" +BASIC_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1" + +SNAPSHOT_VERSION = 1 +SNAPSHOT_DEFAULT_URL = "https://device-database.eco-dev-aws.openhomefoundation.com" +SNAPSHOT_URL_PATH = f"/api/v1/snapshot/{SNAPSHOT_VERSION}" LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 633ce412c477..f2789b6f479c 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -21,14 +21,15 @@ from homeassistant.components.analytics.analytics import ( async_devices_payload, ) from homeassistant.components.analytics.const import ( - ANALYTICS_ENDPOINT_URL, - ANALYTICS_ENDPOINT_URL_DEV, - ANALYTICS_SNAPSHOT_ENDPOINT_URL, ATTR_BASE, ATTR_DIAGNOSTICS, ATTR_SNAPSHOTS, ATTR_STATISTICS, ATTR_USAGE, + BASIC_ENDPOINT_URL, + BASIC_ENDPOINT_URL_DEV, + SNAPSHOT_DEFAULT_URL, + SNAPSHOT_URL_PATH, ) from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass @@ -51,6 +52,8 @@ from tests.common import ( from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +SNAPSHOT_ENDPOINT_URL = SNAPSHOT_DEFAULT_URL + SNAPSHOT_URL_PATH + MOCK_UUID = "abcdefg" MOCK_VERSION = "1970.1.0" MOCK_VERSION_DEV = "1970.1.0.dev0" @@ -179,14 +182,14 @@ async def test_failed_to_send( aioclient_mock: AiohttpClientMocker, ) -> None: """Test failed to send payload.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=400) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=400) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) assert analytics.preferences[ATTR_BASE] await analytics.send_analytics() assert ( - f"Sending analytics failed with statuscode 400 from {ANALYTICS_ENDPOINT_URL}" + f"Sending analytics failed with statuscode 400 from {BASIC_ENDPOINT_URL}" in caplog.text ) @@ -198,7 +201,7 @@ async def test_failed_to_send_raises( aioclient_mock: AiohttpClientMocker, ) -> None: """Test raises when failed to send payload.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, exc=aiohttp.ClientError()) + aioclient_mock.post(BASIC_ENDPOINT_URL, exc=aiohttp.ClientError()) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) assert analytics.preferences[ATTR_BASE] @@ -215,7 +218,7 @@ async def test_send_base( snapshot: SnapshotAssertion, ) -> None: """Test send base preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) @@ -238,7 +241,7 @@ async def test_send_base_with_supervisor( snapshot: SnapshotAssertion, ) -> None: """Test send base preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) @@ -291,7 +294,7 @@ async def test_send_usage( snapshot: SnapshotAssertion, ) -> None: """Test send usage preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) hass.http = Mock(ssl_certificate=None) await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) @@ -327,7 +330,7 @@ async def test_send_usage_with_supervisor( supervisor_client: AsyncMock, ) -> None: """Test send usage with supervisor preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) hass.http = Mock(ssl_certificate=None) await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) @@ -388,7 +391,7 @@ async def test_send_statistics( snapshot: SnapshotAssertion, ) -> None: """Test send statistics preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) assert analytics.preferences[ATTR_BASE] @@ -414,7 +417,7 @@ async def test_send_statistics_one_integration_fails( aioclient_mock: AiohttpClientMocker, ) -> None: """Test send statistics preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) assert analytics.preferences[ATTR_BASE] @@ -442,7 +445,7 @@ async def test_send_statistics_disabled_integration( snapshot: SnapshotAssertion, ) -> None: """Test send statistics with disabled integration.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) assert analytics.preferences[ATTR_BASE] @@ -481,7 +484,7 @@ async def test_send_statistics_ignored_integration( snapshot: SnapshotAssertion, ) -> None: """Test send statistics with ignored integration.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) assert analytics.preferences[ATTR_BASE] @@ -522,7 +525,7 @@ async def test_send_statistics_async_get_integration_unknown_exception( aioclient_mock: AiohttpClientMocker, ) -> None: """Test send statistics preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) assert analytics.preferences[ATTR_BASE] @@ -548,7 +551,7 @@ async def test_send_statistics_with_supervisor( supervisor_client: AsyncMock, ) -> None: """Test send statistics preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) assert analytics.preferences[ATTR_BASE] @@ -605,7 +608,7 @@ async def test_reusing_uuid( aioclient_mock: AiohttpClientMocker, ) -> None: """Test reusing the stored UUID.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) analytics._data.uuid = "NOT_MOCK_UUID" @@ -627,7 +630,7 @@ async def test_custom_integrations( snapshot: SnapshotAssertion, ) -> None: """Test sending custom integrations.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) hass.http = Mock(ssl_certificate=None) assert await async_setup_component(hass, "test_package", {"test_package": {}}) @@ -652,14 +655,14 @@ async def test_dev_url( aioclient_mock: AiohttpClientMocker, ) -> None: """Test sending payload to dev url.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) await analytics.send_analytics() payload = aioclient_mock.mock_calls[0] - assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV + assert str(payload[1]) == BASIC_ENDPOINT_URL_DEV @pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client") @@ -669,17 +672,16 @@ async def test_dev_url_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test sending payload to dev url that returns error.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, status=400) + aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=400) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) await analytics.send_analytics() payload = aioclient_mock.mock_calls[0] - assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV + assert str(payload[1]) == BASIC_ENDPOINT_URL_DEV assert ( - "Sending analytics failed with statuscode 400 from" - f" {ANALYTICS_ENDPOINT_URL_DEV}" + f"Sending analytics failed with statuscode 400 from {BASIC_ENDPOINT_URL_DEV}" ) in caplog.text @@ -689,7 +691,7 @@ async def test_nightly_endpoint( aioclient_mock: AiohttpClientMocker, ) -> None: """Test sending payload to production url when running nightly.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) @@ -699,7 +701,7 @@ async def test_nightly_endpoint( await analytics.send_analytics() payload = aioclient_mock.mock_calls[0] - assert str(payload[1]) == ANALYTICS_ENDPOINT_URL + assert str(payload[1]) == BASIC_ENDPOINT_URL @pytest.mark.usefixtures( @@ -712,7 +714,7 @@ async def test_send_with_no_energy( snapshot: SnapshotAssertion, ) -> None: """Test send base preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) hass.http = Mock(ssl_certificate=None) @@ -750,7 +752,7 @@ async def test_send_with_no_energy_config( snapshot: SnapshotAssertion, ) -> None: """Test send base preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) @@ -783,7 +785,7 @@ async def test_send_with_energy_config( snapshot: SnapshotAssertion, ) -> None: """Test send base preferences are defined.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) @@ -816,7 +818,7 @@ async def test_send_usage_with_certificate( snapshot: SnapshotAssertion, ) -> None: """Test send usage preferences with certificate.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_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_USAGE: True}) @@ -842,7 +844,7 @@ async def test_send_with_recorder( snapshot: SnapshotAssertion, ) -> None: """Test recorder information.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) hass.http = Mock(ssl_certificate="/some/path/to/cert.pem") @@ -893,7 +895,7 @@ async def test_timeout_while_sending( ) -> None: """Test timeout error while sending analytics.""" analytics = Analytics(hass) - aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, exc=TimeoutError()) + aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, exc=TimeoutError()) await analytics.save_preferences({ATTR_BASE: True}) await analytics.send_analytics() @@ -909,7 +911,7 @@ async def test_not_check_config_entries_if_yaml( snapshot: SnapshotAssertion, ) -> None: """Test skip config entry check if defined in yaml.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) hass.http = Mock(ssl_certificate="/some/path/to/cert.pem") @@ -967,7 +969,7 @@ async def test_submitting_legacy_integrations( ) -> None: """Test submitting legacy integrations.""" hass.http = Mock(ssl_certificate=None) - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) @@ -1472,7 +1474,7 @@ async def test_send_snapshot_success( ) -> None: """Test successful snapshot submission.""" aioclient_mock.post( - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + SNAPSHOT_ENDPOINT_URL, status=200, json={"submission_identifier": "test-identifier-123"}, ) @@ -1496,7 +1498,7 @@ async def test_send_snapshot_with_existing_identifier( ) -> None: """Test snapshot submission with existing identifier.""" aioclient_mock.post( - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + SNAPSHOT_ENDPOINT_URL, status=200, json={"submission_identifier": "test-identifier-123"}, ) @@ -1531,7 +1533,7 @@ async def test_send_snapshot_invalid_identifier( ) -> None: """Test snapshot submission with invalid identifier.""" aioclient_mock.post( - ANALYTICS_SNAPSHOT_ENDPOINT_URL, + SNAPSHOT_ENDPOINT_URL, status=400, json={ "kind": "invalid-submission-identifier", @@ -1575,7 +1577,7 @@ async def test_send_snapshot_invalid_identifier( ), ( {"status": 503, "text": "Service Unavailable"}, - f"Snapshot analytics service {ANALYTICS_SNAPSHOT_ENDPOINT_URL} unavailable", + f"Snapshot analytics service {SNAPSHOT_ENDPOINT_URL} unavailable", ), ( {"status": 500}, @@ -1606,7 +1608,7 @@ async def test_send_snapshot_error( expected_log: str, ) -> None: """Test snapshot submission error.""" - aioclient_mock.post(ANALYTICS_SNAPSHOT_ENDPOINT_URL, **post_kwargs) + aioclient_mock.post(SNAPSHOT_ENDPOINT_URL, **post_kwargs) analytics = Analytics(hass) with patch( @@ -1624,14 +1626,13 @@ async def test_send_snapshot_error( assert expected_log in caplog.text -@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client") async def test_async_schedule( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, ) -> None: """Test scheduling.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, status=200) - aioclient_mock.post(ANALYTICS_SNAPSHOT_ENDPOINT_URL, status=200, json={}) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) + aioclient_mock.post(SNAPSHOT_ENDPOINT_URL, status=200, json={}) analytics = Analytics(hass) @@ -1651,12 +1652,9 @@ async def test_async_schedule( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) await hass.async_block_till_done() + assert any(str(call[1]) == BASIC_ENDPOINT_URL for call in aioclient_mock.mock_calls) assert any( - str(call[1]) == ANALYTICS_ENDPOINT_URL_DEV for call in aioclient_mock.mock_calls - ) - assert any( - str(call[1]) == ANALYTICS_SNAPSHOT_ENDPOINT_URL - for call in aioclient_mock.mock_calls + str(call[1]) == SNAPSHOT_ENDPOINT_URL for call in aioclient_mock.mock_calls ) preferences = await analytics._store.async_load() @@ -1664,7 +1662,6 @@ async def test_async_schedule( assert 0 <= preferences["snapshot_submission_time"] <= 86400 -@pytest.mark.usefixtures("ha_dev_version_mock") async def test_async_schedule_disabled( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -1689,42 +1686,13 @@ async def test_async_schedule_disabled( assert len(aioclient_mock.mock_calls) == 0 -@pytest.mark.usefixtures("supervisor_client") -async def test_async_schedule_snapshots_not_dev( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test that snapshots are not scheduled on non-dev versions.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) - - analytics = Analytics(hass) - with patch( - "homeassistant.helpers.storage.Store.async_load", - return_value={ - "onboarded": True, - "preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True}, - "uuid": "12345", - }, - ): - await analytics.load() - - await analytics.async_schedule() - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) - await hass.async_block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert str(aioclient_mock.mock_calls[0][1]) == ANALYTICS_ENDPOINT_URL - - -@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client") async def test_async_schedule_already_scheduled( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, ) -> None: """Test not rescheduled if already scheduled.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, status=200) - aioclient_mock.post(ANALYTICS_SNAPSHOT_ENDPOINT_URL, status=200, json={}) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) + aioclient_mock.post(SNAPSHOT_ENDPOINT_URL, status=200, json={}) analytics = Analytics(hass) with patch( @@ -1745,17 +1713,13 @@ async def test_async_schedule_already_scheduled( assert len(aioclient_mock.mock_calls) == 2 + assert any(str(call[1]) == BASIC_ENDPOINT_URL for call in aioclient_mock.mock_calls) assert any( - str(call[1]) == ANALYTICS_ENDPOINT_URL_DEV for call in aioclient_mock.mock_calls - ) - assert any( - str(call[1]) == ANALYTICS_SNAPSHOT_ENDPOINT_URL - for call in aioclient_mock.mock_calls + str(call[1]) == SNAPSHOT_ENDPOINT_URL for call in aioclient_mock.mock_calls ) @pytest.mark.parametrize(("onboarded"), [True, False]) -@pytest.mark.usefixtures("ha_dev_version_mock") async def test_async_schedule_cancel_when_disabled( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -1791,3 +1755,59 @@ async def test_async_schedule_cancel_when_disabled( await hass.async_block_till_done() assert len(aioclient_mock.mock_calls) == 0 + + +async def test_async_schedule_snapshots_url( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that snapshots use provided url.""" + url = "https://custom-snapshot-url.example.com" + endpoint = f"{url}{SNAPSHOT_URL_PATH}" + + aioclient_mock.post(endpoint, status=200, json={}) + + analytics = Analytics(hass, url) + with patch( + "homeassistant.helpers.storage.Store.async_load", + return_value={ + "onboarded": True, + "preferences": {ATTR_BASE: False, ATTR_SNAPSHOTS: True}, + "uuid": "12345", + }, + ): + await analytics.load() + + await analytics.async_schedule() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert str(aioclient_mock.mock_calls[0][1]) == endpoint + + +async def test_async_schedule_snapshots_disabled( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that snapshots are disabled when configured.""" + aioclient_mock.post(SNAPSHOT_ENDPOINT_URL, status=200, json={}) + + analytics = Analytics(hass, disable_snapshots=True) + with patch( + "homeassistant.helpers.storage.Store.async_load", + return_value={ + "onboarded": True, + "preferences": {ATTR_BASE: False, ATTR_SNAPSHOTS: True}, + "uuid": "12345", + }, + ): + await analytics.load() + + await analytics.async_schedule() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 0 diff --git a/tests/components/analytics/test_init.py b/tests/components/analytics/test_init.py index 3addc8ad9f91..403533539688 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.analytics.const import ANALYTICS_ENDPOINT_URL, DOMAIN +from homeassistant.components.analytics.const import BASIC_ENDPOINT_URL, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -29,7 +29,7 @@ async def test_websocket( aioclient_mock: AiohttpClientMocker, ) -> None: """Test WebSocket commands.""" - aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done()