diff --git a/homeassistant/components/aurorawatch/__init__.py b/homeassistant/components/aurorawatch/__init__.py new file mode 100644 index 00000000000000..e0e16f93c3bc35 --- /dev/null +++ b/homeassistant/components/aurorawatch/__init__.py @@ -0,0 +1,38 @@ +"""The AuroraWatch UK integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AurowatchDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AuroraWatch UK from a config entry.""" + coordinator = AurowatchDataUpdateCoordinator(hass) + + # Perform the first refresh and raise ConfigEntryNotReady on failure + await coordinator.async_config_entry_first_refresh() + + # Store coordinator on the config entry runtime data + entry.runtime_data = coordinator + + # Forward entry setup to sensor platform + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + return unload_ok diff --git a/homeassistant/components/aurorawatch/config_flow.py b/homeassistant/components/aurorawatch/config_flow.py new file mode 100644 index 00000000000000..4822a2305770b1 --- /dev/null +++ b/homeassistant/components/aurorawatch/config_flow.py @@ -0,0 +1,34 @@ +"""Config flow for AuroraWatch UK integration.""" + +import logging + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AurowatchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for AuroraWatch UK.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle the initial step.""" + # Check if already configured + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + + if user_input is None: + # Show the form to confirm setup + return self.async_show_form(step_id="user") + + # Create the config entry + return self.async_create_entry( + title="AuroraWatch UK", + data={}, + ) diff --git a/homeassistant/components/aurorawatch/const.py b/homeassistant/components/aurorawatch/const.py new file mode 100644 index 00000000000000..c4167d05042240 --- /dev/null +++ b/homeassistant/components/aurorawatch/const.py @@ -0,0 +1,23 @@ +"""Constants for the AuroraWatch UK integration.""" + +from datetime import timedelta + +DOMAIN = "aurorawatch" +ATTRIBUTION = "Data provided by AuroraWatch UK" + +# API Configuration +API_URL = "https://aurorawatch-api.lancs.ac.uk/0.2/status/current-status.xml" +API_ACTIVITY_URL = ( + "https://aurorawatch-api.lancs.ac.uk/0.2/status/project/awn/sum-activity.xml" +) +API_TIMEOUT = 10 # seconds + +# Update Configuration +UPDATE_INTERVAL = timedelta(minutes=5) + +# Sensor Attributes +ATTR_LAST_UPDATED = "last_updated" +ATTR_PROJECT_ID = "project_id" +ATTR_SITE_ID = "site_id" +ATTR_SITE_URL = "site_url" +ATTR_API_VERSION = "api_version" diff --git a/homeassistant/components/aurorawatch/coordinator.py b/homeassistant/components/aurorawatch/coordinator.py new file mode 100644 index 00000000000000..b684905264e6ae --- /dev/null +++ b/homeassistant/components/aurorawatch/coordinator.py @@ -0,0 +1,117 @@ +"""Data coordinator for AuroraWatch UK integration.""" + +import logging +from defusedxml import ElementTree as ET +from datetime import timedelta + +import asyncio +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_ACTIVITY_URL, API_TIMEOUT, API_URL, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AurowatchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching AuroraWatch data from API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry | None = None, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="AuroraWatch", + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + + async def _async_update_data(self): + """Fetch data from AuroraWatch API.""" + try: + session = async_get_clientsession(self.hass) + + # Fetch both status and activity data + async with asyncio.timeout(API_TIMEOUT): + async with session.get(API_URL) as status_response: + status_response.raise_for_status() + status_xml = await status_response.text() + + async with session.get(API_ACTIVITY_URL) as activity_response: + activity_response.raise_for_status() + activity_xml = await activity_response.text() + + # Parse status XML + try: + status_root = ET.fromstring(status_xml) + except ET.ParseError as err: + _LOGGER.error("Failed to parse status XML response: %s", err) + raise UpdateFailed(f"Invalid XML response: {err}") from err + + # Parse activity XML + try: + activity_root = ET.fromstring(activity_xml) + except ET.ParseError as err: + _LOGGER.error("Failed to parse activity XML response: %s", err) + raise UpdateFailed(f"Invalid activity XML response: {err}") from err + + # Extract data + try: + # Get updated datetime from status + datetime_element = status_root.find(".//updated/datetime") + if datetime_element is None or datetime_element.text is None: + raise UpdateFailed("Missing 'updated/datetime' element in XML") + last_updated = datetime_element.text + + # Get site status information + status_element = status_root.find(".//site_status") + if status_element is None: + raise UpdateFailed("Missing 'site_status' element in XML") + + status_id = status_element.get("status_id") + if status_id is None: + raise UpdateFailed("Missing 'status_id' attribute in site_status") + + project_id = status_element.get("project_id", "Unknown") + site_id = status_element.get("site_id", "Unknown") + site_url = status_element.get("site_url", "") + + # Get API version + api_version = status_root.get("api_version", "Unknown") + + # Get current activity value (most recent in the list) + activity_elements = activity_root.findall(".//activity") + activity_value = None + if activity_elements: + # Get the last (most recent) activity reading + last_activity = activity_elements[-1] + value_element = last_activity.find("value") + if value_element is not None and value_element.text is not None: + activity_value = float(value_element.text) + + data = { + "status": status_id, + "last_updated": last_updated, + "project_id": project_id, + "site_id": site_id, + "site_url": site_url, + "api_version": api_version, + "activity": activity_value, + } + + _LOGGER.debug("Successfully fetched data: %s", data) + return data + + except (AttributeError, KeyError) as err: + _LOGGER.error("Failed to extract data from XML: %s", err) + raise UpdateFailed(f"Failed to parse XML structure: {err}") from err + + except Exception as err: + _LOGGER.error("Error fetching data: %s", err) + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/aurorawatch/entity.py b/homeassistant/components/aurorawatch/entity.py new file mode 100644 index 00000000000000..7352c304050fc4 --- /dev/null +++ b/homeassistant/components/aurorawatch/entity.py @@ -0,0 +1,45 @@ +"""The AuroraWatch component.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import AurowatchDataUpdateCoordinator + + +class AurowatchEntity(CoordinatorEntity[AurowatchDataUpdateCoordinator]): + """Implementation of the base AuroraWatch Entity.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AurowatchDataUpdateCoordinator, + translation_key: str, + ) -> None: + """Initialize the AuroraWatch Entity.""" + + super().__init__(coordinator=coordinator) + + self._attr_translation_key = translation_key + + # Try to obtain the config entry ID from the coordinator if available. + config_entry = getattr(coordinator, "config_entry", None) + entry_id = getattr(config_entry, "entry_id", None) + + if entry_id is not None: + base_id = entry_id + else: + # Fallback base ID when the coordinator has no config_entry. + # This avoids AttributeError while still providing a stable ID. + base_id = translation_key + + self._attr_unique_id = f"{base_id}_{translation_key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, base_id)}, + manufacturer="Lancaster University", + model="AuroraWatch UK", + name="AuroraWatch UK", + ) diff --git a/homeassistant/components/aurorawatch/icon.png b/homeassistant/components/aurorawatch/icon.png new file mode 100644 index 00000000000000..0e9c48693d5bd0 Binary files /dev/null and b/homeassistant/components/aurorawatch/icon.png differ diff --git a/homeassistant/components/aurorawatch/icons.json b/homeassistant/components/aurorawatch/icons.json new file mode 100644 index 00000000000000..044af871253a8c --- /dev/null +++ b/homeassistant/components/aurorawatch/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "aurora_status": { + "default": "mdi:weather-night" + }, + "geomagnetic_activity": { + "default": "mdi:chart-line" + } + } + } +} diff --git a/homeassistant/components/aurorawatch/logo.png b/homeassistant/components/aurorawatch/logo.png new file mode 100644 index 00000000000000..0e9c48693d5bd0 Binary files /dev/null and b/homeassistant/components/aurorawatch/logo.png differ diff --git a/homeassistant/components/aurorawatch/manifest.json b/homeassistant/components/aurorawatch/manifest.json new file mode 100644 index 00000000000000..d5cbeabe4368ca --- /dev/null +++ b/homeassistant/components/aurorawatch/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aurorawatch", + "name": "AuroraWatch UK", + "codeowners": ["@agentgonzo"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aurorawatch", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": [] +} diff --git a/homeassistant/components/aurorawatch/sensor.py b/homeassistant/components/aurorawatch/sensor.py new file mode 100644 index 00000000000000..4c971eca44c9e1 --- /dev/null +++ b/homeassistant/components/aurorawatch/sensor.py @@ -0,0 +1,85 @@ +"""Sensor platform for AuroraWatch UK integration.""" + +import logging +from typing import cast + +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_API_VERSION, + ATTR_LAST_UPDATED, + ATTR_PROJECT_ID, + ATTR_SITE_ID, + ATTR_SITE_URL, + DOMAIN, +) +from .coordinator import AurowatchDataUpdateCoordinator +from .entity import AurowatchEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the AuroraWatch sensor.""" + coordinator = cast( + AurowatchDataUpdateCoordinator, + hass.data[DOMAIN][entry.entry_id], + ) + async_add_entities( + [ + AurowatchSensor(coordinator), + AurowatchActivitySensor(coordinator), + ] + ) + + +class AurowatchSensor(AurowatchEntity, SensorEntity): + """Representation of an AuroraWatch status sensor.""" + + def __init__(self, coordinator: AurowatchDataUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "aurora_status") + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + if self.coordinator.data: + return self.coordinator.data.get("status") + return None + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + data = self.coordinator.data or {} + return { + ATTR_LAST_UPDATED: data.get("last_updated"), + ATTR_PROJECT_ID: data.get("project_id"), + ATTR_SITE_ID: data.get("site_id"), + ATTR_SITE_URL: data.get("site_url"), + ATTR_API_VERSION: data.get("api_version"), + } + + +class AurowatchActivitySensor(AurowatchEntity, SensorEntity): + """Representation of an AuroraWatch geomagnetic activity sensor.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = "nT" + + def __init__(self, coordinator: AurowatchDataUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "geomagnetic_activity") + + @property + def native_value(self) -> int | float | None: + """Return the geomagnetic activity value.""" + if self.coordinator.data: + return self.coordinator.data.get("activity") + return None diff --git a/homeassistant/components/aurorawatch/strings.json b/homeassistant/components/aurorawatch/strings.json new file mode 100644 index 00000000000000..7b6890daa3485a --- /dev/null +++ b/homeassistant/components/aurorawatch/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "AuroraWatch UK", + "description": "Monitor aurora activity from AuroraWatch UK. This integration will add a sensor showing the current aurora alert status (green, yellow, amber, or red)." + } + }, + "abort": { + "already_configured": "AuroraWatch UK is already configured" + } + }, + "entity": { + "sensor": { + "aurora_status": { + "name": "Aurora status" + }, + "geomagnetic_activity": { + "name": "Geomagnetic activity" + } + } + } +} diff --git a/tests/components/aurorawatch/__init__.py b/tests/components/aurorawatch/__init__.py new file mode 100644 index 00000000000000..728cee9699ccb4 --- /dev/null +++ b/tests/components/aurorawatch/__init__.py @@ -0,0 +1,13 @@ +"""The tests for the AuroraWatch UK integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/aurorawatch/conftest.py b/tests/components/aurorawatch/conftest.py new file mode 100644 index 00000000000000..50f45439cc426f --- /dev/null +++ b/tests/components/aurorawatch/conftest.py @@ -0,0 +1,105 @@ +"""Common fixtures for the AuroraWatch UK tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.aurorawatch.const import DOMAIN + +from tests.common import MockConfigEntry + +# Sample XML responses for testing +MOCK_STATUS_XML = """ + + + 2024-01-15T12:00:00Z + + + No significant activity + + +""" + +MOCK_ACTIVITY_XML = """ + + + 2024-01-15T11:00:00Z + 45.2 + + + 2024-01-15T12:00:00Z + 52.7 + + +""" + +MOCK_INVALID_STATUS_XML = """ + + + +""" + +MOCK_MALFORMED_XML = """ + +""" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aurorawatch.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aiohttp_session() -> Generator[AsyncMock]: + """Mock aiohttp session.""" + with patch( + "homeassistant.components.aurorawatch.coordinator.async_get_clientsession" + ) as mock_session_factory: + mock_session = AsyncMock() + mock_session_factory.return_value = mock_session + + # Create mock responses + mock_status_response = AsyncMock() + mock_status_response.text = AsyncMock(return_value=MOCK_STATUS_XML) + mock_status_response.raise_for_status = Mock() + + mock_activity_response = AsyncMock() + mock_activity_response.text = AsyncMock(return_value=MOCK_ACTIVITY_XML) + mock_activity_response.raise_for_status = Mock() + + # Mock session.get to return different responses based on URL + async def mock_get(url: str, *args, **kwargs): + """Return an async context manager yielding the appropriate response.""" + if "current-status.xml" in url: + response = mock_status_response + elif "sum-activity.xml" in url: + response = mock_activity_response + else: + response = AsyncMock() + + context_manager = AsyncMock() + context_manager.__aenter__.return_value = response + context_manager.__aexit__.return_value = AsyncMock(return_value=None) + + return context_manager + + mock_session.get = mock_get + + yield mock_session + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="AuroraWatch UK", + data={}, + unique_id=DOMAIN, + ) diff --git a/tests/components/aurorawatch/test_config_flow.py b/tests/components/aurorawatch/test_config_flow.py new file mode 100644 index 00000000000000..a9c4a3129d5ba7 --- /dev/null +++ b/tests/components/aurorawatch/test_config_flow.py @@ -0,0 +1,58 @@ +"""Test the AuroraWatch UK config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.aurorawatch.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aiohttp_session: AsyncMock, +) -> None: + """Test full config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AuroraWatch UK" + assert result["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_already_configured( + hass: HomeAssistant, + mock_aiohttp_session: AsyncMock, +) -> None: + """Test config flow aborts if already configured.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + title="AuroraWatch UK", + data={}, + unique_id=DOMAIN, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/aurorawatch/test_sensor.py b/tests/components/aurorawatch/test_sensor.py new file mode 100644 index 00000000000000..f3b791d702c73a --- /dev/null +++ b/tests/components/aurorawatch/test_sensor.py @@ -0,0 +1,214 @@ +"""Test the AuroraWatch UK sensor platform.""" + +from unittest.mock import AsyncMock + +from aiohttp import ClientError +import pytest + +from homeassistant.components.aurorawatch.const import ( + ATTR_API_VERSION, + ATTR_LAST_UPDATED, + ATTR_PROJECT_ID, + ATTR_SITE_ID, + ATTR_SITE_URL, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_sensor_setup( + hass: HomeAssistant, + mock_aiohttp_session: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor setup.""" + await setup_integration(hass, mock_config_entry) + + entity_registry = er.async_get(hass) + + # Check that both sensors are created + status_sensor = entity_registry.async_get("sensor.aurorawatch_uk_aurora_status") + assert status_sensor + assert status_sensor.unique_id == "aurorawatch_aurora_status" + assert status_sensor.translation_key == "aurora_status" + + activity_sensor = entity_registry.async_get( + "sensor.aurorawatch_uk_geomagnetic_activity" + ) + assert activity_sensor + assert activity_sensor.unique_id == "aurorawatch_geomagnetic_activity" + assert activity_sensor.translation_key == "geomagnetic_activity" + + +async def test_sensor_states( + hass: HomeAssistant, + mock_aiohttp_session: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor states.""" + await setup_integration(hass, mock_config_entry) + + # Check status sensor state + status_state = hass.states.get("sensor.aurorawatch_uk_aurora_status") + assert status_state + assert status_state.state == "green" + + # Check activity sensor state + activity_state = hass.states.get("sensor.aurorawatch_uk_geomagnetic_activity") + assert activity_state + assert activity_state.state == "52.7" + assert activity_state.attributes.get("unit_of_measurement") == "nT" + assert activity_state.attributes.get("state_class") == "measurement" + + +async def test_sensor_attributes( + hass: HomeAssistant, + mock_aiohttp_session: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor attributes.""" + await setup_integration(hass, mock_config_entry) + + # Check status sensor attributes + status_state = hass.states.get("sensor.aurorawatch_uk_aurora_status") + assert status_state + assert status_state.attributes.get(ATTR_LAST_UPDATED) == "2024-01-15T12:00:00Z" + assert status_state.attributes.get(ATTR_PROJECT_ID) == "awn" + assert status_state.attributes.get(ATTR_SITE_ID) == "lancaster" + assert ( + status_state.attributes.get(ATTR_SITE_URL) == "http://aurorawatch.lancs.ac.uk" + ) + assert status_state.attributes.get(ATTR_API_VERSION) == "0.2" + assert ( + status_state.attributes.get("attribution") == "Data provided by AuroraWatch UK" + ) + + +async def test_sensor_update_failure_network_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor update with network error.""" + from unittest.mock import patch + + with patch( + "homeassistant.components.aurorawatch.coordinator.async_get_clientsession" + ) as mock_session_factory: + mock_session = AsyncMock() + mock_session_factory.return_value = mock_session + + # Mock network error + mock_session.get.side_effect = ClientError("Connection failed") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Initial setup should fail and transition the entry to SETUP_RETRY, + # and entities should not be created yet. + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry is not None + assert entry.state is ConfigEntryState.SETUP_RETRY + + status_state = hass.states.get("sensor.aurorawatch_uk_aurora_status") + assert status_state is None + + activity_state = hass.states.get("sensor.aurorawatch_uk_geomagnetic_activity") + assert activity_state is None + + +async def test_sensor_update_failure_invalid_xml( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor update with invalid XML.""" + from unittest.mock import Mock, patch + + from tests.components.aurorawatch.conftest import MOCK_MALFORMED_XML + + with patch( + "homeassistant.components.aurorawatch.coordinator.async_get_clientsession" + ) as mock_session_factory: + mock_session = AsyncMock() + mock_session_factory.return_value = mock_session + + # Mock malformed XML response + mock_response = AsyncMock() + mock_response.text = AsyncMock(return_value=MOCK_MALFORMED_XML) + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Initial setup should fail due to parsing error; entry should be in + # SETUP_RETRY and entities should not be created yet. + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry is not None + assert entry.state is ConfigEntryState.SETUP_RETRY + + status_state = hass.states.get("sensor.aurorawatch_uk_aurora_status") + assert status_state is None + + +async def test_sensor_update_failure_missing_fields( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor update with missing required fields.""" + from unittest.mock import Mock, patch + + from tests.components.aurorawatch.conftest import MOCK_INVALID_STATUS_XML + + with patch( + "homeassistant.components.aurorawatch.coordinator.async_get_clientsession" + ) as mock_session_factory: + mock_session = AsyncMock() + mock_session_factory.return_value = mock_session + + # Mock invalid XML response (missing required fields) + mock_response = AsyncMock() + mock_response.text = AsyncMock(return_value=MOCK_INVALID_STATUS_XML) + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Sensors should be unavailable due to missing fields + status_state = hass.states.get("sensor.aurorawatch_uk_aurora_status") + assert status_state + assert status_state.state == STATE_UNAVAILABLE + + +async def test_sensor_device_info( + hass: HomeAssistant, + mock_aiohttp_session: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor device info.""" + await setup_integration(hass, mock_config_entry) + + entity_registry = er.async_get(hass) + status_sensor = entity_registry.async_get("sensor.aurorawatch_uk_aurora_status") + assert status_sensor + assert status_sensor.device_id + + from homeassistant.helpers import device_registry as dr + + device_registry = dr.async_get(hass) + device = device_registry.async_get(status_sensor.device_id) + assert device + assert device.manufacturer == "Lancaster University" + assert device.model == "AuroraWatch UK" + assert device.name == "AuroraWatch UK"