diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 230d172ca91a..3237bb37716f 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -6,9 +6,8 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import Event, HassJob, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -20,7 +19,7 @@ from .analytics import ( EntityAnalyticsModifications, async_devices_payload, ) -from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA from .http import AnalyticsDevicesView __all__ = [ @@ -43,28 +42,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: # Load stored data await analytics.load() - @callback - def start_schedule(_event: Event) -> None: + async def start_schedule(_event: Event) -> None: """Start the send schedule after the started event.""" - # Wait 15 min after started - async_call_later( - hass, - 900, - HassJob( - analytics.send_analytics, - name="analytics schedule", - cancel_on_shutdown=True, - ), - ) - - # Send every day - async_track_time_interval( - hass, - analytics.send_analytics, - INTERVAL, - name="analytics daily", - cancel_on_shutdown=True, - ) + await analytics.async_schedule() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) @@ -111,7 +91,7 @@ async def websocket_analytics_preferences( analytics = hass.data[DATA_COMPONENT] await analytics.save_preferences(preferences) - await analytics.send_analytics() + await analytics.async_schedule() connection.send_result( msg["id"], diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index e788fdf9714d..d87dc34c27b3 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -7,6 +7,8 @@ from asyncio import timeout from collections.abc import Awaitable, Callable, Iterable, Mapping from dataclasses import asdict as dataclass_asdict, dataclass, field from datetime import datetime +import random +import time from typing import Any, Protocol import uuid @@ -31,10 +33,18 @@ from homeassistant.const import ( BASE_PLATFORMS, __version__ as HA_VERSION, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HomeAssistant, + ReleaseChannel, + callback, + get_release_channel, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store @@ -51,6 +61,7 @@ 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, @@ -71,6 +82,7 @@ from .const import ( ATTR_PROTECTED, ATTR_RECORDER, ATTR_SLUG, + ATTR_SNAPSHOTS, ATTR_STATE_COUNT, ATTR_STATISTICS, ATTR_SUPERVISOR, @@ -80,8 +92,10 @@ from .const import ( ATTR_UUID, ATTR_VERSION, DOMAIN, + INTERVAL, LOGGER, PREFERENCE_SCHEMA, + SNAPSHOT_VERSION, STORAGE_KEY, STORAGE_VERSION, ) @@ -194,13 +208,18 @@ def gen_uuid() -> str: return uuid.uuid4().hex +RELEASE_CHANNEL = get_release_channel() + + @dataclass class AnalyticsData: """Analytics data.""" onboarded: bool preferences: dict[str, bool] - uuid: str | None + uuid: str | None = None + submission_identifier: str | None = None + snapshot_submission_time: float | None = None @classmethod def from_dict(cls, data: dict[str, Any]) -> AnalyticsData: @@ -209,6 +228,8 @@ class AnalyticsData: data["onboarded"], data["preferences"], data["uuid"], + data.get("submission_identifier"), + data.get("snapshot_submission_time"), ) @@ -219,8 +240,10 @@ class Analytics: """Initialize the Analytics class.""" self.hass: HomeAssistant = hass self.session = async_get_clientsession(hass) - self._data = AnalyticsData(False, {}, None) + self._data = AnalyticsData(False, {}) self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) + self._basic_scheduled: CALLBACK_TYPE | None = None + self._snapshot_scheduled: CALLBACK_TYPE | None = None @property def preferences(self) -> dict: @@ -228,6 +251,7 @@ class Analytics: preferences = self._data.preferences return { 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), @@ -244,9 +268,9 @@ class Analytics: return self._data.uuid @property - def endpoint(self) -> str: + def endpoint_basic(self) -> str: """Return the endpoint that will receive the payload.""" - if HA_VERSION.endswith("0.dev0"): + if RELEASE_CHANNEL is ReleaseChannel.DEV: # dev installations will contact the dev analytics environment return ANALYTICS_ENDPOINT_URL_DEV return ANALYTICS_ENDPOINT_URL @@ -277,13 +301,17 @@ class Analytics: ): self._data.preferences[ATTR_DIAGNOSTICS] = False + async def _save(self) -> None: + """Save data.""" + await self._store.async_save(dataclass_asdict(self._data)) + async def save_preferences(self, preferences: dict) -> None: """Save preferences.""" preferences = PREFERENCE_SCHEMA(preferences) self._data.preferences.update(preferences) self._data.onboarded = True - await self._store.async_save(dataclass_asdict(self._data)) + await self._save() if self.supervisor: await hassio.async_update_diagnostics( @@ -292,17 +320,16 @@ class Analytics: async def send_analytics(self, _: datetime | None = None) -> None: """Send analytics.""" + if not self.onboarded or not self.preferences.get(ATTR_BASE, False): + return + hass = self.hass supervisor_info = None operating_system_info: dict[str, Any] = {} - if not self.onboarded or not self.preferences.get(ATTR_BASE, False): - LOGGER.debug("Nothing to submit") - return - if self._data.uuid is None: self._data.uuid = gen_uuid() - await self._store.async_save(dataclass_asdict(self._data)) + await self._save() if self.supervisor: supervisor_info = hassio.get_supervisor_info(hass) @@ -436,7 +463,7 @@ class Analytics: try: async with timeout(30): - response = await self.session.post(self.endpoint, json=payload) + response = await self.session.post(self.endpoint_basic, json=payload) if response.status == 200: LOGGER.info( ( @@ -449,7 +476,7 @@ class Analytics: LOGGER.warning( "Sending analytics failed with statuscode %s from %s", response.status, - self.endpoint, + self.endpoint_basic, ) except TimeoutError: LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL) @@ -489,6 +516,182 @@ class Analytics: if entry.source != SOURCE_IGNORE and entry.disabled_by is None ) + async def send_snapshot(self, _: datetime | None = None) -> None: + """Send a snapshot.""" + if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False): + return + + payload = await _async_snapshot_payload(self.hass) + + headers = { + "Content-Type": "application/json", + "User-Agent": f"home-assistant/{HA_VERSION}", + } + if self._data.submission_identifier is not None: + headers["X-Device-Database-Submission-Identifier"] = ( + self._data.submission_identifier + ) + + try: + async with timeout(30): + response = await self.session.post( + ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers + ) + + if response.status == 200: # OK + response_data = await response.json() + new_identifier = response_data.get("submission_identifier") + + if ( + new_identifier is not None + and new_identifier != self._data.submission_identifier + ): + self._data.submission_identifier = new_identifier + await self._save() + + LOGGER.info( + "Submitted snapshot analytics to Home Assistant servers" + ) + + elif response.status == 400: # Bad Request + response_data = await response.json() + error_kind = response_data.get("kind", "unknown") + error_message = response_data.get("message", "Unknown error") + + if error_kind == "invalid-submission-identifier": + # Clear the invalid identifier and retry on next cycle + LOGGER.warning( + "Invalid submission identifier to %s, clearing: %s", + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + error_message, + ) + self._data.submission_identifier = None + await self._save() + else: + LOGGER.warning( + "Malformed snapshot analytics submission (%s) to %s: %s", + error_kind, + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + error_message, + ) + + elif response.status == 503: # Service Unavailable + response_text = await response.text() + LOGGER.warning( + "Snapshot analytics service %s unavailable: %s", + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + response_text, + ) + + else: + LOGGER.warning( + "Unexpected status code %s when submitting snapshot analytics to %s", + response.status, + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + ) + + except TimeoutError: + LOGGER.error( + "Timeout sending snapshot analytics to %s", + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + ) + except aiohttp.ClientError as err: + LOGGER.error( + "Error sending snapshot analytics to %s: %r", + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + err, + ) + + async def async_schedule(self) -> None: + """Schedule analytics.""" + if not self.onboarded: + LOGGER.debug("Analytics not scheduled") + if self._basic_scheduled is not None: + self._basic_scheduled() + self._basic_scheduled = None + if self._snapshot_scheduled: + self._snapshot_scheduled() + self._snapshot_scheduled = None + return + + if not self.preferences.get(ATTR_BASE, False): + LOGGER.debug("Basic analytics not scheduled") + if self._basic_scheduled is not None: + self._basic_scheduled() + self._basic_scheduled = None + elif self._basic_scheduled is None: + # Wait 15 min after started for basic analytics + self._basic_scheduled = async_call_later( + self.hass, + 900, + HassJob( + self._async_schedule_basic, + name="basic analytics schedule", + cancel_on_shutdown=True, + ), + ) + + if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in ( + ReleaseChannel.DEV, + ReleaseChannel.NIGHTLY, + ): + LOGGER.debug("Snapshot analytics not scheduled") + if self._snapshot_scheduled: + self._snapshot_scheduled() + self._snapshot_scheduled = None + elif self._snapshot_scheduled is None: + snapshot_submission_time = self._data.snapshot_submission_time + + if snapshot_submission_time is None: + # Randomize the submission time within the 24 hours + snapshot_submission_time = random.uniform(0, 86400) + self._data.snapshot_submission_time = snapshot_submission_time + await self._save() + LOGGER.debug( + "Initialized snapshot submission time to %s", + snapshot_submission_time, + ) + + # Calculate delay until next submission + current_time = time.time() + delay = (snapshot_submission_time - current_time) % 86400 + + self._snapshot_scheduled = async_call_later( + self.hass, + delay, + HassJob( + self._async_schedule_snapshots, + name="snapshot analytics schedule", + cancel_on_shutdown=True, + ), + ) + + async def _async_schedule_basic(self, _: datetime | None = None) -> None: + """Schedule basic analytics.""" + await self.send_analytics() + + # Send basic analytics every day + self._basic_scheduled = async_track_time_interval( + self.hass, + self.send_analytics, + INTERVAL, + name="basic analytics daily", + cancel_on_shutdown=True, + ) + + async def _async_schedule_snapshots(self, _: datetime | None = None) -> None: + """Schedule snapshot analytics.""" + await self.send_snapshot() + + # Send snapshot analytics every day + self._snapshot_scheduled = async_track_time_interval( + self.hass, + self.send_snapshot, + INTERVAL, + name="snapshot analytics daily", + cancel_on_shutdown=True, + ) + def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: """Extract domains from the YAML configuration.""" @@ -505,8 +708,8 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications() DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() -async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 - """Return detailed information about entities and devices.""" +async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901 + """Return detailed information about entities and devices for a snapshot.""" dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) @@ -711,8 +914,13 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 entities_info.append(entity_info) + return integrations_info + + +async def async_devices_payload(hass: HomeAssistant) -> dict: + """Return detailed information about entities and devices for a direct download.""" return { - "version": "home-assistant:1", + "version": f"home-assistant:{SNAPSHOT_VERSION}", "home_assistant": HA_VERSION, - "integrations": integrations_info, + "integrations": await _async_snapshot_payload(hass), } diff --git a/homeassistant/components/analytics/const.py b/homeassistant/components/analytics/const.py index 6f74cc60f84c..44659763c176 100644 --- a/homeassistant/components/analytics/const.py +++ b/homeassistant/components/analytics/const.py @@ -7,6 +7,8 @@ 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" @@ -38,6 +40,7 @@ ATTR_PREFERENCES = "preferences" ATTR_PROTECTED = "protected" ATTR_RECORDER = "recorder" ATTR_SLUG = "slug" +ATTR_SNAPSHOTS = "snapshots" ATTR_STATE_COUNT = "state_count" ATTR_STATISTICS = "statistics" ATTR_SUPERVISOR = "supervisor" @@ -51,6 +54,7 @@ ATTR_VERSION = "version" PREFERENCE_SCHEMA = vol.Schema( { vol.Optional(ATTR_BASE): bool, + vol.Optional(ATTR_SNAPSHOTS): bool, vol.Optional(ATTR_DIAGNOSTICS): bool, vol.Optional(ATTR_STATISTICS): bool, vol.Optional(ATTR_USAGE): bool, diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index ec35cc56d51a..633ce412c477 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1,6 +1,7 @@ """The tests for the analytics .""" from collections.abc import Generator +from datetime import timedelta from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -22,8 +23,10 @@ from homeassistant.components.analytics.analytics import ( 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, ) @@ -31,13 +34,20 @@ from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import ATTR_ASSUMED_STATE, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ReleaseChannel from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + async_fire_time_changed, + mock_integration, + mock_platform, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -59,9 +69,31 @@ def uuid_mock() -> Generator[None]: @pytest.fixture(autouse=True) def ha_version_mock() -> Generator[None]: """Mock the core version.""" - with patch( - "homeassistant.components.analytics.analytics.HA_VERSION", - MOCK_VERSION, + with ( + patch( + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION, + ), + patch( + "homeassistant.components.analytics.analytics.RELEASE_CHANNEL", + ReleaseChannel.STABLE, + ), + ): + yield + + +@pytest.fixture +def ha_dev_version_mock() -> Generator[None]: + """Mock the core version as a dev version.""" + with ( + patch( + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION_DEV, + ), + patch( + "homeassistant.components.analytics.analytics.RELEASE_CHANNEL", + ReleaseChannel.DEV, + ), ): yield @@ -97,7 +129,6 @@ async def test_no_send( await analytics.send_analytics() - assert "Nothing to submit" in caplog.text assert len(aioclient_mock.mock_calls) == 0 @@ -615,7 +646,7 @@ async def test_custom_integrations( assert snapshot == submitted_data -@pytest.mark.usefixtures("supervisor_client") +@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client") async def test_dev_url( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -625,16 +656,13 @@ async def test_dev_url( analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) - with patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV - ): - await analytics.send_analytics() + await analytics.send_analytics() payload = aioclient_mock.mock_calls[0] assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV -@pytest.mark.usefixtures("supervisor_client") +@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client") async def test_dev_url_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -645,10 +673,7 @@ async def test_dev_url_error( analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True}) - with patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV - ): - await analytics.send_analytics() + await analytics.send_analytics() payload = aioclient_mock.mock_calls[0] assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV @@ -860,7 +885,7 @@ async def test_send_with_problems_loading_yaml( assert len(aioclient_mock.mock_calls) == 0 -@pytest.mark.usefixtures("mock_hass_config", "supervisor_client") +@pytest.mark.usefixtures("ha_dev_version_mock", "mock_hass_config", "supervisor_client") async def test_timeout_while_sending( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -871,10 +896,7 @@ async def test_timeout_while_sending( aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, exc=TimeoutError()) await analytics.save_preferences({ATTR_BASE: True}) - with patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV - ): - await analytics.send_analytics() + await analytics.send_analytics() assert "Timeout sending analytics" in caplog.text @@ -1426,3 +1448,346 @@ async def test_analytics_platforms( }, }, } + + +async def test_send_snapshot_disabled( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test no snapshots are sent.""" + analytics = Analytics(hass) + + await analytics.send_snapshot() + + await analytics.save_preferences({ATTR_SNAPSHOTS: False}) + await analytics.send_snapshot() + + assert len(aioclient_mock.mock_calls) == 0 + + +async def test_send_snapshot_success( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test successful snapshot submission.""" + aioclient_mock.post( + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + status=200, + json={"submission_identifier": "test-identifier-123"}, + ) + + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_SNAPSHOTS: True}) + await analytics.send_snapshot() + + assert len(aioclient_mock.mock_calls) == 1 + + preferences = await analytics._store.async_load() + assert preferences["submission_identifier"] == "test-identifier-123" + assert "Submitted snapshot analytics to Home Assistant servers" in caplog.text + + +async def test_send_snapshot_with_existing_identifier( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test snapshot submission with existing identifier.""" + aioclient_mock.post( + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + status=200, + json={"submission_identifier": "test-identifier-123"}, + ) + + analytics = Analytics(hass) + with patch( + "homeassistant.helpers.storage.Store.async_load", + return_value={ + "onboarded": True, + "preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True}, + "uuid": "12345", + "submission_identifier": "old-identifier", + }, + ): + await analytics.load() + + await analytics.send_snapshot() + + assert len(aioclient_mock.mock_calls) == 1 + call_headers = aioclient_mock.mock_calls[0][3] + assert call_headers["X-Device-Database-Submission-Identifier"] == "old-identifier" + + preferences = await analytics._store.async_load() + assert preferences["submission_identifier"] == "test-identifier-123" + assert "Submitted snapshot analytics to Home Assistant servers" in caplog.text + + +async def test_send_snapshot_invalid_identifier( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test snapshot submission with invalid identifier.""" + aioclient_mock.post( + ANALYTICS_SNAPSHOT_ENDPOINT_URL, + status=400, + json={ + "kind": "invalid-submission-identifier", + "message": "The identifier is invalid", + }, + ) + + analytics = Analytics(hass) + with patch( + "homeassistant.helpers.storage.Store.async_load", + return_value={ + "onboarded": True, + "preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True}, + "uuid": "12345", + "submission_identifier": "invalid-identifier", + }, + ): + await analytics.load() + + await analytics.send_snapshot() + + assert len(aioclient_mock.mock_calls) == 1 + + preferences = await analytics._store.async_load() + assert preferences.get("submission_identifier") is None + assert "Invalid submission identifier" in caplog.text + + +@pytest.mark.parametrize( + ("post_kwargs", "expected_log"), + [ + ( + { + "status": 400, + "json": { + "kind": "malformed-payload", + "message": "Invalid payload format", + }, + }, + "Malformed snapshot analytics submission", + ), + ( + {"status": 503, "text": "Service Unavailable"}, + f"Snapshot analytics service {ANALYTICS_SNAPSHOT_ENDPOINT_URL} unavailable", + ), + ( + {"status": 500}, + "Unexpected status code 500 when submitting snapshot analytics", + ), + ( + {"exc": TimeoutError()}, + "Timeout sending snapshot analytics", + ), + ( + {"exc": aiohttp.ClientError()}, + "Error sending snapshot analytics", + ), + ], + ids=[ + "bad_request", + "service_unavailable", + "unexpected_status", + "timeout", + "client_error", + ], +) +async def test_send_snapshot_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, + post_kwargs: dict[str, Any], + expected_log: str, +) -> None: + """Test snapshot submission error.""" + aioclient_mock.post(ANALYTICS_SNAPSHOT_ENDPOINT_URL, **post_kwargs) + + 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.send_snapshot() + + 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={}) + + analytics = Analytics(hass) + + # Schedule when not onboarded + 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 + + # Onboard and enable both + await analytics.save_preferences({ATTR_BASE: True, ATTR_SNAPSHOTS: True}) + + await analytics.async_schedule() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) + await hass.async_block_till_done() + + 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 + ) + + preferences = await analytics._store.async_load() + assert preferences["snapshot_submission_time"] is not None + 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, +) -> None: + """Test scheduling when disabled.""" + analytics = Analytics(hass) + with patch( + "homeassistant.helpers.storage.Store.async_load", + return_value={ + "onboarded": True, + "preferences": {ATTR_BASE: False, ATTR_SNAPSHOTS: False}, + "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 + + +@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={}) + + 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() + 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) == 2 + + 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 + ) + + +@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, + onboarded: bool, +) -> None: + """Test that scheduled tasks are cancelled when disabled.""" + 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() + + with patch( + "homeassistant.helpers.storage.Store.async_load", + return_value={ + "onboarded": onboarded, + "preferences": {ATTR_BASE: False, ATTR_SNAPSHOTS: False}, + "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 66000fc5936c..3addc8ad9f91 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -45,7 +45,6 @@ async def test_websocket( {"type": "analytics/preferences", "preferences": {"base": True}} ) response = await ws_client.receive_json() - assert len(aioclient_mock.mock_calls) == 1 assert response["result"]["preferences"]["base"] await ws_client.send_json_auto_id({"type": "analytics"})