diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index b4422ea367d..3bd2e8d7533 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -4,7 +4,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import labs, websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.typing import ConfigType @@ -18,7 +18,13 @@ from .analytics import ( EntityAnalyticsModifications, async_devices_payload, ) -from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA +from .const import ( + ATTR_ONBOARDED, + ATTR_PREFERENCES, + ATTR_SNAPSHOTS, + DOMAIN, + PREFERENCE_SCHEMA, +) from .http import AnalyticsDevicesView __all__ = [ @@ -44,29 +50,55 @@ CONFIG_SCHEMA = vol.Schema( DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN) +LABS_SNAPSHOT_FEATURE = "snapshots" + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the analytics integration.""" analytics_config = config.get(DOMAIN, {}) - # For now we want to enable device analytics only if the url option - # is explicitly listed in YAML. if CONF_SNAPSHOTS_URL in analytics_config: - disable_snapshots = False + await labs.async_update_preview_feature( + hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True + ) snapshots_url = analytics_config[CONF_SNAPSHOTS_URL] else: - disable_snapshots = True snapshots_url = None - analytics = Analytics(hass, snapshots_url, disable_snapshots) + analytics = Analytics(hass, snapshots_url) # Load stored data await analytics.load() + started = False + + async def _async_handle_labs_update( + event: Event[labs.EventLabsUpdatedData], + ) -> None: + """Handle labs feature toggle.""" + await analytics.save_preferences({ATTR_SNAPSHOTS: event.data["enabled"]}) + if started: + await analytics.async_schedule() + + @callback + def _async_labs_event_filter(event_data: labs.EventLabsUpdatedData) -> bool: + """Filter labs events for this integration's snapshot feature.""" + return ( + event_data["domain"] == DOMAIN + and event_data["preview_feature"] == LABS_SNAPSHOT_FEATURE + ) + async def start_schedule(_event: Event) -> None: """Start the send schedule after the started event.""" + nonlocal started + started = True await analytics.async_schedule() + hass.bus.async_listen( + labs.EVENT_LABS_UPDATED, + _async_handle_labs_update, + event_filter=_async_labs_event_filter, + ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) websocket_api.async_register_command(hass, websocket_analytics) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 2895818b528..7778e3239ab 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -22,6 +22,7 @@ from homeassistant.components.energy import ( DOMAIN as ENERGY_DOMAIN, is_configured as energy_is_configured, ) +from homeassistant.components.labs import async_is_preview_feature_enabled from homeassistant.components.recorder import ( DOMAIN as RECORDER_DOMAIN, get_instance as get_recorder_instance, @@ -241,12 +242,10 @@ class Analytics: self, hass: HomeAssistant, snapshots_url: str | None = None, - disable_snapshots: bool = False, ) -> None: """Initialize the Analytics class.""" self._hass: HomeAssistant = hass self._snapshots_url = snapshots_url - self._disable_snapshots = disable_snapshots self._session = async_get_clientsession(hass) self._data = AnalyticsData(False, {}) @@ -258,15 +257,13 @@ class Analytics: def preferences(self) -> dict: """Return the current active preferences.""" preferences = self._data.preferences - result = { + 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), + ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False), } - if not self._disable_snapshots: - result[ATTR_SNAPSHOTS] = preferences.get(ATTR_SNAPSHOTS, False) - return result @property def onboarded(self) -> bool: @@ -291,6 +288,11 @@ class Analytics: """Return bool if a supervisor is present.""" return is_hassio(self._hass) + @property + def _snapshots_enabled(self) -> bool: + """Check if snapshots feature is enabled via labs.""" + return async_is_preview_feature_enabled(self._hass, DOMAIN, "snapshots") + async def load(self) -> None: """Load preferences.""" stored = await self._store.async_load() @@ -645,7 +647,10 @@ class Analytics: ), ) - if not self.preferences.get(ATTR_SNAPSHOTS, False) or self._disable_snapshots: + if ( + not self.preferences.get(ATTR_SNAPSHOTS, False) + or not self._snapshots_enabled + ): LOGGER.debug("Snapshot analytics not scheduled") if self._snapshot_scheduled: self._snapshot_scheduled() diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 606b7a2f328..03d699f5654 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -7,5 +7,11 @@ "documentation": "https://www.home-assistant.io/integrations/analytics", "integration_type": "system", "iot_class": "cloud_push", + "preview_features": { + "snapshots": { + "feedback_url": "https://forms.gle/GqvRmgmghSDco8M46", + "report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new" + } + }, "quality_scale": "internal" } diff --git a/homeassistant/components/analytics/strings.json b/homeassistant/components/analytics/strings.json new file mode 100644 index 00000000000..1e413d34454 --- /dev/null +++ b/homeassistant/components/analytics/strings.json @@ -0,0 +1,10 @@ +{ + "preview_features": { + "snapshots": { + "description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.", + "disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.", + "enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.", + "name": "Device database" + } + } +} diff --git a/homeassistant/generated/labs.py b/homeassistant/generated/labs.py index e6ceccdeebc..b82f87ce9be 100644 --- a/homeassistant/generated/labs.py +++ b/homeassistant/generated/labs.py @@ -4,6 +4,13 @@ To update, run python3 -m script.hassfest """ LABS_PREVIEW_FEATURES = { + "analytics": { + "snapshots": { + "feedback_url": "https://forms.gle/GqvRmgmghSDco8M46", + "learn_more_url": "", + "report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new", + }, + }, "automation": { "new_triggers_conditions": { "feedback_url": "https://forms.gle/fWFZqf5MzuwWTsCH8", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 0dfac987603..cf0e327ef7f 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -111,6 +111,16 @@ def installation_type_mock() -> Generator[None]: yield +@pytest.fixture +def labs_snapshots_enabled() -> Generator[None]: + """Mock the labs feature to enable snapshots.""" + with patch( + "homeassistant.components.analytics.analytics.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + def _last_call_payload(aioclient: AiohttpClientMocker) -> dict[str, Any]: """Return the payload of the last call.""" return aioclient.mock_calls[-1][2] @@ -1454,6 +1464,7 @@ async def test_analytics_platforms( } +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_send_snapshot_disabled( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -1469,6 +1480,7 @@ async def test_send_snapshot_disabled( assert len(aioclient_mock.mock_calls) == 0 +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_send_snapshot_success( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -1493,6 +1505,7 @@ async def test_send_snapshot_success( assert "Submitted snapshot analytics to Home Assistant servers" in caplog.text +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_send_snapshot_with_existing_identifier( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -1528,6 +1541,7 @@ async def test_send_snapshot_with_existing_identifier( assert "Submitted snapshot analytics to Home Assistant servers" in caplog.text +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_send_snapshot_invalid_identifier( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -1564,6 +1578,7 @@ async def test_send_snapshot_invalid_identifier( assert "Invalid submission identifier" in caplog.text +@pytest.mark.usefixtures("labs_snapshots_enabled") @pytest.mark.parametrize( ("post_kwargs", "expected_log"), [ @@ -1628,6 +1643,7 @@ async def test_send_snapshot_error( assert expected_log in caplog.text +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_async_schedule( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -1664,6 +1680,7 @@ async def test_async_schedule( assert 0 <= preferences["snapshot_submission_time"] <= 86400 +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_async_schedule_disabled( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -1688,6 +1705,7 @@ async def test_async_schedule_disabled( assert len(aioclient_mock.mock_calls) == 0 +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_async_schedule_already_scheduled( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -1721,6 +1739,7 @@ async def test_async_schedule_already_scheduled( ) +@pytest.mark.usefixtures("labs_snapshots_enabled") @pytest.mark.parametrize(("onboarded"), [True, False]) async def test_async_schedule_cancel_when_disabled( hass: HomeAssistant, @@ -1759,6 +1778,7 @@ async def test_async_schedule_cancel_when_disabled( assert len(aioclient_mock.mock_calls) == 0 +@pytest.mark.usefixtures("labs_snapshots_enabled") async def test_async_schedule_snapshots_url( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -1787,29 +1807,3 @@ async def test_async_schedule_snapshots_url( assert len(aioclient_mock.mock_calls) == 1 assert str(aioclient_mock.mock_calls[0][1]) == endpoint - - -async def test_async_schedule_snapshots_disabled( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test that snapshots are disabled when configured.""" - aioclient_mock.post(SNAPSHOT_ENDPOINT_URL, status=200, json={}) - - analytics = Analytics(hass, disable_snapshots=True) - with patch( - "homeassistant.helpers.storage.Store.async_load", - return_value={ - "onboarded": True, - "preferences": {ATTR_BASE: False, ATTR_SNAPSHOTS: True}, - "uuid": "12345", - }, - ): - await analytics.load() - - await analytics.async_schedule() - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) - await hass.async_block_till_done() - - assert len(aioclient_mock.mock_calls) == 0 diff --git a/tests/components/analytics/test_init.py b/tests/components/analytics/test_init.py index 40353353968..daae59e5445 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -1,18 +1,33 @@ """The tests for the analytics .""" +from datetime import timedelta +from typing import Any from unittest.mock import patch import pytest -from homeassistant.components.analytics.const import BASIC_ENDPOINT_URL, DOMAIN +from homeassistant.components.analytics import LABS_SNAPSHOT_FEATURE +from homeassistant.components.analytics.const import ( + BASIC_ENDPOINT_URL, + DOMAIN, + SNAPSHOT_DEFAULT_URL, + SNAPSHOT_URL_PATH, + STORAGE_KEY, +) +from homeassistant.components.labs import async_update_preview_feature +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator MOCK_VERSION = "1970.1.0" +SNAPSHOT_ENDPOINT_URL = SNAPSHOT_DEFAULT_URL + SNAPSHOT_URL_PATH + async def test_setup(hass: HomeAssistant) -> None: """Test setup of the integration.""" @@ -22,6 +37,48 @@ async def test_setup(hass: HomeAssistant) -> None: assert DOMAIN in hass.data +async def test_labs_feature_toggle( + hass: HomeAssistant, + hass_storage: dict[str, Any], + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that snapshots can be toggled via labs feature.""" + aioclient_mock.post(SNAPSHOT_ENDPOINT_URL, status=200, json={}) + + assert await async_setup_component(hass, "labs", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 0 + + await async_update_preview_feature(hass, DOMAIN, LABS_SNAPSHOT_FEATURE, True) + + assert hass_storage[STORAGE_KEY]["data"]["preferences"]["snapshots"] is True + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) + await hass.async_block_till_done() + + assert any( + str(call[1]) == SNAPSHOT_ENDPOINT_URL for call in aioclient_mock.mock_calls + ) + + aioclient_mock.clear_requests() + + await async_update_preview_feature(hass, DOMAIN, LABS_SNAPSHOT_FEATURE, False) + + assert hass_storage[STORAGE_KEY]["data"]["preferences"]["snapshots"] is False + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 0 + + @pytest.mark.usefixtures("supervisor_client") async def test_websocket( hass: HomeAssistant,