Compare commits

...

5 Commits

Author SHA1 Message Date
Mike Degatano c86d7052c0 Fixes from feedback 2026-05-26 21:06:53 +00:00
Mike Degatano 07965e468b Fix prek errors 2026-05-26 20:24:10 +00:00
Mike Degatano 5972dc182b Fix config entry from feedback
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 20:24:07 +00:00
Mike Degatano 7ad535841a Hassfest changes for config flow 2026-05-26 20:24:07 +00:00
Mike Degatano d9fae7fecf Migrate analytics integration to config entry setup
- Add config_flow.py with a minimal system config flow
- Split async_setup (lightweight: YAML config, labs feature, discovery
  flow, websocket/HTTP registration) from async_setup_entry (heavy:
  Analytics init, load, scheduling, listeners)
- Add async_unload_entry that cancels scheduled analytics tasks
- Thread snapshots_url from YAML through hass.data so it reaches
  async_setup_entry without persisting to config entry data, keeping
  the option as a hidden developer-only YAML setting for now
- Catch HassioNotReadyError from Analytics.load and raise
  ConfigEntryNotReady so setup is retried when Supervisor is not yet
  ready
- Register websocket commands and HTTP view in async_setup so they
  survive entry reload; guard both handlers with ERR_NOT_FOUND when
  the entry is not loaded
- Replace async_listen_once(EVENT_HOMEASSISTANT_STARTED) with
  async_at_started so the schedule starts immediately on reload when
  HA is already running
- Add cancel_scheduled() to Analytics class
- Update stale comments in Analytics.load and send_analytics
- Add supervisor_not_ready exception translation key
- Add tests for: ConfigEntryNotReady on supervisor failure, schedule
  fires and sends analytics, unload stops the schedule, websocket
  error when entry not loaded, snapshots_url routes to custom URL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 20:24:07 +00:00
