Skip to content
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ homeassistant/components/alpha_vantage/* @fabaff
homeassistant/components/ambiclimate/* @danielhiversen
homeassistant/components/ambient_station/* @bachya
homeassistant/components/amcrest/* @pnbruckner
homeassistant/components/analytics/* @home-assistant/core
homeassistant/components/androidtv/* @JeffLIrion
homeassistant/components/apache_kafka/* @bachya
homeassistant/components/api/* @home-assistant/core
Expand Down
78 changes: 78 additions & 0 deletions homeassistant/components/analytics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Send instance and usage analytics."""
import voluptuous as vol

from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval

from .analytics import Analytics
from .const import ATTR_HUUID, ATTR_PREFERENCES, DOMAIN, INTERVAL


async def async_setup(hass: HomeAssistant, _):
"""Set up the analytics integration."""
analytics = Analytics(hass)

# Load stored data
await analytics.load()

async def start_schedule(_event):
"""Start the send schedule after the started event."""
# Wait 15 min after started
async_call_later(hass, 900, analytics.send_analytics)

# Send every day
async_track_time_interval(hass, analytics.send_analytics, INTERVAL)

hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)

websocket_api.async_register_command(hass, websocket_analytics_preferences)
websocket_api.async_register_command(hass, websocket_analytics_preferences_update)

hass.data[DOMAIN] = analytics
return True


@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command({vol.Required("type"): "analytics"})
Comment thread
ludeeus marked this conversation as resolved.
async def websocket_analytics_preferences(
hass: HomeAssistant,
connection: websocket_api.connection.ActiveConnection,
msg: dict,
) -> None:
"""Return analytics preferences."""
analytics: Analytics = hass.data[DOMAIN]
huuid = await hass.helpers.instance_id.async_get()
connection.send_result(
msg["id"],
{ATTR_PREFERENCES: analytics.preferences, ATTR_HUUID: huuid},
)


@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
vol.Required("type"): "analytics/preferences",
Comment thread
ludeeus marked this conversation as resolved.
vol.Required("preferences"): cv.ensure_list,
Comment thread
ludeeus marked this conversation as resolved.
Outdated
}
)
async def websocket_analytics_preferences_update(
hass: HomeAssistant,
connection: websocket_api.connection.ActiveConnection,
msg: dict,
) -> None:
"""Update analytics preferences."""
preferences = msg[ATTR_PREFERENCES]
analytics: Analytics = hass.data[DOMAIN]

await analytics.save_preferences(preferences)
await analytics.send_analytics()

connection.send_result(
msg["id"],
{ATTR_PREFERENCES: analytics.preferences},
)
209 changes: 209 additions & 0 deletions homeassistant/components/analytics/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""Analytics helper class for the analytics integration."""
import asyncio
from typing import List, Optional

import aiohttp
import async_timeout

from homeassistant.components.api import ATTR_INSTALLATION_TYPE
from homeassistant.components.automation.const import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.hassio.const import DOMAIN as HASSIO_DOMAIN
from homeassistant.components.hassio.handler import HassIO
from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import Store
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.loader import async_get_integration

from .const import (
ANALYTICS_ENDPOINT_URL,
ATTR_ADDON_COUNT,
ATTR_ADDONS,
ATTR_AUTO_UPDATE,
ATTR_AUTOMATION_COUNT,
ATTR_DIAGNOSTICS,
ATTR_HEALTHY,
ATTR_HUUID,
ATTR_INTEGRATION_COUNT,
ATTR_INTEGRATIONS,
ATTR_ONBOARDED,
ATTR_PREFERENCES,
ATTR_PROTECTED,
ATTR_SLUG,
ATTR_STATE_COUNT,
ATTR_SUPERVISOR,
ATTR_SUPPORTED,
ATTR_USER_COUNT,
ATTR_VERSION,
INGORED_DOMAINS,
LOGGER,
STORAGE_KEY,
STORAGE_VERSION,
AnalyticsPreference,
)

DEFAULT_DATA = {ATTR_ONBOARDED: False, ATTR_PREFERENCES: []}
Comment thread
ludeeus marked this conversation as resolved.
Outdated


class Analytics:
"""Analytics helper class for the analytics integration."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the Analytics class."""
self.hass: HomeAssistant = hass
self.session = async_get_clientsession(hass)
self._data = DEFAULT_DATA
self._store: Store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
self._supervisor: Optional[HassIO] = self.hass.data.get(HASSIO_DOMAIN)
Comment thread
ludeeus marked this conversation as resolved.
Outdated

@property
def preferences(self) -> List[AnalyticsPreference]:
"""Return the current active preferences."""
return self._data[ATTR_PREFERENCES]

@property
def onboarded(self) -> bool:
"""Return bool if the user has made a choice."""
return self._data[ATTR_ONBOARDED]

async def load(self) -> None:
"""Load preferences."""
self._data = await self._store.async_load() or DEFAULT_DATA
if self._supervisor:
supervisor_info = await self._supervisor.get_supervisor_info()
if not self.onboarded:
# User have not configured analytics, get this setting from the supervisor
if (
supervisor_info[ATTR_DIAGNOSTICS]
and AnalyticsPreference.DIAGNOSTICS
not in self._data[ATTR_PREFERENCES]
):
self._data[ATTR_PREFERENCES].append(ATTR_DIAGNOSTICS)
elif (
not supervisor_info[ATTR_DIAGNOSTICS]
and AnalyticsPreference.DIAGNOSTICS in self._data[ATTR_PREFERENCES]
):
self._data[ATTR_PREFERENCES].remove(ATTR_DIAGNOSTICS)

async def save_preferences(self, preferences: List[AnalyticsPreference]) -> None:
"""Save preferences."""
self._data[ATTR_PREFERENCES] = preferences
self._data[ATTR_ONBOARDED] = True
await self._store.async_save(self._data)

if self._supervisor:
await self._supervisor.update_diagnostics(
AnalyticsPreference.DIAGNOSTICS in self._data[ATTR_PREFERENCES]
)

async def send_analytics(self, _=None) -> None:
"""Send analytics."""
supervisor_info = None

if not self.onboarded or AnalyticsPreference.BASE not in self.preferences:
LOGGER.debug("Nothing to submit")
return

huuid = await self.hass.helpers.instance_id.async_get()

if self._supervisor:
supervisor_info = await self._supervisor.get_supervisor_info()

system_info = await async_get_system_info(self.hass)
integrations = []
addons = []
payload: dict = {
ATTR_HUUID: huuid,
ATTR_VERSION: HA_VERSION,
ATTR_INSTALLATION_TYPE: system_info[ATTR_INSTALLATION_TYPE],
}

if supervisor_info is not None:
payload[ATTR_SUPERVISOR] = {
ATTR_HEALTHY: supervisor_info[ATTR_HEALTHY],
ATTR_SUPPORTED: supervisor_info[ATTR_SUPPORTED],
}

if (
AnalyticsPreference.USAGE in self.preferences
or AnalyticsPreference.STATISTICS in self.preferences
):
configured_integrations = await asyncio.gather(
*[
async_get_integration(self.hass, domain)
for domain in self.hass.config.components
# Filter out platforms.
if "." not in domain
]
)

for integration in configured_integrations:
if (
integration.disabled
or integration.domain in INGORED_DOMAINS
or not integration.is_built_in
Comment thread
ludeeus marked this conversation as resolved.
Outdated
):
continue

integrations.append(integration.domain)
Comment thread
ludeeus marked this conversation as resolved.

if supervisor_info is not None:
installed_addons = await asyncio.gather(
*[
self._supervisor.get_addon_info(addon[ATTR_SLUG])
for addon in supervisor_info[ATTR_ADDONS]
]
)
for addon in installed_addons:
addons.append(
{
ATTR_SLUG: addon[ATTR_SLUG],
ATTR_PROTECTED: addon[ATTR_PROTECTED],
ATTR_VERSION: addon[ATTR_VERSION],
ATTR_AUTO_UPDATE: addon[ATTR_AUTO_UPDATE],
}
)

if AnalyticsPreference.USAGE in self.preferences:
payload[ATTR_INTEGRATIONS] = integrations
if supervisor_info is not None:
payload[ATTR_ADDONS] = addons

if AnalyticsPreference.STATISTICS in self.preferences:
payload[ATTR_STATE_COUNT] = len(self.hass.states.async_all())
payload[ATTR_AUTOMATION_COUNT] = len(
self.hass.states.async_all(AUTOMATION_DOMAIN)
)
payload[ATTR_INTEGRATION_COUNT] = len(integrations)
if supervisor_info is not None:
payload[ATTR_ADDON_COUNT] = len(addons)
payload[ATTR_USER_COUNT] = len(
[
user
for user in await self.hass.auth.async_get_users()
if not user.system_generated
]
)

try:
with async_timeout.timeout(30):
response = await self.session.post(ANALYTICS_ENDPOINT_URL, json=payload)
if response.status == 200:
LOGGER.info(
(
"Submitted analytics to Home Assistant servers. "
"Information submitted includes %s"
),
payload,
)
else:
LOGGER.warning(
"Sending analytics failed with statuscode %s", response.status
)
except asyncio.TimeoutError:
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
except aiohttp.ClientError as err:
LOGGER.error(
"Error sending analytics to %s: %r", ANALYTICS_ENDPOINT_URL, err
)
88 changes: 88 additions & 0 deletions homeassistant/components/analytics/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Constants for the analytics integration."""
from datetime import timedelta
from enum import Enum
import logging

ANALYTICS_ENDPOINT_URL = "https://floral-resonance-f63b.ludeeus.workers.dev"
Comment thread
MartinHjelmare marked this conversation as resolved.
Outdated
DOMAIN = "analytics"
INTERVAL = timedelta(days=1)
STORAGE_KEY = "core.analytics"
STORAGE_VERSION = 1


LOGGER: logging.Logger = logging.getLogger(__package__)

ATTR_ADDON_COUNT = "addon_count"
ATTR_ADDONS = "addons"
ATTR_AUTOMATION_COUNT = "automation_count"
ATTR_AUTO_UPDATE = "auto_update"
ATTR_DIAGNOSTICS = "diagnostics"
ATTR_HEALTHY = "healthy"
ATTR_HUUID = "huuid"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_INTEGRATION_COUNT = "integration_count"
ATTR_INTEGRATIONS = "integrations"
ATTR_ONBOARDED = "onboarded"
ATTR_PREFERENCES = "preferences"
ATTR_PROTECTED = "protected"
ATTR_SLUG = "slug"
ATTR_STATE_COUNT = "state_count"
ATTR_SUPERVISOR = "supervisor"
ATTR_SUPPORTED = "supported"
ATTR_USER_COUNT = "user_count"
ATTR_VERSION = "version"


class AnalyticsPreference(str, Enum):
"""Analytics prefrences."""

BASE = "base"
DIAGNOSTICS = "diagnostics"
STATISTICS = "statistics"
USAGE = "usage"


INGORED_DOMAINS = [
Comment thread
frenck marked this conversation as resolved.
Outdated
"air_quality",
"alarm_control_panel",
"analytics",
"api",
"auth",
"binary_sensor",
"calendar",
"camera",
"climate",
"config",
"conversation",
"cover",
"demo",
"device_automation",
"device_tracker",
"fan",
"hassio",
"homeassistant",
"http",
"humidifier",
"image_processing",
"image",
"light",
"lock",
"logger",
"lovelace",
"media_player",
"notify",
"number",
"onboarding",
"persistent_notification",
"recorder",
"search",
"sensor",
"stt",
"switch",
"system_log",
"tts",
"vacuum",
"water_heater",
"weather",
"websocket_api",
]
Comment thread
ludeeus marked this conversation as resolved.
Outdated
8 changes: 8 additions & 0 deletions homeassistant/components/analytics/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"domain": "analytics",
"name": "Analytics",
"documentation": "https://www.home-assistant.io/integrations/analytics",
"codeowners": ["@home-assistant/core"],
"dependencies": ["api", "websocket_api"],
"quality_scale": "internal"
}
Loading