Skip to content

Commit bd0ab4d

Browse files
authored
Add snapshot device analytics url config option (home-assistant#156984)
1 parent 80151b2 commit bd0ab4d

File tree

5 files changed

+184
-128
lines changed

5 files changed

+184
-128
lines changed

homeassistant/components/analytics/__init__.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from homeassistant.components import websocket_api
88
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
99
from homeassistant.core import Event, HomeAssistant, callback
10-
from homeassistant.helpers import config_validation as cv
1110
from homeassistant.helpers.typing import ConfigType
1211
from homeassistant.util.hass_dict import HassKey
1312

@@ -30,14 +29,36 @@
3029
"async_devices_payload",
3130
]
3231

33-
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
32+
CONF_SNAPSHOTS_URL = "snapshots_url"
33+
34+
CONFIG_SCHEMA = vol.Schema(
35+
{
36+
DOMAIN: vol.Schema(
37+
{
38+
vol.Optional(CONF_SNAPSHOTS_URL): vol.Any(str, None),
39+
}
40+
)
41+
},
42+
extra=vol.ALLOW_EXTRA,
43+
)
3444

3545
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
3646

3747

38-
async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
48+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
3949
"""Set up the analytics integration."""
40-
analytics = Analytics(hass)
50+
analytics_config = config.get(DOMAIN, {})
51+
52+
# For now we want to enable device analytics only if the url option
53+
# is explicitly listed in YAML.
54+
if CONF_SNAPSHOTS_URL in analytics_config:
55+
disable_snapshots = False
56+
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
57+
else:
58+
disable_snapshots = True
59+
snapshots_url = None
60+
61+
analytics = Analytics(hass, snapshots_url, disable_snapshots)
4162

4263
# Load stored data
4364
await analytics.load()

homeassistant/components/analytics/analytics.py

Lines changed: 53 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,6 @@
5959
from homeassistant.setup import async_get_loaded_integrations
6060

