diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 0df3b8138e2..83610f0dc75 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +from .http import AnalyticsDevicesView 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_preferences) + hass.http.register_view(AnalyticsDevicesView) + hass.data[DATA_COMPONENT] = analytics return True diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 1a07a8abd0f..8a2a182c796 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -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.core import HomeAssistant, callback 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.hassio import is_hassio 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 class AnalyticsData: """Analytics data.""" @@ -184,7 +189,7 @@ class Analytics: return 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)) if self.supervisor: @@ -381,3 +386,83 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: ).values(): domains.update(platforms) 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, + } diff --git a/homeassistant/components/analytics/http.py b/homeassistant/components/analytics/http.py new file mode 100644 index 00000000000..a91b373bc45 --- /dev/null +++ b/homeassistant/components/analytics/http.py @@ -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" + }, + ) diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 5142a86ad97..ab51ed31c9e 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -3,7 +3,7 @@ "name": "Analytics", "after_dependencies": ["energy", "hassio", "recorder"], "codeowners": ["@home-assistant/core", "@ludeeus"], - "dependencies": ["api", "websocket_api"], + "dependencies": ["api", "websocket_api", "http"], "documentation": "https://www.home-assistant.io/integrations/analytics", "integration_type": "system", "iot_class": "cloud_push", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 01d08572197..90f3049d8fd 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1,8 +1,9 @@ """The tests for the analytics .""" from collections.abc import Generator +from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, patch import aiohttp from awesomeversion import AwesomeVersion @@ -10,7 +11,10 @@ import pytest from syrupy.assertion import SnapshotAssertion 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 ( ANALYTICS_ENDPOINT_URL, ANALYTICS_ENDPOINT_URL_DEV, @@ -22,11 +26,13 @@ from homeassistant.components.analytics.const import ( from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator MOCK_UUID = "abcdefg" MOCK_VERSION = "1970.1.0" @@ -37,8 +43,9 @@ MOCK_VERSION_NIGHTLY = "1970.1.0.dev19700101" @pytest.fixture(autouse=True) def uuid_mock() -> Generator[None]: """Mock the UUID.""" - with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: - hex_mock.return_value = MOCK_UUID + with patch( + "homeassistant.components.analytics.analytics.gen_uuid", return_value=MOCK_UUID + ): yield @@ -966,3 +973,104 @@ async def test_submitting_legacy_integrations( assert submitted_data["integrations"] == ["legacy_binary_sensor"] assert submitted_data == logged_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)