Allow downloading a device analytics dump (#149376)

This commit is contained in:
Paulus Schoutsen 2025-07-24 17:27:02 +02:00 committed by GitHub
parent 760b69d458
commit 8b8616182d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 230 additions and 7 deletions

View File

@ -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

View File

@ -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,
}

View 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"
},
)

View File

@ -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",

View File

@ -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)