7 changed files with 247 additions and 30 deletions
+52 -18
View File
@@ -5,8 +5,12 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components import labs, websocket_api from homeassistant.components import labs, websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
) )
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN) DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
LABS_SNAPSHOT_FEATURE = "snapshots" LABS_SNAPSHOT_FEATURE = "snapshots"
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the analytics integration.""" """Set up the analytics integration."""
analytics_config = config.get(DOMAIN, {}) analytics_config = config.get(DOMAIN, {})
snapshots_url: str | None = None
if CONF_SNAPSHOTS_URL in analytics_config: if CONF_SNAPSHOTS_URL in analytics_config:
await labs.async_update_preview_feature( await labs.async_update_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
) )
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL] snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
else:
snapshots_url = None
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Analytics from a config entry."""
snapshots_url = hass.data.get(_DATA_SNAPSHOTS_URL)
analytics = Analytics(hass, snapshots_url) analytics = Analytics(hass, snapshots_url)
# Load stored data try:
await analytics.load() await analytics.load()
except HassioNotReadyError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_ready",
) from err
started = False started = False
@@ -80,26 +106,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if started: if started:
await analytics.async_schedule() await analytics.async_schedule()
async def start_schedule(_event: Event) -> None: async def start_schedule(hass: HomeAssistant) -> None:
"""Start the send schedule after the started event.""" """Start the send schedule once Home Assistant has started."""
nonlocal started nonlocal started
started = True started = True
await analytics.async_schedule() await analytics.async_schedule()
labs.async_subscribe_preview_feature( entry.async_on_unload(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
) )
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) entry.async_on_unload(async_at_started(hass, start_schedule))
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 hass.data[DATA_COMPONENT] = analytics
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Analytics config entry."""
analytics = hass.data.pop(DATA_COMPONENT)
analytics.cancel_scheduled()
return True
@callback @callback
@websocket_api.require_admin @websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "analytics"}) @websocket_api.websocket_command({vol.Required("type"): "analytics"})
@@ -109,7 +139,9 @@ def websocket_analytics(
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Return analytics preferences.""" """Return analytics preferences."""
analytics = hass.data[DATA_COMPONENT] if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
connection.send_result( connection.send_result(
msg["id"], msg["id"],
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded}, {ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
@@ -130,8 +162,10 @@ async def websocket_analytics_preferences(
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Update analytics preferences.""" """Update analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
preferences = msg[ATTR_PREFERENCES] preferences = msg[ATTR_PREFERENCES]
analytics = hass.data[DATA_COMPONENT]
await analytics.save_preferences(preferences) await analytics.save_preferences(preferences)
await analytics.async_schedule() await analytics.async_schedule()
+16 -10
View File
@@ -299,12 +299,8 @@ class Analytics:
self._data = AnalyticsData.from_dict(stored) self._data = AnalyticsData.from_dict(stored)
if self.supervisor and not self.onboarded: if self.supervisor and not self.onboarded:
# This may raise HassioNotReadyError if Supervisor was unreachable # This may raise HassioNotReadyError if Supervisor was unreachable.
# during setup of the Supervisor integration. That will fail setup # The caller is responsible for handling this and triggering a retry.
# of this integration. However there is no better option at this time
# since we need to get the diagnostic setting from Supervisor to correctly
# setup this integration and we can't raise ConfigEntryNotReady to
# trigger a retry from async_setup.
supervisor_info = hassio.get_supervisor_info(self._hass) supervisor_info = hassio.get_supervisor_info(self._hass)
# User have not configured analytics, get this setting from the supervisor # User have not configured analytics, get this setting from the supervisor
@@ -349,10 +345,10 @@ class Analytics:
await self._save() await self._save()
if self.supervisor: if self.supervisor:
# get_supervisor_info was called during setup so we can't get here # Try to pull Supervisor information, but don't fail if some or all
# if it raised. The others may raise HassioNotReadyError if only some # of it is unavailable due to setup failures in the hassio integration.
# data was successfully fetched from Supervisor with contextlib.suppress(hassio.HassioNotReadyError):
supervisor_info = hassio.get_supervisor_info(hass) supervisor_info = hassio.get_supervisor_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError): with contextlib.suppress(hassio.HassioNotReadyError):
operating_system_info = hassio.get_os_info(hass) operating_system_info = hassio.get_os_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError): with contextlib.suppress(hassio.HassioNotReadyError):
@@ -630,6 +626,16 @@ class Analytics:
err, err,
) )
@callback
def cancel_scheduled(self) -> None:
"""Cancel all scheduled analytics tasks."""
if self._basic_scheduled is not None:
self._basic_scheduled()
self._basic_scheduled = None
if self._snapshot_scheduled is not None:
self._snapshot_scheduled()
self._snapshot_scheduled = None
async def async_schedule(self) -> None: async def async_schedule(self) -> None:
"""Schedule analytics.""" """Schedule analytics."""
if not self.onboarded: if not self.onboarded:
@@ -0,0 +1,19 @@
"""Config flow for Analytics integration."""
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Analytics."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Analytics", data={})
@@ -3,6 +3,7 @@
"name": "Analytics", "name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"], "after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"config_flow": true,
"dependencies": ["api", "websocket_api", "http"], "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",
@@ -14,5 +15,6 @@
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new" "report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
} }
}, },
"quality_scale": "internal" "quality_scale": "internal",
"single_config_entry": true
} }
@@ -1,4 +1,9 @@
{ {
"exceptions": {
"supervisor_not_ready": {
"message": "Supervisor was not ready during setup, will retry"
}
},
"preview_features": { "preview_features": {
"snapshots": { "snapshots": {
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).", "description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
+1
View File
@@ -59,6 +59,7 @@ FLOWS = {
"amberelectric", "amberelectric",
"ambient_network", "ambient_network",
"ambient_station", "ambient_station",
"analytics",
"analytics_insights", "analytics_insights",
"android_ip_webcam", "android_ip_webcam",
"androidtv", "androidtv",
+151 -1
View File
@@ -6,15 +6,18 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant.components.analytics import LABS_SNAPSHOT_FEATURE from homeassistant.components.analytics import CONF_SNAPSHOTS_URL, LABS_SNAPSHOT_FEATURE
from homeassistant.components.analytics.const import ( from homeassistant.components.analytics.const import (
BASIC_ENDPOINT_URL, BASIC_ENDPOINT_URL,
BASIC_ENDPOINT_URL_DEV,
DOMAIN, DOMAIN,
SNAPSHOT_DEFAULT_URL, SNAPSHOT_DEFAULT_URL,
SNAPSHOT_URL_PATH, SNAPSHOT_URL_PATH,
STORAGE_KEY, STORAGE_KEY,
) )
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.components.labs import async_update_preview_feature from homeassistant.components.labs import async_update_preview_feature
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@@ -37,6 +40,153 @@ async def test_setup(hass: HomeAssistant) -> None:
assert DOMAIN in hass.data assert DOMAIN in hass.data
async def test_setup_with_snapshots_url(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test setup with snapshots_url in YAML config sends snapshots to that URL."""
custom_url = "https://custom-snapshot-endpoint.example.com"
snapshot_endpoint = custom_url + SNAPSHOT_URL_PATH
aioclient_mock.post(snapshot_endpoint, status=200, json={})
with patch(
"homeassistant.components.analytics.analytics._async_snapshot_payload",
return_value={"mock": {}},
):
assert await async_setup_component(hass, "labs", {})
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_SNAPSHOTS_URL: custom_url}}
)
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"snapshots": True}}
)
assert (await ws_client.receive_json())["success"]
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert any(str(call[1]) == snapshot_endpoint for call in aioclient_mock.mock_calls)
async def test_setup_entry_supervisor_not_ready(hass: HomeAssistant) -> None:
"""Test that HassioNotReadyError raises ConfigEntryNotReady."""
with (
patch(
"homeassistant.components.analytics.analytics.is_hassio",
return_value=True,
),
patch(
"homeassistant.components.hassio.get_supervisor_info",
side_effect=HassioNotReadyError,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_schedule_starts_and_sends_analytics(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that the analytics schedule fires and sends analytics after time travel."""
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"base": True}}
)
assert (await ws_client.receive_json())["success"]
assert len(aioclient_mock.mock_calls) == 0
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
async def test_unload_entry(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unloading the config entry stops the analytics schedule."""
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"base": True}}
)
await ws_client.receive_json()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
@pytest.mark.parametrize(
("ws_type", "ws_options"),
[("analytics", {}), ("analytics/preferences", {"preferences": {"base": True}})],
)
async def test_websocket_not_loaded(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
ws_type: str,
ws_options: dict[str, Any],
) -> None:
"""Test websocket returns error when analytics entry failed to load."""
with (
patch(
"homeassistant.components.analytics.analytics.is_hassio",
return_value=True,
),
patch(
"homeassistant.components.hassio.get_supervisor_info",
side_effect=HassioNotReadyError,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id({"type": ws_type} | ws_options)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "not_found"
@pytest.mark.usefixtures("mock_snapshot_payload") @pytest.mark.usefixtures("mock_snapshot_payload")
async def test_labs_feature_toggle( async def test_labs_feature_toggle(
hass: HomeAssistant, hass: HomeAssistant,