6161
from .const import (
62-
ANALYTICS_ENDPOINT_URL,
63-
ANALYTICS_ENDPOINT_URL_DEV,
64-
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
6562
ATTR_ADDON_COUNT,
6663
ATTR_ADDONS,
6764
ATTR_ARCH,
@@ -91,10 +88,14 @@
9188
ATTR_USER_COUNT,
9289
ATTR_UUID,
9390
ATTR_VERSION,
91+
BASIC_ENDPOINT_URL,
92+
BASIC_ENDPOINT_URL_DEV,
9493
DOMAIN,
9594
INTERVAL,
9695
LOGGER,
9796
PREFERENCE_SCHEMA,
97+
SNAPSHOT_DEFAULT_URL,
98+
SNAPSHOT_URL_PATH,
9899
SNAPSHOT_VERSION,
99100
STORAGE_KEY,
100101
STORAGE_VERSION,
@@ -236,10 +237,18 @@ def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
236237
class Analytics:
237238
"""Analytics helper class for the analytics integration."""
238239

239-
def __init__(self, hass: HomeAssistant) -> None:
240+
def __init__(
241+
self,
242+
hass: HomeAssistant,
243+
snapshots_url: str | None = None,
244+
disable_snapshots: bool = False,
245+
) -> None:
240246
"""Initialize the Analytics class."""
241-
self.hass: HomeAssistant = hass
242-
self.session = async_get_clientsession(hass)
247+
self._hass: HomeAssistant = hass
248+
self._snapshots_url = snapshots_url
249+
self._disable_snapshots = disable_snapshots
250+
251+
self._session = async_get_clientsession(hass)
243252
self._data = AnalyticsData(False, {})
244253
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
245254
self._basic_scheduled: CALLBACK_TYPE | None = None
@@ -249,13 +258,15 @@ def __init__(self, hass: HomeAssistant) -> None:
249258
def preferences(self) -> dict:
250259
"""Return the current active preferences."""
251260
preferences = self._data.preferences
252-
return {
261+
result = {
253262
ATTR_BASE: preferences.get(ATTR_BASE, False),
254-
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
255263
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
256264
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
257265
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
258266
}
267+
if not self._disable_snapshots:
268+
result[ATTR_SNAPSHOTS] = preferences.get(ATTR_SNAPSHOTS, False)
269+
return result
259270

260271
@property
261272
def onboarded(self) -> bool:
@@ -272,13 +283,13 @@ def endpoint_basic(self) -> str:
272283
"""Return the endpoint that will receive the payload."""
273284
if RELEASE_CHANNEL is ReleaseChannel.DEV:
274285
# dev installations will contact the dev analytics environment
275-
return ANALYTICS_ENDPOINT_URL_DEV
276-
return ANALYTICS_ENDPOINT_URL
286+
return BASIC_ENDPOINT_URL_DEV
287+
return BASIC_ENDPOINT_URL
277288

278289
@property
279290
def supervisor(self) -> bool:
280291
"""Return bool if a supervisor is present."""
281-
return is_hassio(self.hass)
292+
return is_hassio(self._hass)
282293

283294
async def load(self) -> None:
284295
"""Load preferences."""
@@ -288,7 +299,7 @@ async def load(self) -> None:
288299

289300
if (
290301
self.supervisor
291-
and (supervisor_info := hassio.get_supervisor_info(self.hass)) is not None
302+
and (supervisor_info := hassio.get_supervisor_info(self._hass)) is not None
292303
):
293304
if not self.onboarded:
294305
# User have not configured analytics, get this setting from the supervisor
@@ -315,15 +326,15 @@ async def save_preferences(self, preferences: dict) -> None:
315326

316327
if self.supervisor:
317328
await hassio.async_update_diagnostics(
318-
self.hass, self.preferences.get(ATTR_DIAGNOSTICS, False)
329+
self._hass, self.preferences.get(ATTR_DIAGNOSTICS, False)
319330
)
320331

321332
async def send_analytics(self, _: datetime | None = None) -> None:
322333
"""Send analytics."""
323334
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
324335
return
325336

326-
hass = self.hass
337+
hass = self._hass
327338
supervisor_info = None
328339
operating_system_info: dict[str, Any] = {}
329340

@@ -463,7 +474,7 @@ async def send_analytics(self, _: datetime | None = None) -> None:
463474

464475
try:
465476
async with timeout(30):
466-
response = await self.session.post(self.endpoint_basic, json=payload)
477+
response = await self._session.post(self.endpoint_basic, json=payload)
467478
if response.status == 200:
468479
LOGGER.info(
469480
(
@@ -479,11 +490,9 @@ async def send_analytics(self, _: datetime | None = None) -> None:
479490
self.endpoint_basic,
480491
)
481492
except TimeoutError:
482-
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
493+
LOGGER.error("Timeout sending analytics to %s", BASIC_ENDPOINT_URL)
483494
except aiohttp.ClientError as err:
484-
LOGGER.error(
485-
"Error sending analytics to %s: %r", ANALYTICS_ENDPOINT_URL, err
486-
)
495+
LOGGER.error("Error sending analytics to %s: %r", BASIC_ENDPOINT_URL, err)
487496

488497
@callback
489498
def _async_should_report_integration(
@@ -507,7 +516,7 @@ def _async_should_report_integration(
507516
if not integration.config_flow:
508517
return False
509518

510-
entries = self.hass.config_entries.async_entries(integration.domain)
519+
entries = self._hass.config_entries.async_entries(integration.domain)
511520

512521
# Filter out ignored and disabled entries
513522
return any(
@@ -521,7 +530,7 @@ async def send_snapshot(self, _: datetime | None = None) -> None:
521530
if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False):
522531
return
523532

524-
payload = await _async_snapshot_payload(self.hass)
533+
payload = await _async_snapshot_payload(self._hass)
525534

526535
headers = {
527536
"Content-Type": "application/json",
@@ -532,11 +541,16 @@ async def send_snapshot(self, _: datetime | None = None) -> None:
532541
self._data.submission_identifier
533542
)
534543

544+
url = (
545+
self._snapshots_url
546+
if self._snapshots_url is not None
547+
else SNAPSHOT_DEFAULT_URL
548+
)
549+
url += SNAPSHOT_URL_PATH
550+
535551
try:
536552
async with timeout(30):
537-
response = await self.session.post(
538-
ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers
539-
)
553+
response = await self._session.post(url, json=payload, headers=headers)
540554

541555
if response.status == 200: # OK
542556
response_data = await response.json()
@@ -562,7 +576,7 @@ async def send_snapshot(self, _: datetime | None = None) -> None:
562576
# Clear the invalid identifier and retry on next cycle
563577
LOGGER.warning(
564578
"Invalid submission identifier to %s, clearing: %s",
565-
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
579+
url,
566580
error_message,
567581
)
568582
self._data.submission_identifier = None
@@ -571,34 +585,34 @@ async def send_snapshot(self, _: datetime | None = None) -> None:
571585
LOGGER.warning(
572586
"Malformed snapshot analytics submission (%s) to %s: %s",
573587
error_kind,
574-
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
588+
url,
575589
error_message,
576590
)
577591

578592
elif response.status == 503: # Service Unavailable
579593
response_text = await response.text()
580594
LOGGER.warning(
581595
"Snapshot analytics service %s unavailable: %s",
582-
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
596+
url,
583597
response_text,
584598
)
585599

586600
else:
587601
LOGGER.warning(
588602
"Unexpected status code %s when submitting snapshot analytics to %s",
589603
response.status,
590-
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
604+
url,
591605
)
592606

593607
except TimeoutError:
594608
LOGGER.error(
595609
"Timeout sending snapshot analytics to %s",
596-
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
610+
url,
597611
)
598612
except aiohttp.ClientError as err:
599613
LOGGER.error(
600614
"Error sending snapshot analytics to %s: %r",
601-
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
615+
url,
602616
err,
603617
)
604618

@@ -622,7 +636,7 @@ async def async_schedule(self) -> None:
622636
elif self._basic_scheduled is None:
623637
# Wait 15 min after started for basic analytics
624638
self._basic_scheduled = async_call_later(
625-
self.hass,
639+
self._hass,
626640
900,
627641
HassJob(
628642
self._async_schedule_basic,
@@ -631,20 +645,19 @@ async def async_schedule(self) -> None:
631645
),
632646
)
633647

634-
if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in (
635-
ReleaseChannel.DEV,
636-
ReleaseChannel.NIGHTLY,
637-
):
648+
if not self.preferences.get(ATTR_SNAPSHOTS, False) or self._disable_snapshots:
638649
LOGGER.debug("Snapshot analytics not scheduled")
639650
if self._snapshot_scheduled:
640651
self._snapshot_scheduled()
641652
self._snapshot_scheduled = None
642653
elif self._snapshot_scheduled is None:
643654
snapshot_submission_time = self._data.snapshot_submission_time
644655

656+
interval_seconds = INTERVAL.total_seconds()
657+
645658
if snapshot_submission_time is None:
646659
# Randomize the submission time within the 24 hours
647-
snapshot_submission_time = random.uniform(0, 86400)
660+
snapshot_submission_time = random.uniform(0, interval_seconds)
648661
self._data.snapshot_submission_time = snapshot_submission_time
649662
await self._save()
650663
LOGGER.debug(
@@ -654,10 +667,10 @@ async def async_schedule(self) -> None:
654667

655668
# Calculate delay until next submission
656669
current_time = time.time()
657-
delay = (snapshot_submission_time - current_time) % 86400
670+
delay = (snapshot_submission_time - current_time) % interval_seconds
658671

659672
self._snapshot_scheduled = async_call_later(
660-
self.hass,
673+
self._hass,
661674
delay,
662675
HassJob(
663676
self._async_schedule_snapshots,
@@ -672,7 +685,7 @@ async def _async_schedule_basic(self, _: datetime | None = None) -> None:
672685

673686
# Send basic analytics every day
674687
self._basic_scheduled = async_track_time_interval(
675-
self.hass,
688+
self._hass,
676689
self.send_analytics,
677690
INTERVAL,
678691
name="basic analytics daily",
@@ -685,7 +698,7 @@ async def _async_schedule_snapshots(self, _: datetime | None = None) -> None:
685698

686699
# Send snapshot analytics every day
687700
self._snapshot_scheduled = async_track_time_interval(
688-
self.hass,
701+
self._hass,
689702
self.send_snapshot,
690703
INTERVAL,
691704
name="snapshot analytics daily",

homeassistant/components/analytics/const.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55

66
import voluptuous as vol
77

8-
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
9-
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
10-
SNAPSHOT_VERSION = "1"
11-
ANALYTICS_SNAPSHOT_ENDPOINT_URL = f"https://device-database.eco-dev-aws.openhomefoundation.com/api/v1/snapshot/{SNAPSHOT_VERSION}"
128
DOMAIN = "analytics"
139
INTERVAL = timedelta(days=1)
1410
STORAGE_KEY = "core.analytics"
1511
STORAGE_VERSION = 1
1612

13+
BASIC_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
14+
BASIC_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
15+
16+
SNAPSHOT_VERSION = 1
17+
SNAPSHOT_DEFAULT_URL = "https://device-database.eco-dev-aws.openhomefoundation.com"
18+
SNAPSHOT_URL_PATH = f"/api/v1/snapshot/{SNAPSHOT_VERSION}"
1719

1820
LOGGER: logging.Logger = logging.getLogger(__package__)
1921

0 commit comments

Comments
 (0)