From f0ce605c480ebcfa23d278e36c0aa4e9431147af Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 30 Sep 2026 12:32:13 +0200 Subject: [PATCH] Add repair issue for unknown station in WAQI (#182612) --- homeassistant/components/waqi/coordinator.py | 26 +++++- homeassistant/components/waqi/repairs.py | 56 ++++++++++++ homeassistant/components/waqi/strings.json | 13 +++ tests/components/waqi/test_repairs.py | 89 ++++++++++++++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/waqi/repairs.py create mode 100644 tests/components/waqi/test_repairs.py diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index 53624e198d88..12eba1208094 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -3,13 +3,14 @@ from datetime import timedelta from typing import override -from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError +from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError, WAQIUnknownStationError from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_STATION_NUMBER, LOGGER +from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER type WAQIConfigEntry = ConfigEntry[dict[str, WAQIDataUpdateCoordinator]] @@ -39,9 +40,28 @@ class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): @override async def _async_update_data(self) -> WAQIAirQuality: + issue_id = f"station_not_found_{self.subentry.subentry_id}" try: - return await self._client.get_by_station_number( + air_quality = await self._client.get_by_station_number( self.subentry.data[CONF_STATION_NUMBER] ) + except WAQIUnknownStationError as exc: + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="station_not_found", + translation_placeholders={"name": self.subentry.title}, + data={ + "entry_id": self.config_entry.entry_id, + "subentry_id": self.subentry.subentry_id, + "name": self.subentry.title, + }, + ) + raise UpdateFailed(str(exc)) from exc except WAQIError as exc: raise UpdateFailed(str(exc)) from exc + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + return air_quality diff --git a/homeassistant/components/waqi/repairs.py b/homeassistant/components/waqi/repairs.py new file mode 100644 index 000000000000..56a673046ca4 --- /dev/null +++ b/homeassistant/components/waqi/repairs.py @@ -0,0 +1,56 @@ +"""Repairs for the World Air Quality Index (WAQI) integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult +from homeassistant.core import HomeAssistant + + +class StationNotFoundRepairFlow(RepairsFlow): + """Handler to remove a measuring station that can no longer be found.""" + + def __init__(self, entry_id: str, subentry_id: str, name: str) -> None: + """Initialize the flow.""" + self._entry_id = entry_id + self._subentry_id = subentry_id + self._name = name + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + entry = self.hass.config_entries.async_get_entry(self._entry_id) + if TYPE_CHECKING: + assert entry is not None + self.hass.config_entries.async_remove_subentry(entry, self._subentry_id) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=probatio.Schema({}), + description_placeholders={"name": self._name}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str] | None, +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert data is not None + return StationNotFoundRepairFlow( + entry_id=data["entry_id"], + subentry_id=data["subentry_id"], + name=data["name"], + ) diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index bb55855a1d9e..ca454a1e40cb 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -92,5 +92,18 @@ "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" } } + }, + "issues": { + "station_not_found": { + "fix_flow": { + "step": { + "confirm": { + "description": "The measuring station \"{name}\" can no longer be found by the World Air Quality Index API. This usually means the station has been permanently taken offline. A different station may report different metrics, so it is not automatically substituted.\n\nSelect **Submit** to remove this measuring station. You can add a replacement station afterwards.", + "title": "[%key:component::waqi::issues::station_not_found::title%]" + } + } + }, + "title": "Measuring station \"{name}\" not found" + } } } diff --git a/tests/components/waqi/test_repairs.py b/tests/components/waqi/test_repairs.py new file mode 100644 index 000000000000..6caf750e4cd8 --- /dev/null +++ b/tests/components/waqi/test_repairs.py @@ -0,0 +1,89 @@ +"""Test the World Air Quality Index (WAQI) repairs.""" + +from unittest.mock import AsyncMock + +from aiowaqi import WAQIUnknownStationError + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.components.waqi.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator + + +async def test_station_not_found_fix_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + mock_waqi: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the station not found fix flow removes the subentry.""" + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + subentry_id = list(mock_config_entry.subentries)[0] + mock_waqi.get_by_station_number.side_effect = WAQIUnknownStationError( + "Could not find station @4585" + ) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + issue_id = f"station_not_found_{subentry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "station_not_found" + assert issue.is_fixable is True + assert issue.data == { + "entry_id": mock_config_entry.entry_id, + "subentry_id": subentry_id, + "name": "de Jongweg, Utrecht", + } + + http_client = await hass_client() + + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) + flow_id = data["flow_id"] + + assert data["step_id"] == "confirm" + assert data["description_placeholders"] == {"name": "de Jongweg, Utrecht"} + + data = await process_repair_fix_flow(http_client, flow_id, json={}) + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + assert subentry_id not in mock_config_entry.subentries + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +async def test_issue_cleared_on_recovery( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_waqi: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the issue is removed once the station is reachable again.""" + subentry_id = list(mock_config_entry.subentries)[0] + mock_waqi.get_by_station_number.side_effect = WAQIUnknownStationError( + "Could not find station @4585" + ) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + issue_id = f"station_not_found_{subentry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + mock_waqi.get_by_station_number.side_effect = None + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None