Send snapshot analytics for device database in dev (#155717)

This commit is contained in:
Artur Pragacz
2025-11-18 17:15:27 +01:00
committed by GitHub
parent b8e3d57fea
commit 963e27dda4
5 changed files with 620 additions and 64 deletions

View File

@@ -6,9 +6,8 @@ import voluptuous as vol
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED 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 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.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
@@ -20,7 +19,7 @@ from .analytics import (
EntityAnalyticsModifications, EntityAnalyticsModifications,
async_devices_payload, 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 from .http import AnalyticsDevicesView
__all__ = [ __all__ = [
@@ -43,28 +42,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
# Load stored data # Load stored data
await analytics.load() await analytics.load()
@callback async def start_schedule(_event: Event) -> None:
def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event.""" """Start the send schedule after the started event."""
# Wait 15 min after started await analytics.async_schedule()
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,
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_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] analytics = hass.data[DATA_COMPONENT]
await analytics.save_preferences(preferences) await analytics.save_preferences(preferences)
await analytics.send_analytics() await analytics.async_schedule()
connection.send_result( connection.send_result(
msg["id"], msg["id"],

View File

@@ -7,6 +7,8 @@ from asyncio import timeout
from collections.abc import Awaitable, Callable, Iterable, Mapping from collections.abc import Awaitable, Callable, Iterable, Mapping
from dataclasses import asdict as dataclass_asdict, dataclass, field from dataclasses import asdict as dataclass_asdict, dataclass, field
from datetime import datetime from datetime import datetime
import random
import time
from typing import Any, Protocol from typing import Any, Protocol
import uuid import uuid
@@ -31,10 +33,18 @@ from homeassistant.const import (
BASE_PLATFORMS, BASE_PLATFORMS,
__version__ as HA_VERSION, __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.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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.hassio import is_hassio
from homeassistant.helpers.singleton import singleton from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
@@ -51,6 +61,7 @@ from homeassistant.setup import async_get_loaded_integrations
from .const import ( from .const import (
ANALYTICS_ENDPOINT_URL, ANALYTICS_ENDPOINT_URL,
ANALYTICS_ENDPOINT_URL_DEV, ANALYTICS_ENDPOINT_URL_DEV,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
ATTR_ADDON_COUNT, ATTR_ADDON_COUNT,
ATTR_ADDONS, ATTR_ADDONS,
ATTR_ARCH, ATTR_ARCH,
@@ -71,6 +82,7 @@ from .const import (
ATTR_PROTECTED, ATTR_PROTECTED,
ATTR_RECORDER, ATTR_RECORDER,
ATTR_SLUG, ATTR_SLUG,
ATTR_SNAPSHOTS,
ATTR_STATE_COUNT, ATTR_STATE_COUNT,
ATTR_STATISTICS, ATTR_STATISTICS,
ATTR_SUPERVISOR, ATTR_SUPERVISOR,
@@ -80,8 +92,10 @@ from .const import (
ATTR_UUID, ATTR_UUID,
ATTR_VERSION, ATTR_VERSION,
DOMAIN, DOMAIN,
INTERVAL,
LOGGER, LOGGER,
PREFERENCE_SCHEMA, PREFERENCE_SCHEMA,
SNAPSHOT_VERSION,
STORAGE_KEY, STORAGE_KEY,
STORAGE_VERSION, STORAGE_VERSION,
) )
@@ -194,13 +208,18 @@ def gen_uuid() -> str:
return uuid.uuid4().hex return uuid.uuid4().hex
RELEASE_CHANNEL = get_release_channel()
@dataclass @dataclass
class AnalyticsData: class AnalyticsData:
"""Analytics data.""" """Analytics data."""
onboarded: bool onboarded: bool
preferences: dict[str, bool] preferences: dict[str, bool]
uuid: str | None uuid: str | None = None
submission_identifier: str | None = None
snapshot_submission_time: float | None = None
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> AnalyticsData: def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
@@ -209,6 +228,8 @@ class AnalyticsData:
data["onboarded"], data["onboarded"],
data["preferences"], data["preferences"],
data["uuid"], data["uuid"],
data.get("submission_identifier"),
data.get("snapshot_submission_time"),
) )
@@ -219,8 +240,10 @@ class Analytics:
"""Initialize the Analytics class.""" """Initialize the Analytics class."""
self.hass: HomeAssistant = hass self.hass: HomeAssistant = hass
self.session = async_get_clientsession(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._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 @property
def preferences(self) -> dict: def preferences(self) -> dict:
@@ -228,6 +251,7 @@ class Analytics:
preferences = self._data.preferences preferences = self._data.preferences
return { return {
ATTR_BASE: preferences.get(ATTR_BASE, False), ATTR_BASE: preferences.get(ATTR_BASE, False),
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False), ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
ATTR_USAGE: preferences.get(ATTR_USAGE, False), ATTR_USAGE: preferences.get(ATTR_USAGE, False),
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False), ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
@@ -244,9 +268,9 @@ class Analytics:
return self._data.uuid return self._data.uuid
@property @property
def endpoint(self) -> str: def endpoint_basic(self) -> str:
"""Return the endpoint that will receive the payload.""" """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 # dev installations will contact the dev analytics environment
return ANALYTICS_ENDPOINT_URL_DEV return ANALYTICS_ENDPOINT_URL_DEV
return ANALYTICS_ENDPOINT_URL return ANALYTICS_ENDPOINT_URL
@@ -277,13 +301,17 @@ class Analytics:
): ):
self._data.preferences[ATTR_DIAGNOSTICS] = False 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: async def save_preferences(self, preferences: dict) -> None:
"""Save preferences.""" """Save preferences."""
preferences = PREFERENCE_SCHEMA(preferences) preferences = PREFERENCE_SCHEMA(preferences)
self._data.preferences.update(preferences) self._data.preferences.update(preferences)
self._data.onboarded = True self._data.onboarded = True
await self._store.async_save(dataclass_asdict(self._data)) await self._save()
if self.supervisor: if self.supervisor:
await hassio.async_update_diagnostics( await hassio.async_update_diagnostics(
@@ -292,17 +320,16 @@ class Analytics:
async def send_analytics(self, _: datetime | None = None) -> None: async def send_analytics(self, _: datetime | None = None) -> None:
"""Send analytics.""" """Send analytics."""
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
return
hass = self.hass hass = self.hass
supervisor_info = None supervisor_info = None
operating_system_info: dict[str, Any] = {} 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: if self._data.uuid is None:
self._data.uuid = gen_uuid() self._data.uuid = gen_uuid()
await self._store.async_save(dataclass_asdict(self._data)) await self._save()
if self.supervisor: if self.supervisor:
supervisor_info = hassio.get_supervisor_info(hass) supervisor_info = hassio.get_supervisor_info(hass)
@@ -436,7 +463,7 @@ class Analytics:
try: try:
async with timeout(30): 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: if response.status == 200:
LOGGER.info( LOGGER.info(
( (
@@ -449,7 +476,7 @@ class Analytics:
LOGGER.warning( LOGGER.warning(
"Sending analytics failed with statuscode %s from %s", "Sending analytics failed with statuscode %s from %s",
response.status, response.status,
self.endpoint, self.endpoint_basic,
) )
except TimeoutError: except TimeoutError:
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL) 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 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]: def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
"""Extract domains from the YAML configuration.""" """Extract domains from the YAML configuration."""
@@ -505,8 +708,8 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
"""Return detailed information about entities and devices.""" """Return detailed information about entities and devices for a snapshot."""
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
ent_reg = er.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) 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 { return {
"version": "home-assistant:1", "version": f"home-assistant:{SNAPSHOT_VERSION}",
"home_assistant": HA_VERSION, "home_assistant": HA_VERSION,
"integrations": integrations_info, "integrations": await _async_snapshot_payload(hass),
} }

View File

@@ -7,6 +7,8 @@ import voluptuous as vol
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1" ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.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" DOMAIN = "analytics"
INTERVAL = timedelta(days=1) INTERVAL = timedelta(days=1)
STORAGE_KEY = "core.analytics" STORAGE_KEY = "core.analytics"
@@ -38,6 +40,7 @@ ATTR_PREFERENCES = "preferences"
ATTR_PROTECTED = "protected" ATTR_PROTECTED = "protected"
ATTR_RECORDER = "recorder" ATTR_RECORDER = "recorder"
ATTR_SLUG = "slug" ATTR_SLUG = "slug"
ATTR_SNAPSHOTS = "snapshots"
ATTR_STATE_COUNT = "state_count" ATTR_STATE_COUNT = "state_count"
ATTR_STATISTICS = "statistics" ATTR_STATISTICS = "statistics"
ATTR_SUPERVISOR = "supervisor" ATTR_SUPERVISOR = "supervisor"
@@ -51,6 +54,7 @@ ATTR_VERSION = "version"
PREFERENCE_SCHEMA = vol.Schema( PREFERENCE_SCHEMA = vol.Schema(
{ {
vol.Optional(ATTR_BASE): bool, vol.Optional(ATTR_BASE): bool,
vol.Optional(ATTR_SNAPSHOTS): bool,
vol.Optional(ATTR_DIAGNOSTICS): bool, vol.Optional(ATTR_DIAGNOSTICS): bool,
vol.Optional(ATTR_STATISTICS): bool, vol.Optional(ATTR_STATISTICS): bool,
vol.Optional(ATTR_USAGE): bool, vol.Optional(ATTR_USAGE): bool,

View File

@@ -1,6 +1,7 @@
"""The tests for the analytics .""" """The tests for the analytics ."""
from collections.abc import Generator from collections.abc import Generator
from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
@@ -22,8 +23,10 @@ from homeassistant.components.analytics.analytics import (
from homeassistant.components.analytics.const import ( from homeassistant.components.analytics.const import (
ANALYTICS_ENDPOINT_URL, ANALYTICS_ENDPOINT_URL,
ANALYTICS_ENDPOINT_URL_DEV, ANALYTICS_ENDPOINT_URL_DEV,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
ATTR_BASE, ATTR_BASE,
ATTR_DIAGNOSTICS, ATTR_DIAGNOSTICS,
ATTR_SNAPSHOTS,
ATTR_STATISTICS, ATTR_STATISTICS,
ATTR_USAGE, ATTR_USAGE,
) )
@@ -31,13 +34,20 @@ from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import ATTR_ASSUMED_STATE, EntityCategory 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.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.loader import IntegrationNotFound from homeassistant.loader import IntegrationNotFound
from homeassistant.setup import async_setup_component 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.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@@ -59,9 +69,31 @@ def uuid_mock() -> Generator[None]:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def ha_version_mock() -> Generator[None]: def ha_version_mock() -> Generator[None]:
"""Mock the core version.""" """Mock the core version."""
with patch( with (
patch(
"homeassistant.components.analytics.analytics.HA_VERSION", "homeassistant.components.analytics.analytics.HA_VERSION",
MOCK_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 yield
@@ -97,7 +129,6 @@ async def test_no_send(
await analytics.send_analytics() await analytics.send_analytics()
assert "Nothing to submit" in caplog.text
assert len(aioclient_mock.mock_calls) == 0 assert len(aioclient_mock.mock_calls) == 0
@@ -615,7 +646,7 @@ async def test_custom_integrations(
assert snapshot == submitted_data assert snapshot == submitted_data
@pytest.mark.usefixtures("supervisor_client") @pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client")
async def test_dev_url( async def test_dev_url(
hass: HomeAssistant, hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
@@ -625,16 +656,13 @@ async def test_dev_url(
analytics = Analytics(hass) analytics = Analytics(hass)
await analytics.save_preferences({ATTR_BASE: True}) 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] payload = aioclient_mock.mock_calls[0]
assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV 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( async def test_dev_url_error(
hass: HomeAssistant, hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
@@ -645,9 +673,6 @@ async def test_dev_url_error(
analytics = Analytics(hass) analytics = Analytics(hass)
await analytics.save_preferences({ATTR_BASE: True}) 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] payload = aioclient_mock.mock_calls[0]
@@ -860,7 +885,7 @@ async def test_send_with_problems_loading_yaml(
assert len(aioclient_mock.mock_calls) == 0 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( async def test_timeout_while_sending(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
@@ -871,9 +896,6 @@ async def test_timeout_while_sending(
aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, exc=TimeoutError()) aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, exc=TimeoutError())
await analytics.save_preferences({ATTR_BASE: True}) 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 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

View File

@@ -45,7 +45,6 @@ async def test_websocket(
{"type": "analytics/preferences", "preferences": {"base": True}} {"type": "analytics/preferences", "preferences": {"base": True}}
) )
response = await ws_client.receive_json() response = await ws_client.receive_json()
assert len(aioclient_mock.mock_calls) == 1
assert response["result"]["preferences"]["base"] assert response["result"]["preferences"]["base"]
await ws_client.send_json_auto_id({"type": "analytics"}) await ws_client.send_json_auto_id({"type": "analytics"})