diff --git a/.strict-typing b/.strict-typing index 45d11f089dd..c5b4e376414 100644 --- a/.strict-typing +++ b/.strict-typing @@ -115,6 +115,7 @@ homeassistant.components.group.* homeassistant.components.guardian.* homeassistant.components.history.* homeassistant.components.homeassistant.triggers.event +homeassistant.components.homeassistant_alerts.* homeassistant.components.homekit homeassistant.components.homekit.accessories homeassistant.components.homekit.aidmanager diff --git a/CODEOWNERS b/CODEOWNERS index 3ad4c23816c..bd39fd68590 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -455,6 +455,8 @@ build.json @home-assistant/supervisor /tests/components/home_plus_control/ @chemaaa /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core +/homeassistant/components/homeassistant_alerts/ @home-assistant/core +/tests/components/homeassistant_alerts/ @home-assistant/core /homeassistant/components/homeassistant_yellow/ @home-assistant/core /tests/components/homeassistant_yellow/ @home-assistant/core /homeassistant/components/homekit/ @bdraco diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 3cb9e60a278..f790292c27a 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -11,6 +11,7 @@ "dhcp", "energy", "frontend", + "homeassistant_alerts", "history", "input_boolean", "input_button", diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py new file mode 100644 index 00000000000..1aedd6c5419 --- /dev/null +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -0,0 +1,185 @@ +"""The Home Assistant alerts integration.""" +from __future__ import annotations + +import asyncio +import dataclasses +from datetime import timedelta +import logging + +import aiohttp +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy + +from homeassistant.components.repairs import async_create_issue, async_delete_issue +from homeassistant.components.repairs.models import IssueSeverity +from homeassistant.const import __version__ +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.yaml import parse_yaml + +DOMAIN = "homeassistant_alerts" +UPDATE_INTERVAL = timedelta(hours=3) +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up alerts.""" + last_alerts: dict[str, str | None] = {} + + async def async_update_alerts() -> None: + nonlocal last_alerts + + active_alerts: dict[str, str | None] = {} + + for issue_id, alert in coordinator.data.items(): + # Skip creation if already created and not updated since then + if issue_id in last_alerts and alert.date_updated == last_alerts[issue_id]: + active_alerts[issue_id] = alert.date_updated + continue + + # Fetch alert to get title + description + try: + response = await async_get_clientsession(hass).get( + f"https://alerts.home-assistant.io/alerts/{alert.filename}", + timeout=aiohttp.ClientTimeout(total=10), + ) + except asyncio.TimeoutError: + _LOGGER.warning("Error fetching %s: timeout", alert.filename) + continue + + alert_content = await response.text() + alert_parts = alert_content.split("---") + + if len(alert_parts) != 3: + _LOGGER.warning( + "Error parsing %s: unexpected metadata format", alert.filename + ) + continue + + try: + alert_info = parse_yaml(alert_parts[1]) + except ValueError as err: + _LOGGER.warning("Error parsing %s metadata: %s", alert.filename, err) + continue + + if not isinstance(alert_info, dict) or "title" not in alert_info: + _LOGGER.warning("Error in %s metadata: title not found", alert.filename) + continue + + alert_title = alert_info["title"] + alert_content = alert_parts[2].strip() + + async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + learn_more_url=alert.alert_url, + severity=IssueSeverity.WARNING, + translation_key="alert", + translation_placeholders={ + "title": alert_title, + "description": alert_content, + }, + ) + active_alerts[issue_id] = alert.date_updated + + inactive_alerts = last_alerts.keys() - active_alerts.keys() + for issue_id in inactive_alerts: + async_delete_issue(hass, DOMAIN, issue_id) + + last_alerts = active_alerts + + @callback + def async_schedule_update_alerts() -> None: + if not coordinator.last_update_success: + return + + hass.async_create_task(async_update_alerts()) + + coordinator = AlertUpdateCoordinator(hass) + coordinator.async_add_listener(async_schedule_update_alerts) + await coordinator.async_refresh() + + return True + + +@dataclasses.dataclass(frozen=True) +class IntegrationAlert: + """Issue Registry Entry.""" + + integration: str + filename: str + date_updated: str | None + alert_url: str | None + + @property + def issue_id(self) -> str: + """Return the issue id.""" + return f"{self.filename}_{self.integration}" + + +class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]): + """Data fetcher for HA Alerts.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + self.ha_version = AwesomeVersion( + __version__, + ensure_strategy=AwesomeVersionStrategy.CALVER, + find_first_match=False, + ) + + async def _async_update_data(self) -> dict[str, IntegrationAlert]: + response = await async_get_clientsession(self.hass).get( + "https://alerts.home-assistant.io/alerts.json", + timeout=aiohttp.ClientTimeout(total=10), + ) + alerts = await response.json() + + result = {} + + for alert in alerts: + if "alert_url" not in alert or "integrations" not in alert: + continue + + if "homeassistant" in alert: + if "affected_from_version" in alert["homeassistant"]: + affected_from_version = AwesomeVersion( + alert["homeassistant"]["affected_from_version"], + find_first_match=False, + ) + if self.ha_version < affected_from_version: + continue + if "resolved_in_version" in alert["homeassistant"]: + resolved_in_version = AwesomeVersion( + alert["homeassistant"]["resolved_in_version"], + find_first_match=False, + ) + if self.ha_version >= resolved_in_version: + continue + + for integration in alert["integrations"]: + if "package" not in integration: + continue + + if integration["package"] not in self.hass.config.components: + continue + + integration_alert = IntegrationAlert( + integration=integration["package"], + filename=alert["filename"], + date_updated=alert.get("date_updated"), + alert_url=alert["alert_url"], + ) + + result[integration_alert.issue_id] = integration_alert + + return result diff --git a/homeassistant/components/homeassistant_alerts/manifest.json b/homeassistant/components/homeassistant_alerts/manifest.json new file mode 100644 index 00000000000..0d276c6f3ae --- /dev/null +++ b/homeassistant/components/homeassistant_alerts/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "homeassistant_alerts", + "name": "Home Assistant alerts", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/homeassistant_alerts", + "codeowners": ["@home-assistant/core"], + "dependencies": ["repairs"] +} diff --git a/homeassistant/components/homeassistant_alerts/strings.json b/homeassistant/components/homeassistant_alerts/strings.json new file mode 100644 index 00000000000..7a9634d6268 --- /dev/null +++ b/homeassistant/components/homeassistant_alerts/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "alert": { + "title": "{title}", + "description": "{description}" + } + } +} diff --git a/mypy.ini b/mypy.ini index af6abe6658f..37765023f74 100644 --- a/mypy.ini +++ b/mypy.ini @@ -988,6 +988,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homeassistant_alerts.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homekit] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index c6cc019ff31..5ce67b59198 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -56,6 +56,7 @@ NO_IOT_CLASS = [ "hardware", "history", "homeassistant", + "homeassistant_alerts", "homeassistant_yellow", "image", "input_boolean", diff --git a/tests/components/homeassistant_alerts/__init__.py b/tests/components/homeassistant_alerts/__init__.py new file mode 100644 index 00000000000..e8e83fad6bd --- /dev/null +++ b/tests/components/homeassistant_alerts/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant alerts integration.""" diff --git a/tests/components/homeassistant_alerts/fixtures/alerts_1.json b/tests/components/homeassistant_alerts/fixtures/alerts_1.json new file mode 100644 index 00000000000..381a31d7a5d --- /dev/null +++ b/tests/components/homeassistant_alerts/fixtures/alerts_1.json @@ -0,0 +1,174 @@ +[ + { + "title": "Aladdin Connect is turning off their previous connection method", + "created": "2022-07-14T06:00:00.000Z", + "integrations": [ + { + "package": "aladdin_connect" + } + ], + "homeassistant": { + "package": "homeassistant", + "resolved_in_version": "2022.7" + }, + "filename": "aladdin_connect.markdown", + "alert_url": "https://alerts.home-assistant.io/#aladdin_connect.markdown" + }, + { + "title": "Dark Sky API closed for new users", + "created": "2020-03-31T14:40:00.000Z", + "integrations": [ + { + "package": "darksky" + } + ], + "github_issue": "https://github.com/home-assistant/home-assistant.io/pull/12591", + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.30" + }, + "filename": "dark_sky.markdown", + "alert_url": "https://alerts.home-assistant.io/#dark_sky.markdown" + }, + { + "title": "Hikvision Security Vulnerability", + "created": "2021-09-20T22:08:00.000Z", + "integrations": [ + { + "package": "hikvision" + }, + { + "package": "hikvisioncam" + } + ], + "filename": "hikvision.markdown", + "alert_url": "https://alerts.home-assistant.io/#hikvision.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "Hive shutting down North American Servers", + "created": "2021-11-13T13:58:00.000Z", + "integrations": [ + { + "package": "hive" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "2021.11.0" + }, + "filename": "hive_us.markdown", + "alert_url": "https://alerts.home-assistant.io/#hive_us.markdown" + }, + { + "title": "HomematicIP (EQ-3) blocks public IP addresses, if access to the Cloud is too frequent.", + "created": "2020-12-20T12:00:00.000Z", + "integrations": [ + { + "package": "homematicip_cloud" + } + ], + "packages": [ + { + "package": "homematicip" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.7" + }, + "filename": "homematicip_cloud.markdown", + "alert_url": "https://alerts.home-assistant.io/#homematicip_cloud.markdown" + }, + { + "title": "Logitech no longer accepting API applications", + "created": "2022-05-16T12:00:00.000Z", + "integrations": [ + { + "package": "logi_circle" + } + ], + "github_issue": "https://github.com/home-assistant/core/issues/71945", + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.92.0" + }, + "filename": "logi_circle.markdown", + "alert_url": "https://alerts.home-assistant.io/#logi_circle.markdown" + }, + { + "title": "New Neato Botvacs Do Not Support Existing API", + "created": "2021-12-20T13:27:00.000Z", + "integrations": [ + { + "package": "neato" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.30" + }, + "filename": "neato.markdown", + "alert_url": "https://alerts.home-assistant.io/#neato.markdown" + }, + { + "title": "Nest Desktop Auth Deprecation", + "created": "2022-05-12T14:04:00.000Z", + "integrations": [ + { + "package": "nest" + } + ], + "github_issue": "https://github.com/home-assistant/core/issues/67662#issuecomment-1144425848", + "filename": "nest.markdown", + "alert_url": "https://alerts.home-assistant.io/#nest.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "Haiku Firmware Update Protocol Change", + "created": "2022-04-05T00:00:00.000Z", + "integrations": [ + { + "package": "senseme" + } + ], + "filename": "senseme.markdown", + "alert_url": "https://alerts.home-assistant.io/#senseme.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "The SoChain integration is disabled due to a dependency conflict", + "created": "2022-02-01T00:00:00.000Z", + "integrations": [ + { + "package": "sochain" + } + ], + "filename": "sochain.markdown", + "alert_url": "https://alerts.home-assistant.io/#sochain.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "Yeelight-manufactured Xiaomi-branded devices removed Local Control", + "created": "2021-03-29T06:00:00.000Z", + "integrations": [ + { + "package": "yeelight" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.89" + }, + "filename": "yeelight.markdown", + "alert_url": "https://alerts.home-assistant.io/#yeelight.markdown" + } +] diff --git a/tests/components/homeassistant_alerts/fixtures/alerts_2.json b/tests/components/homeassistant_alerts/fixtures/alerts_2.json new file mode 100644 index 00000000000..2941d9da143 --- /dev/null +++ b/tests/components/homeassistant_alerts/fixtures/alerts_2.json @@ -0,0 +1,159 @@ +[ + { + "title": "Dark Sky API closed for new users", + "created": "2020-03-31T14:40:00.000Z", + "integrations": [ + { + "package": "darksky" + } + ], + "github_issue": "https://github.com/home-assistant/home-assistant.io/pull/12591", + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.30" + }, + "filename": "dark_sky.markdown", + "alert_url": "https://alerts.home-assistant.io/#dark_sky.markdown" + }, + { + "title": "Hikvision Security Vulnerability", + "created": "2021-09-20T22:08:00.000Z", + "integrations": [ + { + "package": "hikvision" + }, + { + "package": "hikvisioncam" + } + ], + "filename": "hikvision.markdown", + "alert_url": "https://alerts.home-assistant.io/#hikvision.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "Hive shutting down North American Servers", + "created": "2021-11-13T13:58:00.000Z", + "integrations": [ + { + "package": "hive" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "2021.11.0" + }, + "filename": "hive_us.markdown", + "alert_url": "https://alerts.home-assistant.io/#hive_us.markdown" + }, + { + "title": "HomematicIP (EQ-3) blocks public IP addresses, if access to the Cloud is too frequent.", + "created": "2020-12-20T12:00:00.000Z", + "integrations": [ + { + "package": "homematicip_cloud" + } + ], + "packages": [ + { + "package": "homematicip" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.7" + }, + "filename": "homematicip_cloud.markdown", + "alert_url": "https://alerts.home-assistant.io/#homematicip_cloud.markdown" + }, + { + "title": "Logitech no longer accepting API applications", + "created": "2022-05-16T12:00:00.000Z", + "integrations": [ + { + "package": "logi_circle" + } + ], + "github_issue": "https://github.com/home-assistant/core/issues/71945", + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.92.0" + }, + "filename": "logi_circle.markdown", + "alert_url": "https://alerts.home-assistant.io/#logi_circle.markdown" + }, + { + "title": "New Neato Botvacs Do Not Support Existing API", + "created": "2021-12-20T13:27:00.000Z", + "integrations": [ + { + "package": "neato" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.30" + }, + "filename": "neato.markdown", + "alert_url": "https://alerts.home-assistant.io/#neato.markdown" + }, + { + "title": "Nest Desktop Auth Deprecation", + "created": "2022-05-12T14:04:00.000Z", + "integrations": [ + { + "package": "nest" + } + ], + "github_issue": "https://github.com/home-assistant/core/issues/67662#issuecomment-1144425848", + "filename": "nest.markdown", + "alert_url": "https://alerts.home-assistant.io/#nest.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "Haiku Firmware Update Protocol Change", + "created": "2022-04-05T00:00:00.000Z", + "integrations": [ + { + "package": "senseme" + } + ], + "filename": "senseme.markdown", + "alert_url": "https://alerts.home-assistant.io/#senseme.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "The SoChain integration is disabled due to a dependency conflict", + "created": "2022-02-01T00:00:00.000Z", + "integrations": [ + { + "package": "sochain" + } + ], + "filename": "sochain.markdown", + "alert_url": "https://alerts.home-assistant.io/#sochain.markdown", + "homeassistant": { + "package": "homeassistant" + } + }, + { + "title": "Yeelight-manufactured Xiaomi-branded devices removed Local Control", + "created": "2021-03-29T06:00:00.000Z", + "integrations": [ + { + "package": "yeelight" + } + ], + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.89" + }, + "filename": "yeelight.markdown", + "alert_url": "https://alerts.home-assistant.io/#yeelight.markdown" + } +] diff --git a/tests/components/homeassistant_alerts/fixtures/alerts_no_integrations.json b/tests/components/homeassistant_alerts/fixtures/alerts_no_integrations.json new file mode 100644 index 00000000000..25ce79d7e7c --- /dev/null +++ b/tests/components/homeassistant_alerts/fixtures/alerts_no_integrations.json @@ -0,0 +1,27 @@ +[ + { + "title": "Dark Sky API closed for new users", + "created": "2020-03-31T14:40:00.000Z", + "integrations": [ + { + "package": "darksky" + } + ], + "github_issue": "https://github.com/home-assistant/home-assistant.io/pull/12591", + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.30" + }, + "filename": "dark_sky.markdown", + "alert_url": "https://alerts.home-assistant.io/#dark_sky.markdown" + }, + { + "title": "Hikvision Security Vulnerability", + "created": "2021-09-20T22:08:00.000Z", + "filename": "hikvision.markdown", + "alert_url": "https://alerts.home-assistant.io/#hikvision.markdown", + "homeassistant": { + "package": "homeassistant" + } + } +] diff --git a/tests/components/homeassistant_alerts/fixtures/alerts_no_package.json b/tests/components/homeassistant_alerts/fixtures/alerts_no_package.json new file mode 100644 index 00000000000..bcc0a0223ee --- /dev/null +++ b/tests/components/homeassistant_alerts/fixtures/alerts_no_package.json @@ -0,0 +1,33 @@ +[ + { + "title": "Dark Sky API closed for new users", + "created": "2020-03-31T14:40:00.000Z", + "integrations": [ + { + "package": "darksky" + } + ], + "github_issue": "https://github.com/home-assistant/home-assistant.io/pull/12591", + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.30" + }, + "filename": "dark_sky.markdown", + "alert_url": "https://alerts.home-assistant.io/#dark_sky.markdown" + }, + { + "title": "Hikvision Security Vulnerability", + "created": "2021-09-20T22:08:00.000Z", + "integrations": [ + { + "package": "hikvision" + }, + {} + ], + "filename": "hikvision.markdown", + "alert_url": "https://alerts.home-assistant.io/#hikvision.markdown", + "homeassistant": { + "package": "homeassistant" + } + } +] diff --git a/tests/components/homeassistant_alerts/fixtures/alerts_no_url.json b/tests/components/homeassistant_alerts/fixtures/alerts_no_url.json new file mode 100644 index 00000000000..89f277cf69b --- /dev/null +++ b/tests/components/homeassistant_alerts/fixtures/alerts_no_url.json @@ -0,0 +1,34 @@ +[ + { + "title": "Dark Sky API closed for new users", + "created": "2020-03-31T14:40:00.000Z", + "integrations": [ + { + "package": "darksky" + } + ], + "github_issue": "https://github.com/home-assistant/home-assistant.io/pull/12591", + "homeassistant": { + "package": "homeassistant", + "affected_from_version": "0.30" + }, + "filename": "dark_sky.markdown", + "alert_url": "https://alerts.home-assistant.io/#dark_sky.markdown" + }, + { + "title": "Hikvision Security Vulnerability", + "created": "2021-09-20T22:08:00.000Z", + "integrations": [ + { + "package": "hikvision" + }, + { + "package": "hikvisioncam" + } + ], + "filename": "hikvision.markdown", + "homeassistant": { + "package": "homeassistant" + } + } +] diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py new file mode 100644 index 00000000000..c0b6f471033 --- /dev/null +++ b/tests/components/homeassistant_alerts/test_init.py @@ -0,0 +1,433 @@ +"""Test creating repairs from alerts.""" +from __future__ import annotations + +from datetime import timedelta +import json +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.components.homeassistant_alerts import DOMAIN, UPDATE_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import assert_lists_same, async_fire_time_changed, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +def stub_alert(aioclient_mock, filename): + """Stub an alert.""" + aioclient_mock.get( + f"https://alerts.home-assistant.io/alerts/{filename}", + text=f"""--- +title: Title for {filename} +--- +Content for {filename} +""", + ) + + +@pytest.mark.parametrize( + "ha_version, expected_alerts", + ( + ( + "2022.7.0", + [ + ("aladdin_connect.markdown", "aladdin_connect"), + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ("hikvision.markdown", "hikvisioncam"), + ("hive_us.markdown", "hive"), + ("homematicip_cloud.markdown", "homematicip_cloud"), + ("logi_circle.markdown", "logi_circle"), + ("neato.markdown", "neato"), + ("nest.markdown", "nest"), + ("senseme.markdown", "senseme"), + ("sochain.markdown", "sochain"), + ], + ), + ( + "2022.8.0", + [ + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ("hikvision.markdown", "hikvisioncam"), + ("hive_us.markdown", "hive"), + ("homematicip_cloud.markdown", "homematicip_cloud"), + ("logi_circle.markdown", "logi_circle"), + ("neato.markdown", "neato"), + ("nest.markdown", "nest"), + ("senseme.markdown", "senseme"), + ("sochain.markdown", "sochain"), + ], + ), + ( + "2021.10.0", + [ + ("aladdin_connect.markdown", "aladdin_connect"), + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ("hikvision.markdown", "hikvisioncam"), + ("homematicip_cloud.markdown", "homematicip_cloud"), + ("logi_circle.markdown", "logi_circle"), + ("neato.markdown", "neato"), + ("nest.markdown", "nest"), + ("senseme.markdown", "senseme"), + ("sochain.markdown", "sochain"), + ], + ), + ), +) +async def test_alerts( + hass: HomeAssistant, + hass_ws_client, + aioclient_mock: AiohttpClientMocker, + ha_version, + expected_alerts, +) -> None: + """Test creating issues based on alerts.""" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://alerts.home-assistant.io/alerts.json", + text=load_fixture("alerts_1.json", "homeassistant_alerts"), + ) + for alert in expected_alerts: + stub_alert(aioclient_mock, alert[0]) + + activated_components = ( + "aladdin_connect", + "darksky", + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ) + for domain in activated_components: + hass.config.components.add(domain) + + with patch( + "homeassistant.components.homeassistant_alerts.__version__", + ha_version, + ): + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "issues": [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}_{integration}", + "learn_more_url": f"https://alerts.home-assistant.io/#{alert}", + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in expected_alerts + ] + } + + +@pytest.mark.parametrize( + "ha_version, fixture, expected_alerts", + ( + ( + "2022.7.0", + "alerts_no_url.json", + [ + ("dark_sky.markdown", "darksky"), + ], + ), + ( + "2022.7.0", + "alerts_no_integrations.json", + [ + ("dark_sky.markdown", "darksky"), + ], + ), + ( + "2022.7.0", + "alerts_no_package.json", + [ + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ], + ), + ), +) +async def test_bad_alerts( + hass: HomeAssistant, + hass_ws_client, + aioclient_mock: AiohttpClientMocker, + ha_version, + fixture, + expected_alerts, +) -> None: + """Test creating issues based on alerts.""" + fixture_content = load_fixture(fixture, "homeassistant_alerts") + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://alerts.home-assistant.io/alerts.json", + text=fixture_content, + ) + for alert in json.loads(fixture_content): + stub_alert(aioclient_mock, alert["filename"]) + + activated_components = ( + "darksky", + "hikvision", + "hikvisioncam", + ) + for domain in activated_components: + hass.config.components.add(domain) + + with patch( + "homeassistant.components.homeassistant_alerts.__version__", + ha_version, + ): + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "issues": [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}_{integration}", + "learn_more_url": f"https://alerts.home-assistant.io/#{alert}", + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in expected_alerts + ] + } + + +async def test_no_alerts( + hass: HomeAssistant, + hass_ws_client, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test creating issues based on alerts.""" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://alerts.home-assistant.io/alerts.json", + text="", + ) + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +@pytest.mark.parametrize( + "ha_version, fixture_1, expected_alerts_1, fixture_2, expected_alerts_2", + ( + ( + "2022.7.0", + "alerts_1.json", + [ + ("aladdin_connect.markdown", "aladdin_connect"), + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ("hikvision.markdown", "hikvisioncam"), + ("hive_us.markdown", "hive"), + ("homematicip_cloud.markdown", "homematicip_cloud"), + ("logi_circle.markdown", "logi_circle"), + ("neato.markdown", "neato"), + ("nest.markdown", "nest"), + ("senseme.markdown", "senseme"), + ("sochain.markdown", "sochain"), + ], + "alerts_2.json", + [ + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ("hikvision.markdown", "hikvisioncam"), + ("hive_us.markdown", "hive"), + ("homematicip_cloud.markdown", "homematicip_cloud"), + ("logi_circle.markdown", "logi_circle"), + ("neato.markdown", "neato"), + ("nest.markdown", "nest"), + ("senseme.markdown", "senseme"), + ("sochain.markdown", "sochain"), + ], + ), + ( + "2022.7.0", + "alerts_2.json", + [ + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ("hikvision.markdown", "hikvisioncam"), + ("hive_us.markdown", "hive"), + ("homematicip_cloud.markdown", "homematicip_cloud"), + ("logi_circle.markdown", "logi_circle"), + ("neato.markdown", "neato"), + ("nest.markdown", "nest"), + ("senseme.markdown", "senseme"), + ("sochain.markdown", "sochain"), + ], + "alerts_1.json", + [ + ("aladdin_connect.markdown", "aladdin_connect"), + ("dark_sky.markdown", "darksky"), + ("hikvision.markdown", "hikvision"), + ("hikvision.markdown", "hikvisioncam"), + ("hive_us.markdown", "hive"), + ("homematicip_cloud.markdown", "homematicip_cloud"), + ("logi_circle.markdown", "logi_circle"), + ("neato.markdown", "neato"), + ("nest.markdown", "nest"), + ("senseme.markdown", "senseme"), + ("sochain.markdown", "sochain"), + ], + ), + ), +) +async def test_alerts_change( + hass: HomeAssistant, + hass_ws_client, + aioclient_mock: AiohttpClientMocker, + ha_version: str, + fixture_1: str, + expected_alerts_1: list[tuple(str, str)], + fixture_2: str, + expected_alerts_2: list[tuple(str, str)], +) -> None: + """Test creating issues based on alerts.""" + fixture_1_content = load_fixture(fixture_1, "homeassistant_alerts") + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://alerts.home-assistant.io/alerts.json", + text=fixture_1_content, + ) + for alert in json.loads(fixture_1_content): + stub_alert(aioclient_mock, alert["filename"]) + + activated_components = ( + "aladdin_connect", + "darksky", + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ) + for domain in activated_components: + hass.config.components.add(domain) + + with patch( + "homeassistant.components.homeassistant_alerts.__version__", + ha_version, + ): + assert await async_setup_component(hass, DOMAIN, {}) + + now = dt_util.utcnow() + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert_lists_same( + msg["result"]["issues"], + [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}_{integration}", + "learn_more_url": f"https://alerts.home-assistant.io/#{alert}", + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in expected_alerts_1 + ], + ) + + fixture_2_content = load_fixture(fixture_2, "homeassistant_alerts") + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://alerts.home-assistant.io/alerts.json", + text=fixture_2_content, + ) + for alert in json.loads(fixture_2_content): + stub_alert(aioclient_mock, alert["filename"]) + + future = now + UPDATE_INTERVAL + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert_lists_same( + msg["result"]["issues"], + [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}_{integration}", + "learn_more_url": f"https://alerts.home-assistant.io/#{alert}", + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in expected_alerts_2 + ], + )