1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Enable snapshot analytics as labs feature (#160068)

Co-authored-by: Steven Travers <steven.travers20@gmail.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Artur Pragacz
2026-01-28 13:24:38 +01:00
committed by GitHub
parent 699b4b12da
commit 020d122799
7 changed files with 152 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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