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
77 changes: 77 additions & 0 deletions homeassistant/components/analytics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""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.event import async_call_later, async_track_time_interval

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


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)
websocket_api.async_register_command(hass, websocket_analytics_preferences)

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(
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", default={}): PREFERENCE_SCHEMA,
}
)
async def websocket_analytics_preferences(
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},
)
212 changes: 212 additions & 0 deletions homeassistant/components/analytics/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""Analytics helper class for the analytics integration."""
import asyncio

import aiohttp
import async_timeout

from homeassistant.components import hassio
from homeassistant.components.api import ATTR_INSTALLATION_TYPE
from homeassistant.components.automation.const import DOMAIN as AUTOMATION_DOMAIN
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_BASE,
ATTR_DIAGNOSTICS,
ATTR_HEALTHY,
ATTR_HUUID,
ATTR_INTEGRATION_COUNT,
ATTR_INTEGRATIONS,
ATTR_ONBOARDED,
ATTR_PREFERENCES,
ATTR_PROTECTED,
ATTR_SLUG,
ATTR_STATE_COUNT,
ATTR_STATISTICS,
ATTR_SUPERVISOR,
ATTR_SUPPORTED,
ATTR_USAGE,
ATTR_USER_COUNT,
ATTR_VERSION,
LOGGER,
PREFERENCE_SCHEMA,
STORAGE_KEY,
STORAGE_VERSION,
)


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 = {ATTR_PREFERENCES: {}, ATTR_ONBOARDED: False}
self._store: Store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)

@property
def preferences(self) -> dict:
"""Return the current active preferences."""
preferences = self._data[ATTR_PREFERENCES]
return {
ATTR_BASE: preferences.get(ATTR_BASE, False),
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
}

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

@property
def supervisor(self) -> bool:
"""Return bool if a supervisor is present."""
return hassio.is_hassio(self.hass)

async def load(self) -> None:
"""Load preferences."""
stored = await self._store.async_load()
if stored:
self._data = stored
if self.supervisor:
supervisor_info = hassio.get_supervisor_info(self.hass)
if not self.onboarded:
# User have not configured analytics, get this setting from the supervisor
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data[ATTR_PREFERENCES][ATTR_DIAGNOSTICS] = True
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data[ATTR_PREFERENCES][ATTR_DIAGNOSTICS] = False

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

if self.supervisor:
await hassio.async_update_diagnostics(
self.hass, self.preferences.get(ATTR_DIAGNOSTICS, False)
)

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

if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
LOGGER.debug("Nothing to submit")
return

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

if self.supervisor:
supervisor_info = hassio.get_supervisor_info(self.hass)

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 self.preferences.get(ATTR_USAGE, False) or self.preferences.get(
ATTR_STATISTICS, False
):
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 not integration.is_built_in:
continue

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

if supervisor_info is not None:
installed_addons = await asyncio.gather(
*[
hassio.async_get_addon_info(self.hass, 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 self.preferences.get(ATTR_USAGE, False):
payload[ATTR_INTEGRATIONS] = integrations
if supervisor_info is not None:
payload[ATTR_ADDONS] = addons

if self.preferences.get(ATTR_STATISTICS, False):
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
)
47 changes: 47 additions & 0 deletions homeassistant/components/analytics/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Constants for the analytics integration."""
from datetime import timedelta
import logging

import voluptuous as vol

ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
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_AUTO_UPDATE = "auto_update"
ATTR_AUTOMATION_COUNT = "automation_count"
ATTR_BASE = "base"
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_STATISTICS = "statistics"
ATTR_SUPERVISOR = "supervisor"
ATTR_SUPPORTED = "supported"
ATTR_USAGE = "usage"
ATTR_USER_COUNT = "user_count"
ATTR_VERSION = "version"


PREFERENCE_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_BASE): bool,
vol.Optional(ATTR_DIAGNOSTICS): bool,
vol.Optional(ATTR_STATISTICS): bool,
vol.Optional(ATTR_USAGE): bool,
}
)
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"
}
1 change: 1 addition & 0 deletions homeassistant/components/default_config/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"name": "Default Config",
"documentation": "https://www.home-assistant.io/integrations/default_config",
"dependencies": [
"analytics",
"automation",
"cloud",
"counter",
Expand Down
10 changes: 10 additions & 0 deletions homeassistant/components/hassio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ async def async_get_addon_info(hass: HomeAssistantType, slug: str) -> dict:
return await hassio.get_addon_info(slug)


@bind_hass
async def async_update_diagnostics(hass: HomeAssistantType, diagnostics: bool) -> dict:
"""Update Supervisor diagnostics toggle.

The caller of the function should handle HassioAPIError.
"""
hassio = hass.data[DOMAIN]
return await hassio.update_diagnostics(diagnostics)


@bind_hass
@api_data
async def async_install_addon(hass: HomeAssistantType, slug: str) -> dict:
Expand Down
10 changes: 10 additions & 0 deletions homeassistant/components/hassio/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ def update_hass_timezone(self, timezone):
"""
return self.send_command("/supervisor/options", payload={"timezone": timezone})

@_api_bool
def update_diagnostics(self, diagnostics: bool):
"""Update Supervisor diagnostics setting.

This method return a coroutine.
"""
return self.send_command(
"/supervisor/options", payload={"diagnostics": diagnostics}
)

async def send_command(self, command, method="post", payload=None, timeout=10):
"""Send API command to Hass.io.

Expand Down
Loading