mirror of
https://github.com/home-assistant/core.git
synced 2025-07-29 08:07:45 +00:00
Allow downloading a device analytics dump (#149376)
This commit is contained in:
parent
760b69d458
commit
8b8616182d
@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey
|
|||||||
|
|
||||||
from .analytics import Analytics
|
from .analytics import Analytics
|
||||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||||
|
from .http import AnalyticsDevicesView
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||||
|
|
||||||
@ -55,6 +56,8 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
|
|||||||
websocket_api.async_register_command(hass, websocket_analytics)
|
websocket_api.async_register_command(hass, websocket_analytics)
|
||||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||||
|
|
||||||
|
hass.http.register_view(AnalyticsDevicesView)
|
||||||
|
|
||||||
hass.data[DATA_COMPONENT] = analytics
|
hass.data[DATA_COMPONENT] = analytics
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ from homeassistant.config_entries import SOURCE_IGNORE
|
|||||||
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
|
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import 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.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
@ -77,6 +77,11 @@ from .const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gen_uuid() -> str:
|
||||||
|
"""Generate a new UUID."""
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AnalyticsData:
|
class AnalyticsData:
|
||||||
"""Analytics data."""
|
"""Analytics data."""
|
||||||
@ -184,7 +189,7 @@ class Analytics:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self._data.uuid is None:
|
if self._data.uuid is None:
|
||||||
self._data.uuid = uuid.uuid4().hex
|
self._data.uuid = gen_uuid()
|
||||||
await self._store.async_save(dataclass_asdict(self._data))
|
await self._store.async_save(dataclass_asdict(self._data))
|
||||||
|
|
||||||
if self.supervisor:
|
if self.supervisor:
|
||||||
@ -381,3 +386,83 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
|||||||
).values():
|
).values():
|
||||||
domains.update(platforms)
|
domains.update(platforms)
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||||
|
"""Return the devices payload."""
|
||||||
|
integrations_without_model_id: set[str] = set()
|
||||||
|
devices: list[dict[str, Any]] = []
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
# Devices that need via device info set
|
||||||
|
new_indexes: dict[str, int] = {}
|
||||||
|
via_devices: dict[str, str] = {}
|
||||||
|
|
||||||
|
seen_integrations = set()
|
||||||
|
|
||||||
|
for device in dev_reg.devices.values():
|
||||||
|
# Ignore services
|
||||||
|
if device.entry_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not device.primary_config_entry:
|
||||||
|
continue
|
||||||
|
|
||||||
|
config_entry = hass.config_entries.async_get_entry(device.primary_config_entry)
|
||||||
|
|
||||||
|
if config_entry is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_integrations.add(config_entry.domain)
|
||||||
|
|
||||||
|
if not device.model_id:
|
||||||
|
integrations_without_model_id.add(config_entry.domain)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not device.manufacturer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_indexes[device.id] = len(devices)
|
||||||
|
devices.append(
|
||||||
|
{
|
||||||
|
"integration": config_entry.domain,
|
||||||
|
"manufacturer": device.manufacturer,
|
||||||
|
"model_id": device.model_id,
|
||||||
|
"model": device.model,
|
||||||
|
"sw_version": device.sw_version,
|
||||||
|
"hw_version": device.hw_version,
|
||||||
|
"has_suggested_area": device.suggested_area is not None,
|
||||||
|
"has_configuration_url": device.configuration_url is not None,
|
||||||
|
"via_device": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if device.via_device_id:
|
||||||
|
via_devices[device.id] = device.via_device_id
|
||||||
|
|
||||||
|
for from_device, via_device in via_devices.items():
|
||||||
|
if via_device not in new_indexes:
|
||||||
|
continue
|
||||||
|
devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device]
|
||||||
|
|
||||||
|
integrations = {
|
||||||
|
domain: integration
|
||||||
|
for domain, integration in (
|
||||||
|
await async_get_integrations(hass, seen_integrations)
|
||||||
|
).items()
|
||||||
|
if isinstance(integration, Integration)
|
||||||
|
}
|
||||||
|
|
||||||
|
for device_info in devices:
|
||||||
|
if integration := integrations.get(device_info["integration"]):
|
||||||
|
device_info["is_custom_integration"] = not integration.is_built_in
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": "home-assistant:1",
|
||||||
|
"no_model_id": sorted(
|
||||||
|
[
|
||||||
|
domain
|
||||||
|
for domain in integrations_without_model_id
|
||||||
|
if domain in integrations and integrations[domain].is_built_in
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"devices": devices,
|
||||||
|
}
|
||||||
|
27
homeassistant/components/analytics/http.py
Normal file
27
homeassistant/components/analytics/http.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""HTTP endpoints for analytics integration."""
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .analytics import async_devices_payload
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDevicesView(HomeAssistantView):
|
||||||
|
"""View to handle analytics devices payload download requests."""
|
||||||
|
|
||||||
|
url = "/api/analytics/devices"
|
||||||
|
name = "api:analytics:devices"
|
||||||
|
|
||||||
|
@require_admin
|
||||||
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
|
"""Return analytics devices payload as JSON."""
|
||||||
|
hass: HomeAssistant = request.app[KEY_HASS]
|
||||||
|
payload = await async_devices_payload(hass)
|
||||||
|
return self.json(
|
||||||
|
payload,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": "attachment; filename=analytics_devices.json"
|
||||||
|
},
|
||||||
|
)
|
@ -3,7 +3,7 @@
|
|||||||
"name": "Analytics",
|
"name": "Analytics",
|
||||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||||
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
||||||
"dependencies": ["api", "websocket_api"],
|
"dependencies": ["api", "websocket_api", "http"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
"""The tests for the analytics ."""
|
"""The tests for the analytics ."""
|
||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from http import HTTPStatus
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
@ -10,7 +11,10 @@ import pytest
|
|||||||
from syrupy.assertion import SnapshotAssertion
|
from syrupy.assertion import SnapshotAssertion
|
||||||
from syrupy.matchers import path_type
|
from syrupy.matchers import path_type
|
||||||
|
|
||||||
from homeassistant.components.analytics.analytics import Analytics
|
from homeassistant.components.analytics.analytics import (
|
||||||
|
Analytics,
|
||||||
|
async_devices_payload,
|
||||||
|
)
|
||||||
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,
|
||||||
@ -22,11 +26,13 @@ from homeassistant.components.analytics.const import (
|
|||||||
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
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 tests.common import MockConfigEntry, MockModule, mock_integration
|
from tests.common import MockConfigEntry, MockModule, mock_integration
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
from tests.typing import ClientSessionGenerator
|
||||||
|
|
||||||
MOCK_UUID = "abcdefg"
|
MOCK_UUID = "abcdefg"
|
||||||
MOCK_VERSION = "1970.1.0"
|
MOCK_VERSION = "1970.1.0"
|
||||||
@ -37,8 +43,9 @@ MOCK_VERSION_NIGHTLY = "1970.1.0.dev19700101"
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def uuid_mock() -> Generator[None]:
|
def uuid_mock() -> Generator[None]:
|
||||||
"""Mock the UUID."""
|
"""Mock the UUID."""
|
||||||
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock:
|
with patch(
|
||||||
hex_mock.return_value = MOCK_UUID
|
"homeassistant.components.analytics.analytics.gen_uuid", return_value=MOCK_UUID
|
||||||
|
):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@ -966,3 +973,104 @@ async def test_submitting_legacy_integrations(
|
|||||||
assert submitted_data["integrations"] == ["legacy_binary_sensor"]
|
assert submitted_data["integrations"] == ["legacy_binary_sensor"]
|
||||||
assert submitted_data == logged_data
|
assert submitted_data == logged_data
|
||||||
assert snapshot == submitted_data
|
assert snapshot == submitted_data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_devices_payload(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_client: ClientSessionGenerator,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test devices payload."""
|
||||||
|
assert await async_setup_component(hass, "analytics", {})
|
||||||
|
assert await async_devices_payload(hass) == {
|
||||||
|
"version": "home-assistant:1",
|
||||||
|
"no_model_id": [],
|
||||||
|
"devices": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_config_entry = MockConfigEntry(domain="hue")
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
# Normal entry
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=mock_config_entry.entry_id,
|
||||||
|
identifiers={("device", "1")},
|
||||||
|
sw_version="test-sw-version",
|
||||||
|
hw_version="test-hw-version",
|
||||||
|
name="test-name",
|
||||||
|
manufacturer="test-manufacturer",
|
||||||
|
model="test-model",
|
||||||
|
model_id="test-model-id",
|
||||||
|
suggested_area="Game Room",
|
||||||
|
configuration_url="http://example.com/config",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ignored because service type
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=mock_config_entry.entry_id,
|
||||||
|
identifiers={("device", "2")},
|
||||||
|
manufacturer="test-manufacturer",
|
||||||
|
model_id="test-model-id",
|
||||||
|
entry_type=dr.DeviceEntryType.SERVICE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ignored because no model id
|
||||||
|
no_model_id_config_entry = MockConfigEntry(domain="no_model_id")
|
||||||
|
no_model_id_config_entry.add_to_hass(hass)
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=no_model_id_config_entry.entry_id,
|
||||||
|
identifiers={("device", "4")},
|
||||||
|
manufacturer="test-manufacturer",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ignored because no manufacturer
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=mock_config_entry.entry_id,
|
||||||
|
identifiers={("device", "5")},
|
||||||
|
model_id="test-model-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Entry with via device
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=mock_config_entry.entry_id,
|
||||||
|
identifiers={("device", "6")},
|
||||||
|
manufacturer="test-manufacturer6",
|
||||||
|
model_id="test-model-id6",
|
||||||
|
via_device=("device", "1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await async_devices_payload(hass) == {
|
||||||
|
"version": "home-assistant:1",
|
||||||
|
"no_model_id": [],
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"manufacturer": "test-manufacturer",
|
||||||
|
"model_id": "test-model-id",
|
||||||
|
"model": "test-model",
|
||||||
|
"sw_version": "test-sw-version",
|
||||||
|
"hw_version": "test-hw-version",
|
||||||
|
"integration": "hue",
|
||||||
|
"is_custom_integration": False,
|
||||||
|
"has_suggested_area": True,
|
||||||
|
"has_configuration_url": True,
|
||||||
|
"via_device": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manufacturer": "test-manufacturer6",
|
||||||
|
"model_id": "test-model-id6",
|
||||||
|
"model": None,
|
||||||
|
"sw_version": None,
|
||||||
|
"hw_version": None,
|
||||||
|
"integration": "hue",
|
||||||
|
"is_custom_integration": False,
|
||||||
|
"has_suggested_area": False,
|
||||||
|
"has_configuration_url": False,
|
||||||
|
"via_device": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
client = await hass_client()
|
||||||
|
response = await client.get("/api/analytics/devices")
|
||||||
|
assert response.status == HTTPStatus.OK
|
||||||
|
assert await response.json() == await async_devices_payload(hass)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user