mirror of
https://github.com/home-assistant/core.git
synced 2025-11-26 11:08:01 +00:00
Send snapshot analytics for device database in dev (#155717)
This commit is contained in:
@@ -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"],
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
Reference in New Issue
Block a user