From 0136e9c7eb825a6dbfaa0103a814ca2be9867fc1 Mon Sep 17 00:00:00 2001 From: Italo Lombardi <156904468+italo-lombardi@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:46:48 +0000 Subject: [PATCH 01/19] ISS integration: better entity handling (#159050) Co-authored-by: Ariel Ebersberger --- homeassistant/components/iss/__init__.py | 61 +------ homeassistant/components/iss/config_flow.py | 10 +- homeassistant/components/iss/const.py | 2 + homeassistant/components/iss/coordinator.py | 76 ++++++++ homeassistant/components/iss/sensor.py | 18 +- tests/components/iss/conftest.py | 47 +++++ tests/components/iss/test_init.py | 188 ++++++++++++++++++++ tests/components/iss/test_sensor.py | 99 +++++++++++ 8 files changed, 429 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/iss/coordinator.py create mode 100644 tests/components/iss/conftest.py create mode 100644 tests/components/iss/test_init.py create mode 100644 tests/components/iss/test_sensor.py diff --git a/homeassistant/components/iss/__init__.py b/homeassistant/components/iss/__init__.py index dbbcc8b6c518..d8ffa9c215d9 100644 --- a/homeassistant/components/iss/__init__.py +++ b/homeassistant/components/iss/__init__.py @@ -2,66 +2,21 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta -import logging - -import pyiss -import requests -from requests.exceptions import HTTPError - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import IssConfigEntry, IssDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -@dataclass -class IssData: - """Dataclass representation of data returned from pyiss.""" - - number_of_people_in_space: int - current_location: dict[str, str] - - -def update(iss: pyiss.ISS) -> IssData: - """Retrieve data from the pyiss API.""" - return IssData( - number_of_people_in_space=iss.number_of_people_in_space(), - current_location=iss.current_location(), - ) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IssConfigEntry) -> bool: """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) - - iss = pyiss.ISS() - - async def async_update() -> IssData: - try: - return await hass.async_add_executor_job(update, iss) - except (HTTPError, requests.exceptions.ConnectionError) as ex: - raise UpdateFailed("Unable to retrieve data") from ex - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update, - update_interval=timedelta(seconds=60), - ) + coordinator = IssDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -70,13 +25,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IssConfigEntry) -> bool: """Handle removal of an entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: IssConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index eaf01a6d0946..5aa49c3d45a8 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -4,16 +4,12 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback from .const import DEFAULT_NAME, DOMAIN +from .coordinator import IssConfigEntry class ISSConfigFlow(ConfigFlow, domain=DOMAIN): @@ -24,7 +20,7 @@ class ISSConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: IssConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/iss/const.py b/homeassistant/components/iss/const.py index c3bdcf6fa327..264e24352b0a 100644 --- a/homeassistant/components/iss/const.py +++ b/homeassistant/components/iss/const.py @@ -3,3 +3,5 @@ DOMAIN = "iss" DEFAULT_NAME = "ISS" + +MAX_CONSECUTIVE_FAILURES = 5 diff --git a/homeassistant/components/iss/coordinator.py b/homeassistant/components/iss/coordinator.py new file mode 100644 index 000000000000..88a9c8ebbdbc --- /dev/null +++ b/homeassistant/components/iss/coordinator.py @@ -0,0 +1,76 @@ +"""DataUpdateCoordinator for the ISS integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +import pyiss +import requests +from requests.exceptions import HTTPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, MAX_CONSECUTIVE_FAILURES + +type IssConfigEntry = ConfigEntry[IssDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class IssData: + """Dataclass representation of data returned from pyiss.""" + + number_of_people_in_space: int + current_location: dict[str, str] + + +class IssDataUpdateCoordinator(DataUpdateCoordinator[IssData]): + """ISS coordinator that tolerates transient API failures.""" + + config_entry: IssConfigEntry + + def __init__(self, hass: HomeAssistant, entry: IssConfigEntry) -> None: + """Initialize the ISS coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + self._consecutive_failures = 0 + self.iss = pyiss.ISS() + + def _fetch_iss_data(self) -> IssData: + """Fetch data from ISS API (blocking).""" + return IssData( + number_of_people_in_space=self.iss.number_of_people_in_space(), + current_location=self.iss.current_location(), + ) + + async def _async_update_data(self) -> IssData: + """Fetch data from the ISS API, tolerating transient failures.""" + try: + data = await self.hass.async_add_executor_job(self._fetch_iss_data) + except (HTTPError, requests.exceptions.ConnectionError) as err: + self._consecutive_failures += 1 + if self.data is None: + raise UpdateFailed("Unable to retrieve data") from err + if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + raise UpdateFailed( + f"Unable to retrieve data after {self._consecutive_failures} consecutive update failures" + ) from err + _LOGGER.debug( + "Transient API error (%s/%s), using cached data: %s", + self._consecutive_failures, + MAX_CONSECUTIVE_FAILURES, + err, + ) + return self.data + self._consecutive_failures = 0 + return data diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index b6e98e07f8a8..b7fa190c3bde 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -6,36 +6,32 @@ import logging from typing import Any from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import IssData from .const import DEFAULT_NAME, DOMAIN +from .coordinator import IssConfigEntry, IssDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IssConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: DataUpdateCoordinator[IssData] = hass.data[DOMAIN] + coordinator = entry.runtime_data show_on_map = entry.options.get(CONF_SHOW_ON_MAP, False) async_add_entities([IssSensor(coordinator, entry, show_on_map)]) -class IssSensor(CoordinatorEntity[DataUpdateCoordinator[IssData]], SensorEntity): +class IssSensor(CoordinatorEntity[IssDataUpdateCoordinator], SensorEntity): """Implementation of the ISS sensor.""" _attr_has_entity_name = True @@ -43,8 +39,8 @@ class IssSensor(CoordinatorEntity[DataUpdateCoordinator[IssData]], SensorEntity) def __init__( self, - coordinator: DataUpdateCoordinator[IssData], - entry: ConfigEntry, + coordinator: IssDataUpdateCoordinator, + entry: IssConfigEntry, show: bool, ) -> None: """Initialize the sensor.""" diff --git a/tests/components/iss/conftest.py b/tests/components/iss/conftest.py new file mode 100644 index 000000000000..feb80eba4a13 --- /dev/null +++ b/tests/components/iss/conftest.py @@ -0,0 +1,47 @@ +"""Configuration for ISS tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.iss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + entry_id="test_entry_id", + ) + + +@pytest.fixture +def mock_pyiss() -> Generator[MagicMock]: + """Mock the pyiss.ISS class.""" + with patch("homeassistant.components.iss.coordinator.pyiss.ISS") as mock_iss_class: + mock_iss = MagicMock() + mock_iss.number_of_people_in_space.return_value = 7 + mock_iss.current_location.return_value = { + "latitude": "40.271698", + "longitude": "15.619478", + } + mock_iss_class.return_value = mock_iss + yield mock_iss + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyiss: MagicMock +) -> MockConfigEntry: + """Set up the ISS integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/iss/test_init.py b/tests/components/iss/test_init.py new file mode 100644 index 000000000000..5d01db3db086 --- /dev/null +++ b/tests/components/iss/test_init.py @@ -0,0 +1,188 @@ +"""Test the ISS integration setup and coordinator.""" + +from unittest.mock import MagicMock + +from requests.exceptions import ConnectionError as RequestsConnectionError, HTTPError + +from homeassistant.components.iss.const import MAX_CONSECUTIVE_FAILURES +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test successful setup of config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + coordinator = init_integration.runtime_data + assert coordinator.data is not None + assert coordinator.data.number_of_people_in_space == 7 + assert coordinator.data.current_location == { + "latitude": "40.271698", + "longitude": "15.619478", + } + + +async def test_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test unload of config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_update_listener( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test options update triggers reload and applies new options.""" + state = hass.states.get("sensor.iss") + assert state is not None + assert "lat" in state.attributes + assert "long" in state.attributes + assert ATTR_LATITUDE not in state.attributes + assert ATTR_LONGITUDE not in state.attributes + + hass.config_entries.async_update_entry( + init_integration, options={CONF_SHOW_ON_MAP: True} + ) + await hass.async_block_till_done() + + # After reload with show_on_map=True, attributes should switch + state = hass.states.get("sensor.iss") + assert state is not None + assert ATTR_LATITUDE in state.attributes + assert ATTR_LONGITUDE in state.attributes + assert "lat" not in state.attributes + assert "long" not in state.attributes + + +async def test_coordinator_single_failure_uses_cached_data( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test coordinator tolerates single API failure and uses cached data.""" + coordinator = init_integration.runtime_data + original_data = coordinator.data + + # Simulate API failure + mock_pyiss.number_of_people_in_space.side_effect = HTTPError("API Error") + + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Should still have the cached data + assert coordinator.data == original_data + assert coordinator.last_update_success is True + + +async def test_coordinator_multiple_failures_uses_cached_data( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test coordinator tolerates multiple failures below threshold.""" + coordinator = init_integration.runtime_data + original_data = coordinator.data + + # Simulate multiple API failures (below MAX_CONSECUTIVE_FAILURES) + mock_pyiss.number_of_people_in_space.side_effect = RequestsConnectionError( + "Connection failed" + ) + + for _ in range(MAX_CONSECUTIVE_FAILURES - 1): + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Should still have cached data and be successful + assert coordinator.data == original_data + assert coordinator.last_update_success is True + + +async def test_coordinator_max_failures_marks_unavailable( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test coordinator marks update failed after MAX_CONSECUTIVE_FAILURES.""" + coordinator = init_integration.runtime_data + + # Simulate consecutive API failures reaching the threshold + mock_pyiss.number_of_people_in_space.side_effect = HTTPError("API Error") + + for _ in range(MAX_CONSECUTIVE_FAILURES): + await coordinator.async_refresh() + await hass.async_block_till_done() + + # After MAX_CONSECUTIVE_FAILURES, update should be marked as failed + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, UpdateFailed) + + +async def test_coordinator_failure_counter_resets_on_success( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test coordinator resets failure counter after successful fetch.""" + coordinator = init_integration.runtime_data + + # Simulate some failures + mock_pyiss.number_of_people_in_space.side_effect = HTTPError("API Error") + for _ in range(2): + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Now simulate success + mock_pyiss.number_of_people_in_space.side_effect = None + mock_pyiss.number_of_people_in_space.return_value = 8 + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert coordinator.last_update_success is True + assert coordinator.data.number_of_people_in_space == 8 + + # Failure counter should be reset, so we can tolerate failures again + mock_pyiss.number_of_people_in_space.side_effect = RequestsConnectionError( + "Connection failed" + ) + for _ in range(MAX_CONSECUTIVE_FAILURES - 1): + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Should still be successful due to cached data + assert coordinator.last_update_success is True + + +async def test_coordinator_initial_failure_no_cached_data( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test coordinator fails immediately on initial setup with no cached data.""" + mock_pyiss.number_of_people_in_space.side_effect = HTTPError("API Error") + mock_config_entry.add_to_hass(hass) + + # Setup should fail because there's no cached data + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_handles_connection_error( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test coordinator handles ConnectionError exceptions.""" + coordinator = init_integration.runtime_data + original_data = coordinator.data + + # Simulate ConnectionError + mock_pyiss.current_location.side_effect = RequestsConnectionError( + "Network unreachable" + ) + + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Should use cached data + assert coordinator.data == original_data + assert coordinator.last_update_success is True diff --git a/tests/components/iss/test_sensor.py b/tests/components/iss/test_sensor.py new file mode 100644 index 000000000000..26a3d4f3ee28 --- /dev/null +++ b/tests/components/iss/test_sensor.py @@ -0,0 +1,99 @@ +"""Test the ISS sensor platform.""" + +from unittest.mock import MagicMock + +from homeassistant.components.iss.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensor_created( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test sensor entity is created.""" + state = hass.states.get("sensor.iss") + assert state is not None + assert state.state == "7" + + +async def test_sensor_attributes_show_on_map_false( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test sensor attributes when show_on_map is False.""" + state = hass.states.get("sensor.iss") + assert state is not None + assert state.state == "7" + assert state.attributes["lat"] == "40.271698" + assert state.attributes["long"] == "15.619478" + # Should NOT have ATTR_LATITUDE/ATTR_LONGITUDE when show_on_map is False + assert ATTR_LATITUDE not in state.attributes + assert ATTR_LONGITUDE not in state.attributes + + +async def test_sensor_attributes_show_on_map_true( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test sensor attributes when show_on_map is True.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_SHOW_ON_MAP: True} + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.iss") + assert state is not None + assert state.state == "7" + # Should have ATTR_LATITUDE/ATTR_LONGITUDE when show_on_map is True + assert state.attributes[ATTR_LATITUDE] == "40.271698" + assert state.attributes[ATTR_LONGITUDE] == "15.619478" + # Should NOT have lat/long keys + assert "lat" not in state.attributes + assert "long" not in state.attributes + + +async def test_sensor_device_info( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test sensor has correct device info.""" + entity_registry = er.async_get(hass) + entity = entity_registry.async_get("sensor.iss") + + assert entity is not None + assert entity.unique_id == f"{init_integration.entry_id}_people" + + device_registry = dr.async_get(hass) + device = device_registry.async_get(entity.device_id) + + assert device is not None + assert device.name == DEFAULT_NAME + assert (DOMAIN, init_integration.entry_id) in device.identifiers + + +async def test_sensor_updates_with_coordinator( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_pyiss: MagicMock +) -> None: + """Test sensor updates when coordinator data changes.""" + state = hass.states.get("sensor.iss") + assert state.state == "7" + + # Update mock data + mock_pyiss.number_of_people_in_space.return_value = 10 + mock_pyiss.current_location.return_value = { + "latitude": "50.0", + "longitude": "-100.0", + } + + # Trigger coordinator refresh + coordinator = init_integration.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Check sensor updated + state = hass.states.get("sensor.iss") + assert state.state == "10" + assert state.attributes["lat"] == "50.0" + assert state.attributes["long"] == "-100.0" From 18a8afb017e81a994b90b1a93090498754afc667 Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Wed, 4 Mar 2026 10:47:17 -0800 Subject: [PATCH 02/19] Update keyboard_remote dependencies (#164755) --- homeassistant/components/keyboard_remote/manifest.json | 2 +- requirements_all.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index f543ae72972b..2159dd9d90ea 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "quality_scale": "legacy", - "requirements": ["evdev==1.6.1", "asyncinotify==4.2.0"] + "requirements": ["evdev==1.9.3", "asyncinotify==4.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 123cc2f8da6d..51308cad99be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -553,7 +553,7 @@ async-upnp-client==0.46.2 asyncarve==0.1.1 # homeassistant.components.keyboard_remote -asyncinotify==4.2.0 +asyncinotify==4.4.0 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -936,7 +936,7 @@ eternalegypt==0.0.18 eufylife-ble-client==0.1.8 # homeassistant.components.keyboard_remote -# evdev==1.6.1 +# evdev==1.9.3 # homeassistant.components.evohome evohome-async==1.1.3 From ca338c98f3bf9afdcf1e716ed67cd9283a1b5e78 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 4 Mar 2026 22:57:59 +0100 Subject: [PATCH 03/19] Clarify description of `vacuum.clean_area` action (#164764) --- homeassistant/components/vacuum/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 2ea2aae95943..07947008bafb 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -117,7 +117,7 @@ }, "services": { "clean_area": { - "description": "Tells a vacuum cleaner to clean an area.", + "description": "Tells a vacuum cleaner to clean one or more areas.", "fields": { "cleaning_area_id": { "description": "Areas to clean.", From f83757da7cecd69b5cbdeac2c1bb4d5061c10a31 Mon Sep 17 00:00:00 2001 From: rappenze Date: Wed, 4 Mar 2026 23:04:38 +0100 Subject: [PATCH 04/19] Use unique fibaro_id in test fixtures (#164763) --- tests/components/fibaro/conftest.py | 8 ++++---- tests/components/fibaro/test_climate.py | 14 +++++++------- tests/components/fibaro/test_cover.py | 10 +++++----- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 3949edb2c3a7..bbbbfba430c9 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -86,7 +86,7 @@ def mock_power_sensor() -> Mock: def mock_positionable_cover() -> Mock: """Fixture for a positionable cover.""" cover = Mock() - cover.fibaro_id = 3 + cover.fibaro_id = 2 cover.parent_fibaro_id = 0 cover.name = "Test cover" cover.room_id = 1 @@ -209,7 +209,7 @@ def mock_zigbee_light() -> Mock: def mock_thermostat() -> Mock: """Fixture for a thermostat.""" climate = Mock() - climate.fibaro_id = 4 + climate.fibaro_id = 13 climate.parent_fibaro_id = 0 climate.name = "Test climate" climate.room_id = 1 @@ -290,7 +290,7 @@ def mock_thermostat_with_operating_mode() -> Mock: def mock_thermostat_quickapp_1() -> Mock: """Fixture for a thermostat.""" climate = Mock() - climate.fibaro_id = 6 + climate.fibaro_id = 9 climate.parent_fibaro_id = 0 climate.has_endpoint_id = False climate.name = "Test climate" @@ -321,7 +321,7 @@ def mock_thermostat_quickapp_1() -> Mock: def mock_thermostat_quickapp_2() -> Mock: """Fixture for a thermostat.""" climate = Mock() - climate.fibaro_id = 7 + climate.fibaro_id = 10 climate.parent_fibaro_id = 0 climate.has_endpoint_id = False climate.name = "Test climate 2" diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py index 183a4333b607..80a6f2adf14b 100644 --- a/tests/components/fibaro/test_climate.py +++ b/tests/components/fibaro/test_climate.py @@ -30,9 +30,9 @@ async def test_climate_setup( # Act await init_integration(hass, mock_config_entry) # Assert - entry = entity_registry.async_get("climate.room_1_test_climate_4") + entry = entity_registry.async_get("climate.room_1_test_climate_13") assert entry - assert entry.unique_id == "hc2_111111.4" + assert entry.unique_id == "hc2_111111.13" assert entry.original_name == "Room 1 Test climate" assert entry.supported_features == ( ClimateEntityFeature.TURN_ON @@ -63,9 +63,9 @@ async def test_climate_setup_2_quickapps( # Act await init_integration(hass, mock_config_entry) # Assert - entry1 = entity_registry.async_get("climate.room_1_test_climate_6") + entry1 = entity_registry.async_get("climate.room_1_test_climate_9") assert entry1 - entry2 = entity_registry.async_get("climate.room_1_test_climate_2_7") + entry2 = entity_registry.async_get("climate.room_1_test_climate_2_10") assert entry2 @@ -86,7 +86,7 @@ async def test_hvac_mode_preset( # Act await init_integration(hass, mock_config_entry) # Assert - state = hass.states.get("climate.room_1_test_climate_4") + state = hass.states.get("climate.room_1_test_climate_13") assert state.state == HVACMode.AUTO assert state.attributes["preset_mode"] == "CustomerSpecific" @@ -109,7 +109,7 @@ async def test_hvac_mode_heat( # Act await init_integration(hass, mock_config_entry) # Assert - state = hass.states.get("climate.room_1_test_climate_4") + state = hass.states.get("climate.room_1_test_climate_13") assert state.state == HVACMode.HEAT assert state.attributes["preset_mode"] is None @@ -133,7 +133,7 @@ async def test_set_hvac_mode( await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.room_1_test_climate_4", "hvac_mode": HVACMode.HEAT}, + {"entity_id": "climate.room_1_test_climate_13", "hvac_mode": HVACMode.HEAT}, blocking=True, ) diff --git a/tests/components/fibaro/test_cover.py b/tests/components/fibaro/test_cover.py index 23c704415da5..f1cc10ab1fe6 100644 --- a/tests/components/fibaro/test_cover.py +++ b/tests/components/fibaro/test_cover.py @@ -30,7 +30,7 @@ async def test_positionable_cover_setup( # Act await init_integration(hass, mock_config_entry) # Assert - entry = entity_registry.async_get("cover.room_1_test_cover_3") + entry = entity_registry.async_get("cover.room_1_test_cover_2") assert entry assert entry.supported_features == ( CoverEntityFeature.OPEN @@ -38,7 +38,7 @@ async def test_positionable_cover_setup( | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION ) - assert entry.unique_id == "hc2_111111.3" + assert entry.unique_id == "hc2_111111.2" assert entry.original_name == "Room 1 Test cover" @@ -59,7 +59,7 @@ async def test_cover_opening( # Act await init_integration(hass, mock_config_entry) # Assert - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPENING + assert hass.states.get("cover.room_1_test_cover_2").state == CoverState.OPENING async def test_cover_opening_closing_none( @@ -80,7 +80,7 @@ async def test_cover_opening_closing_none( # Act await init_integration(hass, mock_config_entry) # Assert - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPEN + assert hass.states.get("cover.room_1_test_cover_2").state == CoverState.OPEN async def test_cover_closing( @@ -101,7 +101,7 @@ async def test_cover_closing( # Act await init_integration(hass, mock_config_entry) # Assert - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.CLOSING + assert hass.states.get("cover.room_1_test_cover_2").state == CoverState.CLOSING async def test_cover_setup( From f75140b626727b1ed54170893109b9ad15a15026 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 5 Mar 2026 00:38:59 +0100 Subject: [PATCH 05/19] Add const to Portainer for endpoint up (#164746) --- .../components/portainer/binary_sensor.py | 10 +++--- homeassistant/components/portainer/const.py | 33 ++++++++++++++----- .../components/portainer/coordinator.py | 6 ++-- homeassistant/components/portainer/sensor.py | 8 ++--- homeassistant/components/portainer/switch.py | 4 +-- 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 0e190a3e7766..727860e74e2a 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import CONTAINER_STATE_RUNNING, STACK_STATUS_ACTIVE +from .const import ContainerState, EndpointStatus, StackStatus from .coordinator import PortainerContainerData from .entity import ( PortainerContainerEntity, @@ -53,7 +53,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = PortainerContainerBinarySensorEntityDescription( key="status", translation_key="status", - state_fn=lambda data: data.container.state == CONTAINER_STATE_RUNNING, + state_fn=lambda data: data.container.state == ContainerState.RUNNING, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -63,7 +63,7 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointBinarySensorEntityDescription, ...] = ( PortainerEndpointBinarySensorEntityDescription( key="status", translation_key="status", - state_fn=lambda data: data.endpoint.status == 1, # 1 = Running | 2 = Stopped + state_fn=lambda data: data.endpoint.status == EndpointStatus.UP, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -73,9 +73,7 @@ STACK_SENSORS: tuple[PortainerStackBinarySensorEntityDescription, ...] = ( PortainerStackBinarySensorEntityDescription( key="stack_status", translation_key="status", - state_fn=lambda data: ( - data.stack.status == STACK_STATUS_ACTIVE - ), # 1 = Active | 2 = Inactive + state_fn=lambda data: data.stack.status == StackStatus.ACTIVE, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py index cc2e67e8b6e4..6bec2fed9561 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -1,17 +1,34 @@ """Constants for the Portainer integration.""" +from enum import IntEnum, StrEnum + DOMAIN = "portainer" DEFAULT_NAME = "Portainer" -ENDPOINT_STATUS_DOWN = 2 +class EndpointStatus(IntEnum): + """Portainer endpoint status.""" -CONTAINER_STATE_RUNNING = "running" - -STACK_STATUS_ACTIVE = 1 -STACK_STATUS_INACTIVE = 2 + UP = 1 + DOWN = 2 -STACK_TYPE_SWARM = 1 -STACK_TYPE_COMPOSE = 2 -STACK_TYPE_KUBERNETES = 3 +class ContainerState(StrEnum): + """Portainer container state.""" + + RUNNING = "running" + + +class StackStatus(IntEnum): + """Portainer stack status.""" + + ACTIVE = 1 + INACTIVE = 2 + + +class StackType(IntEnum): + """Portainer stack type.""" + + SWARM = 1 + COMPOSE = 2 + KUBERNETES = 3 diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 6586614a1a65..2fe29413ec1d 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONTAINER_STATE_RUNNING, DOMAIN, ENDPOINT_STATUS_DOWN +from .const import DOMAIN, ContainerState, EndpointStatus type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] @@ -154,7 +154,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD mapped_endpoints: dict[int, PortainerCoordinatorData] = {} for endpoint in endpoints: - if endpoint.status == ENDPOINT_STATUS_DOWN: + if endpoint.status == EndpointStatus.DOWN: _LOGGER.debug( "Skipping offline endpoint: %s (ID: %d)", endpoint.name, @@ -215,7 +215,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD running_containers = [ container for container in containers - if container.state == CONTAINER_STATE_RUNNING + if container.state == ContainerState.RUNNING ] if running_containers: container_stats = dict( diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index be23d58a4f30..fc47205db3fc 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import STACK_TYPE_COMPOSE, STACK_TYPE_KUBERNETES, STACK_TYPE_SWARM +from .const import StackType from .coordinator import ( PortainerConfigEntry, PortainerContainerData, @@ -293,11 +293,11 @@ STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = ( translation_key="stack_type", value_fn=lambda data: ( "swarm" - if data.stack.type == STACK_TYPE_SWARM + if data.stack.type == StackType.SWARM else "compose" - if data.stack.type == STACK_TYPE_COMPOSE + if data.stack.type == StackType.COMPOSE else "kubernetes" - if data.stack.type == STACK_TYPE_KUBERNETES + if data.stack.type == StackType.KUBERNETES else None ), device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index 32b705083027..478c991f513a 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import DOMAIN, STACK_STATUS_ACTIVE +from .const import DOMAIN, StackStatus from .coordinator import ( PortainerContainerData, PortainerCoordinator, @@ -99,7 +99,7 @@ STACK_SWITCHES: tuple[PortainerStackSwitchEntityDescription, ...] = ( key="stack", translation_key="stack", device_class=SwitchDeviceClass.SWITCH, - is_on_fn=lambda data: data.stack.status == STACK_STATUS_ACTIVE, + is_on_fn=lambda data: data.stack.status == StackStatus.ACTIVE, turn_on_fn=lambda portainer: portainer.start_stack, turn_off_fn=lambda portainer: portainer.stop_stack, ), From ad1c6846e7ef418bef81953b46528bdc5a6bb689 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:29:59 +0100 Subject: [PATCH 06/19] Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#164791) --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 30 +++++++++++++++--------------- .github/workflows/wheels.yml | 6 +++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 4da147988521..a7f76926d673 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -79,7 +79,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d9705417a3d6..cc654d2dc486 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -456,7 +456,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -657,7 +657,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${PYTHON_VERSION}.json - name: Upload licenses - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -905,7 +905,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${TEST_GROUP_COUNT} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest_buckets path: pytest_buckets.txt @@ -1024,14 +1024,14 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1044,7 +1044,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1181,7 +1181,7 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1189,7 +1189,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1203,7 +1203,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1342,7 +1342,7 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1350,7 +1350,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1364,7 +1364,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1518,14 +1518,14 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1538,7 +1538,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 781654bc5d89..c3f3ea0473cf 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -74,7 +74,7 @@ jobs: ) > .env_file - name: Upload env_file - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: env_file path: ./.env_file @@ -82,7 +82,7 @@ jobs: overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: requirements_diff path: ./requirements_diff.txt @@ -94,7 +94,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 633e2e746951968f06c8819e57ef92aa97268cd2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 5 Mar 2026 08:32:35 +0100 Subject: [PATCH 07/19] Use common state for "medium" in `smartthings` (#164799) --- homeassistant/components/smartthings/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7e68551df3d8..52b70e547092 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -256,7 +256,7 @@ "state": { "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "moderate_high": "Moderate high", "moderate_low": "Moderate low" } From bfa707d79eb6dd7f400f6fe654b6b591085659ad Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 5 Mar 2026 08:32:46 +0100 Subject: [PATCH 08/19] Use common string for "host" in `devialet` config flow (#164798) --- homeassistant/components/devialet/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/devialet/strings.json b/homeassistant/components/devialet/strings.json index cc3f2d270f8a..3e157561cddc 100644 --- a/homeassistant/components/devialet/strings.json +++ b/homeassistant/components/devialet/strings.json @@ -13,7 +13,7 @@ }, "user": { "data": { - "host": "Host" + "host": "[%key:common::config_flow::data::host%]" }, "description": "Please enter the host name or IP address of the Devialet device." } From 284721e1dfd6688591ea20f9c97a60b185e0b6e2 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 5 Mar 2026 09:06:46 +0100 Subject: [PATCH 09/19] Bump pyportainer 1.0.32 (#164803) --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index c219dcba387b..8f6f83ecd78b 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.31"] + "requirements": ["pyportainer==1.0.32"] } diff --git a/requirements_all.txt b/requirements_all.txt index 51308cad99be..82bda6841dd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2373,7 +2373,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.31 +pyportainer==1.0.32 # homeassistant.components.probe_plus pyprobeplus==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 313674dfd01b..5437f21e9008 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2026,7 +2026,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.31 +pyportainer==1.0.32 # homeassistant.components.probe_plus pyprobeplus==1.1.2 From 60a4a97d9c9e4d69d443f638ddb6df104e2dc6d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:16:23 +0100 Subject: [PATCH 10/19] Bump dawidd6/action-download-artifact from 14 to 16 (#164790) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index a7f76926d673..23761ad68f08 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -111,7 +111,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14 + uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -122,7 +122,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14 + uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package From fc8719ce3512f8c9630ad578f400d28987a16686 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:18:08 +0100 Subject: [PATCH 11/19] Remove caio from licenses exception list (#164806) --- script/licenses.py | 1 - 1 file changed, 1 deletion(-) diff --git a/script/licenses.py b/script/licenses.py index 64e0e2db8229..15d10643fec3 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -181,7 +181,6 @@ EXCEPTIONS = { "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 "chacha20poly1305", # LGPL - "caio", # Apache 2 https://github.com/mosquito/caio/?tab=Apache-2.0-1-ov-file#readme "commentjson", # https://github.com/vaidik/commentjson/pull/55 "crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5 "crownstone-core", # https://github.com/crownstone/crownstone-lib-python-core/pull/6 From 76bc58da2ca5f68395cbb209d29172c82a761c8f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:52:12 +0100 Subject: [PATCH 12/19] Add base NetgearDataCoordinator to netgear (#164816) --- homeassistant/components/netgear/__init__.py | 66 ++++++++---------- homeassistant/components/netgear/button.py | 9 ++- .../components/netgear/coordinator.py | 67 +++++++++++++++++-- .../components/netgear/device_tracker.py | 17 ++--- homeassistant/components/netgear/entity.py | 16 ++--- homeassistant/components/netgear/sensor.py | 17 +++-- homeassistant/components/netgear/switch.py | 17 ++--- homeassistant/components/netgear/update.py | 9 +-- 8 files changed, 132 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 100902595aca..13565061593a 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -10,10 +10,14 @@ from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import PLATFORMS -from .coordinator import NetgearConfigEntry, NetgearRuntimeData +from .coordinator import ( + NetgearConfigEntry, + NetgearDataCoordinator, + NetgearFirmwareCoordinator, + NetgearRuntimeData, +) from .errors import CannotLoginException from .router import NetgearRouter @@ -21,7 +25,6 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) SPEED_TEST_INTERVAL = timedelta(hours=2) -SCAN_INTERVAL_FIRMWARE = timedelta(hours=5) async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> bool: @@ -63,10 +66,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> b """Fetch data from the router.""" return await router.async_get_speed_test() - async def async_check_firmware() -> dict[str, Any] | None: - """Check for new firmware of the router.""" - return await router.async_check_new_firmware() - async def async_update_utilization() -> dict[str, Any] | None: """Fetch data from the router.""" return await router.async_get_utilization() @@ -76,57 +75,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> b return await router.async_get_link_status() # Create update coordinators - coordinator = DataUpdateCoordinator( + coordinator_tracker = NetgearDataCoordinator( hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Devices", + router, + entry, + name="Devices", update_method=async_update_devices, update_interval=SCAN_INTERVAL, ) - coordinator_traffic_meter = DataUpdateCoordinator( + coordinator_traffic_meter = NetgearDataCoordinator( hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Traffic meter", + router, + entry, + name="Traffic meter", update_method=async_update_traffic_meter, update_interval=SCAN_INTERVAL, ) - coordinator_speed_test = DataUpdateCoordinator( + coordinator_speed_test = NetgearDataCoordinator( hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Speed test", + router, + entry, + name="Speed test", update_method=async_update_speed_test, update_interval=SPEED_TEST_INTERVAL, ) - coordinator_firmware = DataUpdateCoordinator( + coordinator_firmware = NetgearFirmwareCoordinator(hass, router, entry) + coordinator_utilization = NetgearDataCoordinator( hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Firmware", - update_method=async_check_firmware, - update_interval=SCAN_INTERVAL_FIRMWARE, - ) - coordinator_utilization = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Utilization", + router, + entry, + name="Utilization", update_method=async_update_utilization, update_interval=SCAN_INTERVAL, ) - coordinator_link = DataUpdateCoordinator( + coordinator_link = NetgearDataCoordinator( hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Ethernet Link Status", + router, + entry, + name="Ethernet Link Status", update_method=async_check_link_status, update_interval=SCAN_INTERVAL, ) if router.track_devices: - await coordinator.async_config_entry_first_refresh() + await coordinator_tracker.async_config_entry_first_refresh() await coordinator_traffic_meter.async_config_entry_first_refresh() await coordinator_firmware.async_config_entry_first_refresh() await coordinator_utilization.async_config_entry_first_refresh() @@ -134,7 +126,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> b entry.runtime_data = NetgearRuntimeData( router=router, - coordinator=coordinator, + coordinator_tracker=coordinator_tracker, coordinator_traffic=coordinator_traffic_meter, coordinator_speed=coordinator_speed_test, coordinator_firmware=coordinator_firmware, diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index 07b9ac510e63..63308ca91b29 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -12,9 +12,8 @@ from homeassistant.components.button import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .coordinator import NetgearConfigEntry +from .coordinator import NetgearConfigEntry, NetgearDataCoordinator from .entity import NetgearRouterCoordinatorEntity from .router import NetgearRouter @@ -43,9 +42,9 @@ async def async_setup_entry( ) -> None: """Set up button for Netgear component.""" router = entry.runtime_data.router - coordinator = entry.runtime_data.coordinator + coordinator_tracker = entry.runtime_data.coordinator_tracker async_add_entities( - NetgearRouterButtonEntity(coordinator, router, entity_description) + NetgearRouterButtonEntity(coordinator_tracker, router, entity_description) for entity_description in BUTTONS ) @@ -57,7 +56,7 @@ class NetgearRouterButtonEntity(NetgearRouterCoordinatorEntity, ButtonEntity): def __init__( self, - coordinator: DataUpdateCoordinator[bool], + coordinator: NetgearDataCoordinator[bool], router: NetgearRouter, entity_description: NetgearButtonEntityDescription, ) -> None: diff --git a/homeassistant/components/netgear/coordinator.py b/homeassistant/components/netgear/coordinator.py index fc0f2c676583..bc30e918c97b 100644 --- a/homeassistant/components/netgear/coordinator.py +++ b/homeassistant/components/netgear/coordinator.py @@ -2,26 +2,81 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from datetime import timedelta +import logging from typing import Any from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .router import NetgearRouter +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL_FIRMWARE = timedelta(hours=5) + @dataclass class NetgearRuntimeData: """Runtime data for the Netgear integration.""" router: NetgearRouter - coordinator: DataUpdateCoordinator[bool] - coordinator_traffic: DataUpdateCoordinator[dict[str, Any] | None] - coordinator_speed: DataUpdateCoordinator[dict[str, Any] | None] - coordinator_firmware: DataUpdateCoordinator[dict[str, Any] | None] - coordinator_utilization: DataUpdateCoordinator[dict[str, Any] | None] - coordinator_link: DataUpdateCoordinator[dict[str, Any] | None] + coordinator_tracker: NetgearDataCoordinator[bool] + coordinator_traffic: NetgearDataCoordinator[dict[str, Any] | None] + coordinator_speed: NetgearDataCoordinator[dict[str, Any] | None] + coordinator_firmware: NetgearFirmwareCoordinator + coordinator_utilization: NetgearDataCoordinator[dict[str, Any] | None] + coordinator_link: NetgearDataCoordinator[dict[str, Any] | None] type NetgearConfigEntry = ConfigEntry[NetgearRuntimeData] + + +class NetgearDataCoordinator[T](DataUpdateCoordinator[T]): + """Base coordinator for Netgear.""" + + config_entry: NetgearConfigEntry + + def __init__( + self, + hass: HomeAssistant, + router: NetgearRouter, + entry: NetgearConfigEntry, + *, + name: str, + update_interval: timedelta, + update_method: Callable[[], Coroutine[Any, Any, T]] | None = None, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{router.device_name} {name}", + update_interval=update_interval, + update_method=update_method, + ) + self.router = router + + +class NetgearFirmwareCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear firmware updates.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + router, + entry, + name="Firmware", + update_interval=SCAN_INTERVAL_FIRMWARE, + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Check for new firmware of the router.""" + return await self.router.async_check_new_firmware() diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index 4536e08dbeab..6e9df9618cde 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -7,10 +7,9 @@ import logging from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DEVICE_ICONS -from .coordinator import NetgearConfigEntry +from .coordinator import NetgearConfigEntry, NetgearDataCoordinator from .entity import NetgearDeviceEntity from .router import NetgearRouter @@ -24,13 +23,13 @@ async def async_setup_entry( ) -> None: """Set up device tracker for Netgear component.""" router = entry.runtime_data.router - coordinator = entry.runtime_data.coordinator + coordinator_tracker = entry.runtime_data.coordinator_tracker tracked = set() @callback def new_device_callback() -> None: """Add new devices if needed.""" - if not coordinator.data: + if not coordinator_tracker.data: return new_entities = [] @@ -39,14 +38,16 @@ async def async_setup_entry( if mac in tracked: continue - new_entities.append(NetgearScannerEntity(coordinator, router, device)) + new_entities.append( + NetgearScannerEntity(coordinator_tracker, router, device) + ) tracked.add(mac) async_add_entities(new_entities) - entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + entry.async_on_unload(coordinator_tracker.async_add_listener(new_device_callback)) - coordinator.data = True + coordinator_tracker.data = True new_device_callback() @@ -57,7 +58,7 @@ class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): def __init__( self, - coordinator: DataUpdateCoordinator[bool], + coordinator: NetgearDataCoordinator[bool], router: NetgearRouter, device: dict, ) -> None: diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py index 4b6794f3229f..67f52b3cc497 100644 --- a/homeassistant/components/netgear/entity.py +++ b/homeassistant/components/netgear/entity.py @@ -10,12 +10,10 @@ from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import NetgearDataCoordinator from .router import NetgearRouter @@ -26,7 +24,7 @@ class NetgearDeviceEntity(CoordinatorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Any], + coordinator: NetgearDataCoordinator[Any], router: NetgearRouter, device: dict, ) -> None: @@ -90,12 +88,12 @@ class NetgearRouterEntity(Entity): ) -class NetgearRouterCoordinatorEntity(NetgearRouterEntity, CoordinatorEntity): +class NetgearRouterCoordinatorEntity[T: NetgearDataCoordinator[Any]]( + NetgearRouterEntity, CoordinatorEntity[T] +): """Base class for a Netgear router entity.""" - def __init__( - self, coordinator: DataUpdateCoordinator[Any], router: NetgearRouter - ) -> None: + def __init__(self, coordinator: T, router: NetgearRouter) -> None: """Initialize a Netgear device.""" CoordinatorEntity.__init__(self, coordinator) NetgearRouterEntity.__init__(self, router) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index c407798cb5a9..cc39be817773 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -26,9 +26,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .coordinator import NetgearConfigEntry +from .coordinator import NetgearConfigEntry, NetgearDataCoordinator from .entity import NetgearDeviceEntity, NetgearRouterCoordinatorEntity from .router import NetgearRouter @@ -272,7 +271,7 @@ async def async_setup_entry( ) -> None: """Set up Netgear sensors from a config entry.""" router = entry.runtime_data.router - coordinator = entry.runtime_data.coordinator + coordinator_tracker = entry.runtime_data.coordinator_tracker coordinator_traffic = entry.runtime_data.coordinator_traffic coordinator_speed = entry.runtime_data.coordinator_speed coordinator_utilization = entry.runtime_data.coordinator_utilization @@ -298,7 +297,7 @@ async def async_setup_entry( @callback def new_device_callback() -> None: """Add new devices if needed.""" - if not coordinator.data: + if not coordinator_tracker.data: return new_entities: list[NetgearSensorEntity] = [] @@ -308,16 +307,16 @@ async def async_setup_entry( continue new_entities.extend( - NetgearSensorEntity(coordinator, router, device, attribute) + NetgearSensorEntity(coordinator_tracker, router, device, attribute) for attribute in sensors ) tracked.add(mac) async_add_entities(new_entities) - entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + entry.async_on_unload(coordinator_tracker.async_add_listener(new_device_callback)) - coordinator.data = True + coordinator_tracker.data = True new_device_callback() @@ -326,7 +325,7 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Any], + coordinator: NetgearDataCoordinator[Any], router: NetgearRouter, device: dict, attribute: str, @@ -365,7 +364,7 @@ class NetgearRouterSensorEntity(NetgearRouterCoordinatorEntity, RestoreSensor): def __init__( self, - coordinator: DataUpdateCoordinator[dict[str, Any] | None], + coordinator: NetgearDataCoordinator[dict[str, Any] | None], router: NetgearRouter, entity_description: NetgearSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 843914490da0..d9e0fdf8f29e 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -12,9 +12,8 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .coordinator import NetgearConfigEntry +from .coordinator import NetgearConfigEntry, NetgearDataCoordinator from .entity import NetgearDeviceEntity, NetgearRouterEntity from .router import NetgearRouter @@ -111,14 +110,14 @@ async def async_setup_entry( ) # Entities per network device - coordinator = entry.runtime_data.coordinator + coordinator_tracker = entry.runtime_data.coordinator_tracker tracked = set() @callback def new_device_callback() -> None: """Add new devices if needed.""" new_entities = [] - if not coordinator.data: + if not coordinator_tracker.data: return for mac, device in router.devices.items(): @@ -127,7 +126,9 @@ async def async_setup_entry( new_entities.extend( [ - NetgearAllowBlock(coordinator, router, device, entity_description) + NetgearAllowBlock( + coordinator_tracker, router, device, entity_description + ) for entity_description in SWITCH_TYPES ] ) @@ -135,9 +136,9 @@ async def async_setup_entry( async_add_entities(new_entities) - entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + entry.async_on_unload(coordinator_tracker.async_add_listener(new_device_callback)) - coordinator.data = True + coordinator_tracker.data = True new_device_callback() @@ -148,7 +149,7 @@ class NetgearAllowBlock(NetgearDeviceEntity, SwitchEntity): def __init__( self, - coordinator: DataUpdateCoordinator[bool], + coordinator: NetgearDataCoordinator[bool], router: NetgearRouter, device: dict, entity_description: SwitchEntityDescription, diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index 266ee2da3958..5f23300468be 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -12,9 +12,8 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .coordinator import NetgearConfigEntry +from .coordinator import NetgearConfigEntry, NetgearFirmwareCoordinator from .entity import NetgearRouterCoordinatorEntity from .router import NetgearRouter @@ -34,7 +33,9 @@ async def async_setup_entry( async_add_entities(entities) -class NetgearUpdateEntity(NetgearRouterCoordinatorEntity, UpdateEntity): +class NetgearUpdateEntity( + NetgearRouterCoordinatorEntity[NetgearFirmwareCoordinator], UpdateEntity +): """Update entity for a Netgear device.""" _attr_device_class = UpdateDeviceClass.FIRMWARE @@ -42,7 +43,7 @@ class NetgearUpdateEntity(NetgearRouterCoordinatorEntity, UpdateEntity): def __init__( self, - coordinator: DataUpdateCoordinator[dict[str, Any] | None], + coordinator: NetgearFirmwareCoordinator, router: NetgearRouter, ) -> None: """Initialize a Netgear device.""" From 42bc5c3a5f7bffe018bfc7ac8a2c1306964b2e93 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:52:29 +0100 Subject: [PATCH 13/19] Add `remote.turned_on` and `remote.turned_off` triggers (#164535) Co-authored-by: Martin Hjelmare --- .../components/automation/__init__.py | 1 + homeassistant/components/remote/icons.json | 8 + homeassistant/components/remote/strings.json | 37 ++- homeassistant/components/remote/trigger.py | 17 ++ homeassistant/components/remote/triggers.yaml | 18 ++ tests/components/remote/test_trigger.py | 212 ++++++++++++++++++ 6 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/remote/trigger.py create mode 100644 homeassistant/components/remote/triggers.yaml create mode 100644 tests/components/remote/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 6345069458b8..3edf2ca3fe7e 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -149,6 +149,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "lock", "media_player", "person", + "remote", "scene", "siren", "switch", diff --git a/homeassistant/components/remote/icons.json b/homeassistant/components/remote/icons.json index 43a7f6ee7b65..1560336d7c1a 100644 --- a/homeassistant/components/remote/icons.json +++ b/homeassistant/components/remote/icons.json @@ -26,5 +26,13 @@ "turn_on": { "service": "mdi:remote" } + }, + "triggers": { + "turned_off": { + "trigger": "mdi:remote-off" + }, + "turned_on": { + "trigger": "mdi:remote" + } } } diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 52aeeca75601..e2f6af026737 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted remotes to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "action_type": { "toggle": "[%key:common::device_automation::action_type::toggle%]", @@ -27,6 +31,15 @@ } } }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, "services": { "delete_command": { "description": "Deletes a command or a list of commands from the database.", @@ -113,5 +126,27 @@ "name": "[%key:common::action::turn_on%]" } }, - "title": "Remote" + "title": "Remote", + "triggers": { + "turned_off": { + "description": "Triggers when one or more remotes turn off.", + "fields": { + "behavior": { + "description": "[%key:component::remote::common::trigger_behavior_description%]", + "name": "[%key:component::remote::common::trigger_behavior_name%]" + } + }, + "name": "Remote turned off" + }, + "turned_on": { + "description": "Triggers when one or more remotes turn on.", + "fields": { + "behavior": { + "description": "[%key:component::remote::common::trigger_behavior_description%]", + "name": "[%key:component::remote::common::trigger_behavior_name%]" + } + }, + "name": "Remote turned on" + } + } } diff --git a/homeassistant/components/remote/trigger.py b/homeassistant/components/remote/trigger.py new file mode 100644 index 000000000000..92a946c5ab77 --- /dev/null +++ b/homeassistant/components/remote/trigger.py @@ -0,0 +1,17 @@ +"""Provides triggers for remotes.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +from . import DOMAIN + +TRIGGERS: dict[str, type[Trigger]] = { + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for remotes.""" + return TRIGGERS diff --git a/homeassistant/components/remote/triggers.yaml b/homeassistant/components/remote/triggers.yaml new file mode 100644 index 000000000000..6dadeba1fd2a --- /dev/null +++ b/homeassistant/components/remote/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: remote + fields: + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any + translation_key: trigger_behavior + +turned_off: *trigger_common +turned_on: *trigger_common diff --git a/tests/components/remote/test_trigger.py b/tests/components/remote/test_trigger.py new file mode 100644 index 000000000000..96f57906e6b5 --- /dev/null +++ b/tests/components/remote/test_trigger.py @@ -0,0 +1,212 @@ +"""Test remote trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.remote import DOMAIN +from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + TriggerStateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture +async def target_remotes(hass: HomeAssistant) -> list[str]: + """Create multiple remotes entities associated with different targets.""" + return (await target_entities(hass, DOMAIN))["included"] + + +@pytest.mark.parametrize( + "trigger_key", + ["remote.turned_on", "remote.turned_off"], +) +async def test_remote_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the remote triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="remote.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_trigger_states( + trigger="remote.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + ], +) +async def test_remote_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_remotes: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the remote triggers when any remote changes to a specific state.""" + other_entity_ids = set(target_remotes) - {entity_id} + + # Set all remotes, including the tested remote, to the initial state + for eid in target_remotes: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check that changing other remotes also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="remote.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_trigger_states( + trigger="remote.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + ], +) +async def test_remote_state_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_remotes: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the remote triggers when the first remote changes to a specific state.""" + other_entity_ids = set(target_remotes) - {entity_id} + + # Set all remotes, including the tested remote, to the initial state + for eid in target_remotes: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Triggering other remotes should not cause the trigger to fire again + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="remote.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_trigger_states( + trigger="remote.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + ], +) +async def test_remote_state_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_remotes: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the remote triggers when the last remote changes to a specific state.""" + other_entity_ids = set(target_remotes) - {entity_id} + + # Set all remotes, including the tested remote, to the initial state + for eid in target_remotes: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() From c3858a08416e538cf06e6ea0b47e75d228c1f8c6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Mar 2026 11:13:01 +0100 Subject: [PATCH 14/19] Improve tuya diagnostic tests (#164819) --- .../components/tuya/snapshots/test_diagnostics.ambr | 12 ++++++------ tests/components/tuya/test_diagnostics.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr index 4207af3e401e..1b6a58d0931b 100644 --- a/tests/components/tuya/snapshots/test_diagnostics.ambr +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -101,15 +101,15 @@ 'state': dict({ 'attributes': dict({ 'device_class': 'duration', - 'friendly_name': 'Multifunction alarm Arm delay', + 'friendly_name': 'Multifunction alarm Alarm delay', 'max': 999.0, 'min': 0.0, 'mode': 'auto', 'step': 1.0, 'unit_of_measurement': 's', }), - 'entity_id': 'number.multifunction_alarm_arm_delay', - 'state': '15.0', + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'state': '20.0', }), 'unit_of_measurement': 's', }), @@ -124,15 +124,15 @@ 'state': dict({ 'attributes': dict({ 'device_class': 'duration', - 'friendly_name': 'Multifunction alarm Alarm delay', + 'friendly_name': 'Multifunction alarm Arm delay', 'max': 999.0, 'min': 0.0, 'mode': 'auto', 'step': 1.0, 'unit_of_measurement': 's', }), - 'entity_id': 'number.multifunction_alarm_alarm_delay', - 'state': '20.0', + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'state': '15.0', }), 'unit_of_measurement': 's', }), diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py index 8f6900ca9c1a..0e7db0d8642e 100644 --- a/tests/components/tuya/test_diagnostics.py +++ b/tests/components/tuya/test_diagnostics.py @@ -37,6 +37,12 @@ async def test_entry_diagnostics( hass, hass_client, mock_config_entry ) + # Sort the lists of entities by entity_id to ensure consistent ordering + # for snapshot testing + for device in result["devices"]: + device["home_assistant"]["entities"] = sorted( + device["home_assistant"]["entities"], key=lambda x: x["state"]["entity_id"] + ) assert result == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) @@ -68,6 +74,11 @@ async def test_device_diagnostics( result = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device ) + # Sort the list of entities by entity_id to ensure consistent ordering + # for snapshot testing + result["home_assistant"]["entities"] = sorted( + result["home_assistant"]["entities"], key=lambda x: x["state"]["entity_id"] + ) assert result == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) From e87c677cc4cc94a181f832233636e3c9be9b7415 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Mar 2026 11:15:50 +0100 Subject: [PATCH 15/19] Improve homee tests (#164820) --- .../homee/test_alarm_control_panel.py | 15 +++++++++++---- tests/components/homee/test_binary_sensor.py | 15 +++++++++++---- tests/components/homee/test_button.py | 12 ++++++++++-- tests/components/homee/test_climate.py | 11 +++++++++-- tests/components/homee/test_cover.py | 11 ++++++++++- tests/components/homee/test_event.py | 17 ++++++++++++----- tests/components/homee/test_fan.py | 11 +++++++++-- tests/components/homee/test_light.py | 11 +++++++++-- tests/components/homee/test_lock.py | 11 +++++++++-- tests/components/homee/test_number.py | 11 +++++++++-- tests/components/homee/test_select.py | 11 +++++++++-- tests/components/homee/test_sensor.py | 11 +++++++++-- tests/components/homee/test_siren.py | 11 +++++++++-- tests/components/homee/test_switch.py | 11 +++++++++-- tests/components/homee/test_valve.py | 11 +++++++++-- 15 files changed, 144 insertions(+), 36 deletions(-) diff --git a/tests/components/homee/test_alarm_control_panel.py b/tests/components/homee/test_alarm_control_panel.py index dafe74660ace..241394b0deaf 100644 --- a/tests/components/homee/test_alarm_control_panel.py +++ b/tests/components/homee/test_alarm_control_panel.py @@ -1,5 +1,6 @@ """Test Homee alarm control panels.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -24,6 +25,15 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch( + "homeassistant.components.homee.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + yield + + async def setup_alarm_control_panel( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry ) -> None: @@ -88,9 +98,6 @@ async def test_alarm_control_panel_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test the alarm-control_panel snapshots.""" - with patch( - "homeassistant.components.homee.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] - ): - await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) + await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_binary_sensor.py b/tests/components/homee/test_binary_sensor.py index 9cfca3846765..8379e0a26f8f 100644 --- a/tests/components/homee/test_binary_sensor.py +++ b/tests/components/homee/test_binary_sensor.py @@ -1,7 +1,9 @@ """Test homee binary sensors.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -13,6 +15,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + async def test_sensor_snapshot( hass: HomeAssistant, mock_homee: MagicMock, @@ -23,8 +32,7 @@ async def test_sensor_snapshot( """Test the multisensor snapshot.""" mock_homee.nodes = [build_mock_node("binary_sensors.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.BINARY_SENSOR]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -39,8 +47,7 @@ async def test_add_device( """Test adding a device.""" mock_homee.nodes = [build_mock_node("binary_sensors.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.BINARY_SENSOR]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) # Add a new device added_node = build_mock_node("add_device.json") diff --git a/tests/components/homee/test_button.py b/tests/components/homee/test_button.py index fc7b018805f6..b780d12e66c0 100644 --- a/tests/components/homee/test_button.py +++ b/tests/components/homee/test_button.py @@ -1,7 +1,9 @@ """Test Homee buttons.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -14,6 +16,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.BUTTON]): + yield + + async def test_button_press( hass: HomeAssistant, mock_homee: MagicMock, @@ -44,7 +53,6 @@ async def test_button_snapshot( """Test the multisensor snapshot.""" mock_homee.nodes = [build_mock_node("buttons.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.BUTTON]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_climate.py b/tests/components/homee/test_climate.py index bb650325240b..df9d119c473c 100644 --- a/tests/components/homee/test_climate.py +++ b/tests/components/homee/test_climate.py @@ -1,5 +1,6 @@ """Test Homee climate entities.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch from pyHomee.const import AttributeType @@ -36,6 +37,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.CLIMATE]): + yield + + async def setup_mock_climate( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -349,7 +357,6 @@ async def test_climate_snapshot( build_mock_node("thermostat_with_preset.json"), build_mock_node("thermostat_with_alternate_preset.json"), ] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.CLIMATE]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index 4f215c683a2c..81ea1c7719b4 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -1,6 +1,7 @@ """Test homee covers.""" -from unittest.mock import MagicMock +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch import pytest from websockets import frames @@ -28,6 +29,7 @@ from homeassistant.const import ( SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -38,6 +40,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.COVER]): + yield + + async def test_open_close_stop_cover( hass: HomeAssistant, mock_homee: MagicMock, diff --git a/tests/components/homee/test_event.py b/tests/components/homee/test_event.py index bbd5bc913180..17bfc89bdbca 100644 --- a/tests/components/homee/test_event.py +++ b/tests/components/homee/test_event.py @@ -1,5 +1,6 @@ """Test homee events.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -15,6 +16,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.EVENT]): + yield + + @pytest.mark.parametrize( ("entity_id", "attribute_id", "expected_event_types"), [ @@ -85,10 +93,9 @@ async def test_event_snapshot( profile: int, ) -> None: """Test the event entity snapshot.""" - with patch("homeassistant.components.homee.PLATFORMS", [Platform.EVENT]): - mock_homee.nodes = [build_mock_node("events.json")] - mock_homee.nodes[0].profile = profile - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + mock_homee.nodes = [build_mock_node("events.json")] + mock_homee.nodes[0].profile = profile + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_fan.py b/tests/components/homee/test_fan.py index 55d019af746a..25bb5f5a72d2 100644 --- a/tests/components/homee/test_fan.py +++ b/tests/components/homee/test_fan.py @@ -1,5 +1,6 @@ """Test Homee fans.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, call, patch import pytest @@ -33,6 +34,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.FAN]): + yield + + @pytest.mark.parametrize( ("speed", "expected"), [ @@ -186,7 +194,6 @@ async def test_fan_snapshot( """Test the fan snapshot.""" mock_homee.nodes = [build_mock_node("fan.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.FAN]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_light.py b/tests/components/homee/test_light.py index c8af4f6b23d6..30369fa0aa7c 100644 --- a/tests/components/homee/test_light.py +++ b/tests/components/homee/test_light.py @@ -1,5 +1,6 @@ """Test homee lights.""" +from collections.abc import AsyncGenerator from typing import Any from unittest.mock import MagicMock, call, patch @@ -24,6 +25,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LIGHT]): + yield + + def mock_attribute_map(attributes) -> dict: """Mock the attribute map of a Homee node.""" attribute_map = {} @@ -152,7 +160,6 @@ async def test_light_snapshot( mock_homee.nodes[i].attribute_map = mock_attribute_map( mock_homee.nodes[i].attributes ) - with patch("homeassistant.components.homee.PLATFORMS", [Platform.LIGHT]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_lock.py b/tests/components/homee/test_lock.py index 6f41185c4ed1..416da8414070 100644 --- a/tests/components/homee/test_lock.py +++ b/tests/components/homee/test_lock.py @@ -1,5 +1,6 @@ """Test Homee locks.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -20,6 +21,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LOCK]): + yield + + async def setup_lock( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homee: MagicMock ) -> None: @@ -136,7 +144,6 @@ async def test_lock_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test the lock snapshots.""" - with patch("homeassistant.components.homee.PLATFORMS", [Platform.LOCK]): - await setup_lock(hass, mock_config_entry, mock_homee) + await setup_lock(hass, mock_config_entry, mock_homee) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py index 2825152241ab..476b061468cd 100644 --- a/tests/components/homee/test_number.py +++ b/tests/components/homee/test_number.py @@ -1,5 +1,6 @@ """Test Homee nmumbers.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -19,6 +20,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): + yield + + async def setup_numbers( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry ) -> None: @@ -103,7 +111,6 @@ async def test_number_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test the multisensor snapshot.""" - with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): - await setup_numbers(hass, mock_homee, mock_config_entry) + await setup_numbers(hass, mock_homee, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_select.py b/tests/components/homee/test_select.py index c0dec2234d66..5135e854742d 100644 --- a/tests/components/homee/test_select.py +++ b/tests/components/homee/test_select.py @@ -1,5 +1,6 @@ """Test homee selects.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -23,6 +24,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SELECT]): + yield + + async def setup_select( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry ) -> None: @@ -100,7 +108,6 @@ async def test_select_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test the select entity snapshot.""" - with patch("homeassistant.components.homee.PLATFORMS", [Platform.SELECT]): - await setup_select(hass, mock_homee, mock_config_entry) + await setup_select(hass, mock_homee, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 0059b4ceedb7..dcd186358e0d 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -1,5 +1,6 @@ """Test homee sensors.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -25,6 +26,13 @@ from . import async_update_attribute_value, build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SENSOR]): + yield + + @pytest.fixture(autouse=True) def enable_all_entities(entity_registry_enabled_by_default: None) -> None: """Make sure all entities are enabled.""" @@ -150,7 +158,6 @@ async def test_sensor_snapshot( """Test the multisensor snapshot.""" mock_homee.nodes = [build_mock_node("sensors.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.SENSOR]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_siren.py b/tests/components/homee/test_siren.py index ccdc01a5f53e..7b23f5787d45 100644 --- a/tests/components/homee/test_siren.py +++ b/tests/components/homee/test_siren.py @@ -1,5 +1,6 @@ """Test homee sirens.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -20,6 +21,13 @@ from . import async_update_attribute_value, build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SIREN]): + yield + + async def setup_siren( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homee: MagicMock ) -> None: @@ -80,7 +88,6 @@ async def test_siren_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test siren snapshot.""" - with patch("homeassistant.components.homee.PLATFORMS", [Platform.SIREN]): - await setup_siren(hass, mock_config_entry, mock_homee) + await setup_siren(hass, mock_config_entry, mock_homee) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_switch.py b/tests/components/homee/test_switch.py index bb14313f4874..877fa081c79f 100644 --- a/tests/components/homee/test_switch.py +++ b/tests/components/homee/test_switch.py @@ -1,5 +1,6 @@ """Test Homee switches.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -25,6 +26,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SWITCH]): + yield + + async def test_switch_state( hass: HomeAssistant, mock_homee: MagicMock, @@ -173,7 +181,6 @@ async def test_switch_snapshot( """Test the multisensor snapshot.""" mock_homee.nodes = [build_mock_node("switches.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.SWITCH]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_valve.py b/tests/components/homee/test_valve.py index 166b52cc07b8..0d8c2f2b93a8 100644 --- a/tests/components/homee/test_valve.py +++ b/tests/components/homee/test_valve.py @@ -1,5 +1,6 @@ """Test Homee valves.""" +from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch import pytest @@ -23,6 +24,13 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.VALVE]): + yield + + async def test_valve_set_position( hass: HomeAssistant, mock_homee: MagicMock, @@ -104,7 +112,6 @@ async def test_valve_snapshot( """Test the valve snapshots.""" mock_homee.nodes = [build_mock_node("valve.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - with patch("homeassistant.components.homee.PLATFORMS", [Platform.VALVE]): - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From c7776057b73b5dfed0d6aa74c8b62726e180fc40 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:45:05 +0100 Subject: [PATCH 16/19] Enforce SSRF redirect protection only for connector allowed_protocol_schema_set (#164769) Co-authored-by: RaHehl Co-authored-by: J. Nick Koston --- homeassistant/helpers/aiohttp_client.py | 6 +++ tests/helpers/test_aiohttp_client.py | 65 ++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index cf40441bf5f3..0939c31eadca 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -87,6 +87,12 @@ async def _ssrf_redirect_middleware( # Relative redirects stay on the same host - always safe return resp + # Only schemes that aiohttp can open a network connection for need + # SSRF protection. Custom app URI schemes (e.g. weconnect://) are inert + # from a networking perspective and must not be blocked. + if connector and redirect_url.scheme not in connector.allowed_protocol_schema_set: + return resp + host = redirect_url.host if await _async_is_blocked_host(host, connector): resp.close() diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 0862d3c1e765..385eba59f50c 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -566,11 +566,48 @@ async def test_redirect_to_non_loopback_allowed( assert resp.status == 200 +@pytest.mark.usefixtures("socket_enabled") +async def test_redirect_to_custom_scheme_not_blocked( + hass: HomeAssistant, redirect_server: TestServer +) -> None: + """Test that redirects to custom (non-HTTP/S) URI schemes are not blocked.""" + session = client.async_create_clientsession(hass) + server_port = redirect_server.port + + # weconnect://authenticated is used as an OAuth callback URI. + # The host 'authenticated' resolves to 0.0.0.0, which would trigger + # the SSRF block for schemes in the connector's allowed set, but + # weconnect:// is a custom app scheme that aiohttp cannot connect to. + redirect_url = ( + f"http://external.example.com:{server_port}" + "/redirect?to=weconnect://authenticated" + ) + + async def mock_async_resolve_host(host: str) -> list[dict[str, object]]: + """Mock DNS for the SSRF middleware check (not TCP connections).""" + if host == "external.example.com": + # Origin must be public so middleware doesn't treat this as + # loopback→loopback (which is always allowed). + return _resolve_result(host, "93.184.216.34") + # The scheme check skips DNS for non-HTTP(S), so this branch is + # only reached if that check is removed — ensuring the test then + # fails with SSRFRedirectError instead of silently passing. + return _resolve_result(host, "0.0.0.0") + + connector = session.connector + # allow_redirects=False so aiohttp returns the 307 response directly + # rather than attempting to connect to the custom-scheme URI. + # SSRFRedirectError must NOT be raised despite "authenticated" → 0.0.0.0. + with patch.object(connector, "async_resolve_host", mock_async_resolve_host): + resp = await session.get(redirect_url, allow_redirects=False) + assert resp.status == 307 + + @pytest.mark.usefixtures("socket_enabled") @pytest.mark.parametrize( ("location", "target_resolved_addr"), [ - # Loopback IPs and hostnames — blocked before DNS resolution + # Loopback IPs and hostnames — blocked before DNS resolution (http) ("http://127.0.0.1/evil", None), ("http://[::1]/evil", None), ("http://localhost/evil", None), @@ -579,12 +616,36 @@ async def test_redirect_to_non_loopback_allowed( ("http://example.localhost./evil", None), ("http://app.localhost/evil", None), ("http://sub.domain.localhost/evil", None), - # Benign hostnames resolving to blocked IPs — blocked after DNS + # Loopback IPs and hostnames — blocked before DNS resolution (https) + ("https://127.0.0.1/evil", None), + ("https://[::1]/evil", None), + ("https://localhost/evil", None), + # Loopback IPs and hostnames — blocked before DNS resolution (ws/wss) + ("ws://127.0.0.1/evil", None), + ("ws://localhost/evil", None), + ("wss://127.0.0.1/evil", None), + ("wss://localhost/evil", None), + # Benign hostnames resolving to blocked IPs — blocked after DNS (http) ("http://evil.example.com:{port}/steal", "127.0.0.1"), ("http://evil.example.com:{port}/steal", "127.0.0.2"), ("http://evil.example.com:{port}/steal", "::1"), ("http://evil.example.com:{port}/steal", "0.0.0.0"), ("http://evil.example.com:{port}/steal", "::"), + # Benign hostnames resolving to blocked IPs — blocked after DNS (https) + ("https://evil.example.com:{port}/steal", "127.0.0.1"), + ("https://evil.example.com:{port}/steal", "0.0.0.0"), + # Benign hostnames resolving to blocked IPs — blocked after DNS (ws/wss) + ("ws://evil.example.com:{port}/steal", "127.0.0.1"), + ("wss://evil.example.com:{port}/steal", "127.0.0.1"), + # Upper-case schemes — yarl normalizes to lowercase per RFC 3986 + ("HTTP://localhost/evil", None), + ("HTTPS://localhost/evil", None), + ("WS://localhost/evil", None), + ("WSS://localhost/evil", None), + ("HTTP://evil.example.com:{port}/steal", "127.0.0.1"), + ("HTTPS://evil.example.com:{port}/steal", "127.0.0.1"), + ("WS://evil.example.com:{port}/steal", "127.0.0.1"), + ("WSS://evil.example.com:{port}/steal", "127.0.0.1"), ], ) async def test_redirect_to_blocked_address( From 698c5eca00fdeace3916d1c2f0bd167fcced0358 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:49:28 +0100 Subject: [PATCH 17/19] Migrate remaining netgear coordinators to separate module (#164826) --- homeassistant/components/netgear/__init__.py | 78 ++----------- homeassistant/components/netgear/button.py | 4 +- .../components/netgear/coordinator.py | 107 +++++++++++++++--- .../components/netgear/device_tracker.py | 4 +- homeassistant/components/netgear/entity.py | 6 +- homeassistant/components/netgear/sensor.py | 8 +- homeassistant/components/netgear/switch.py | 4 +- 7 files changed, 119 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 13565061593a..cbde5ccccadc 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -2,9 +2,7 @@ from __future__ import annotations -from datetime import timedelta import logging -from typing import Any from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant @@ -14,18 +12,19 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import PLATFORMS from .coordinator import ( NetgearConfigEntry, - NetgearDataCoordinator, NetgearFirmwareCoordinator, + NetgearLinkCoordinator, NetgearRuntimeData, + NetgearSpeedTestCoordinator, + NetgearTrackerCoordinator, + NetgearTrafficMeterCoordinator, + NetgearUtilizationCoordinator, ) from .errors import CannotLoginException from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=30) -SPEED_TEST_INTERVAL = timedelta(hours=2) - async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> bool: """Set up Netgear component.""" @@ -52,70 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> b router.ssl, ) - async def async_update_devices() -> bool: - """Fetch data from the router.""" - if router.track_devices: - return await router.async_update_device_trackers() - return False - - async def async_update_traffic_meter() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_traffic_meter() - - async def async_update_speed_test() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_speed_test() - - async def async_update_utilization() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_utilization() - - async def async_check_link_status() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_link_status() - # Create update coordinators - coordinator_tracker = NetgearDataCoordinator( - hass, - router, - entry, - name="Devices", - update_method=async_update_devices, - update_interval=SCAN_INTERVAL, - ) - coordinator_traffic_meter = NetgearDataCoordinator( - hass, - router, - entry, - name="Traffic meter", - update_method=async_update_traffic_meter, - update_interval=SCAN_INTERVAL, - ) - coordinator_speed_test = NetgearDataCoordinator( - hass, - router, - entry, - name="Speed test", - update_method=async_update_speed_test, - update_interval=SPEED_TEST_INTERVAL, - ) + coordinator_tracker = NetgearTrackerCoordinator(hass, router, entry) + coordinator_traffic_meter = NetgearTrafficMeterCoordinator(hass, router, entry) + coordinator_speed_test = NetgearSpeedTestCoordinator(hass, router, entry) coordinator_firmware = NetgearFirmwareCoordinator(hass, router, entry) - coordinator_utilization = NetgearDataCoordinator( - hass, - router, - entry, - name="Utilization", - update_method=async_update_utilization, - update_interval=SCAN_INTERVAL, - ) - coordinator_link = NetgearDataCoordinator( - hass, - router, - entry, - name="Ethernet Link Status", - update_method=async_check_link_status, - update_interval=SCAN_INTERVAL, - ) + coordinator_utilization = NetgearUtilizationCoordinator(hass, router, entry) + coordinator_link = NetgearLinkCoordinator(hass, router, entry) if router.track_devices: await coordinator_tracker.async_config_entry_first_refresh() diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index 63308ca91b29..7ddd11bceafc 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -13,7 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import NetgearConfigEntry, NetgearDataCoordinator +from .coordinator import NetgearConfigEntry, NetgearTrackerCoordinator from .entity import NetgearRouterCoordinatorEntity from .router import NetgearRouter @@ -56,7 +56,7 @@ class NetgearRouterButtonEntity(NetgearRouterCoordinatorEntity, ButtonEntity): def __init__( self, - coordinator: NetgearDataCoordinator[bool], + coordinator: NetgearTrackerCoordinator, router: NetgearRouter, entity_description: NetgearButtonEntityDescription, ) -> None: diff --git a/homeassistant/components/netgear/coordinator.py b/homeassistant/components/netgear/coordinator.py index bc30e918c97b..9ee6b7b7342c 100644 --- a/homeassistant/components/netgear/coordinator.py +++ b/homeassistant/components/netgear/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta import logging @@ -16,7 +15,9 @@ from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL_FIRMWARE = timedelta(hours=5) +SPEED_TEST_INTERVAL = timedelta(hours=2) @dataclass @@ -24,12 +25,12 @@ class NetgearRuntimeData: """Runtime data for the Netgear integration.""" router: NetgearRouter - coordinator_tracker: NetgearDataCoordinator[bool] - coordinator_traffic: NetgearDataCoordinator[dict[str, Any] | None] - coordinator_speed: NetgearDataCoordinator[dict[str, Any] | None] + coordinator_tracker: NetgearTrackerCoordinator + coordinator_traffic: NetgearTrafficMeterCoordinator + coordinator_speed: NetgearSpeedTestCoordinator coordinator_firmware: NetgearFirmwareCoordinator - coordinator_utilization: NetgearDataCoordinator[dict[str, Any] | None] - coordinator_link: NetgearDataCoordinator[dict[str, Any] | None] + coordinator_utilization: NetgearUtilizationCoordinator + coordinator_link: NetgearLinkCoordinator type NetgearConfigEntry = ConfigEntry[NetgearRuntimeData] @@ -48,7 +49,6 @@ class NetgearDataCoordinator[T](DataUpdateCoordinator[T]): *, name: str, update_interval: timedelta, - update_method: Callable[[], Coroutine[Any, Any, T]] | None = None, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -57,11 +57,60 @@ class NetgearDataCoordinator[T](DataUpdateCoordinator[T]): config_entry=entry, name=f"{router.device_name} {name}", update_interval=update_interval, - update_method=update_method, ) self.router = router +class NetgearTrackerCoordinator(NetgearDataCoordinator[bool]): + """Coordinator for Netgear device tracking.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Devices", update_interval=SCAN_INTERVAL + ) + + async def _async_update_data(self) -> bool: + """Fetch data from the router.""" + if self.router.track_devices: + return await self.router.async_update_device_trackers() + return False + + +class NetgearTrafficMeterCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear traffic meter data.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Traffic meter", update_interval=SCAN_INTERVAL + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_traffic_meter() + + +class NetgearSpeedTestCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear speed test data.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Speed test", update_interval=SPEED_TEST_INTERVAL + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_speed_test() + + class NetgearFirmwareCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): """Coordinator for Netgear firmware updates.""" @@ -70,13 +119,45 @@ class NetgearFirmwareCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): ) -> None: """Initialize the coordinator.""" super().__init__( - hass, - router, - entry, - name="Firmware", - update_interval=SCAN_INTERVAL_FIRMWARE, + hass, router, entry, name="Firmware", update_interval=SCAN_INTERVAL_FIRMWARE ) async def _async_update_data(self) -> dict[str, Any] | None: """Check for new firmware of the router.""" return await self.router.async_check_new_firmware() + + +class NetgearUtilizationCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear utilization data.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Utilization", update_interval=SCAN_INTERVAL + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_utilization() + + +class NetgearLinkCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear Ethernet link status.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + router, + entry, + name="Ethernet Link Status", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_link_status() diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index 6e9df9618cde..e47964b82d36 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEVICE_ICONS -from .coordinator import NetgearConfigEntry, NetgearDataCoordinator +from .coordinator import NetgearConfigEntry, NetgearTrackerCoordinator from .entity import NetgearDeviceEntity from .router import NetgearRouter @@ -58,7 +58,7 @@ class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): def __init__( self, - coordinator: NetgearDataCoordinator[bool], + coordinator: NetgearTrackerCoordinator, router: NetgearRouter, device: dict, ) -> None: diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py index 67f52b3cc497..e56d507a72be 100644 --- a/homeassistant/components/netgear/entity.py +++ b/homeassistant/components/netgear/entity.py @@ -13,18 +13,18 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import NetgearDataCoordinator +from .coordinator import NetgearDataCoordinator, NetgearTrackerCoordinator from .router import NetgearRouter -class NetgearDeviceEntity(CoordinatorEntity): +class NetgearDeviceEntity(CoordinatorEntity[NetgearTrackerCoordinator]): """Base class for a device connected to a Netgear router.""" _attr_has_entity_name = True def __init__( self, - coordinator: NetgearDataCoordinator[Any], + coordinator: NetgearTrackerCoordinator, router: NetgearRouter, device: dict, ) -> None: diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index cc39be817773..e404c7621ba4 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -27,7 +27,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import NetgearConfigEntry, NetgearDataCoordinator +from .coordinator import ( + NetgearConfigEntry, + NetgearDataCoordinator, + NetgearTrackerCoordinator, +) from .entity import NetgearDeviceEntity, NetgearRouterCoordinatorEntity from .router import NetgearRouter @@ -325,7 +329,7 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): def __init__( self, - coordinator: NetgearDataCoordinator[Any], + coordinator: NetgearTrackerCoordinator, router: NetgearRouter, device: dict, attribute: str, diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index d9e0fdf8f29e..9b5127fd0ba0 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -13,7 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import NetgearConfigEntry, NetgearDataCoordinator +from .coordinator import NetgearConfigEntry, NetgearTrackerCoordinator from .entity import NetgearDeviceEntity, NetgearRouterEntity from .router import NetgearRouter @@ -149,7 +149,7 @@ class NetgearAllowBlock(NetgearDeviceEntity, SwitchEntity): def __init__( self, - coordinator: NetgearDataCoordinator[bool], + coordinator: NetgearTrackerCoordinator, router: NetgearRouter, device: dict, entity_description: SwitchEntityDescription, From 0e4698eb99fc410a213615c64c724e4c9e973d9e Mon Sep 17 00:00:00 2001 From: Glenn de Haan Date: Thu, 5 Mar 2026 11:50:37 +0100 Subject: [PATCH 18/19] Add device class to active_liter_lpm sensor (#164809) --- homeassistant/components/homewizard/sensor.py | 1 + .../homewizard/snapshots/test_sensor.ambr | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 6e53a1786161..3d15a34c7e7b 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -610,6 +610,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, has_fn=lambda data: data.measurement.active_liter_lpm is not None, value_fn=lambda data: data.measurement.active_liter_lpm, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 0bf33909a032..69f3395a8f6c 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -8007,8 +8007,11 @@ 'name': None, 'object_id_base': 'Water usage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage', 'platform': 'homewizard', @@ -8023,6 +8026,7 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', 'friendly_name': 'Device Water usage', 'state_class': , 'unit_of_measurement': , @@ -11927,8 +11931,11 @@ 'name': None, 'object_id_base': 'Water usage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage', 'platform': 'homewizard', @@ -11943,6 +11950,7 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', 'friendly_name': 'Device Water usage', 'state_class': , 'unit_of_measurement': , @@ -15408,8 +15416,11 @@ 'name': None, 'object_id_base': 'Water usage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage', 'platform': 'homewizard', @@ -15424,6 +15435,7 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', 'friendly_name': 'Device Water usage', 'state_class': , 'unit_of_measurement': , @@ -17573,8 +17585,11 @@ 'name': None, 'object_id_base': 'Water usage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage', 'platform': 'homewizard', @@ -17589,6 +17604,7 @@ # name: test_sensors[HWE-WTR-entity_ids4][sensor.device_water_usage:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', 'friendly_name': 'Device Water usage', 'state_class': , 'unit_of_measurement': , From 5fe2ab93ffa278c77400c9613024b61aa1a76605 Mon Sep 17 00:00:00 2001 From: Andreas Jakl Date: Thu, 5 Mar 2026 12:00:30 +0100 Subject: [PATCH 19/19] Add device tracker to NRGkick integration (#164804) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nrgkick/__init__.py | 1 + .../components/nrgkick/device_tracker.py | 74 ++++++++++++++++ .../components/nrgkick/diagnostics.py | 10 ++- homeassistant/components/nrgkick/icons.json | 5 ++ homeassistant/components/nrgkick/strings.json | 5 ++ tests/components/nrgkick/fixtures/info.json | 6 ++ .../snapshots/test_device_tracker.ambr | 54 ++++++++++++ .../nrgkick/snapshots/test_diagnostics.ambr | 6 ++ .../components/nrgkick/test_device_tracker.py | 86 +++++++++++++++++++ 9 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/nrgkick/device_tracker.py create mode 100644 tests/components/nrgkick/snapshots/test_device_tracker.ambr create mode 100644 tests/components/nrgkick/test_device_tracker.py diff --git a/homeassistant/components/nrgkick/__init__.py b/homeassistant/components/nrgkick/__init__.py index e246e165d46c..974a6ba0622d 100644 --- a/homeassistant/components/nrgkick/__init__.py +++ b/homeassistant/components/nrgkick/__init__.py @@ -12,6 +12,7 @@ from .coordinator import NRGkickConfigEntry, NRGkickDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/nrgkick/device_tracker.py b/homeassistant/components/nrgkick/device_tracker.py new file mode 100644 index 000000000000..5e995e5f35ce --- /dev/null +++ b/homeassistant/components/nrgkick/device_tracker.py @@ -0,0 +1,74 @@ +"""Device tracker platform for NRGkick.""" + +from __future__ import annotations + +from typing import Any, Final + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NRGkickConfigEntry, NRGkickDataUpdateCoordinator +from .entity import NRGkickEntity, get_nested_dict_value + +PARALLEL_UPDATES = 0 + +TRACKER_KEY: Final = "gps_tracker" + + +async def async_setup_entry( + _hass: HomeAssistant, + entry: NRGkickConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NRGkick device tracker based on a config entry.""" + coordinator = entry.runtime_data + + data = coordinator.data + assert data is not None + + info_data: dict[str, Any] = data.info + general_info: dict[str, Any] = info_data.get("general", {}) + model_type = general_info.get("model_type") + + # GPS module is only available on SIM-capable models (same check as cellular + # sensors). SIM-capable models include "SIM" in their model type string. + has_sim_module = isinstance(model_type, str) and "SIM" in model_type.upper() + + if has_sim_module: + async_add_entities([NRGkickDeviceTracker(coordinator)]) + + +class NRGkickDeviceTracker(NRGkickEntity, TrackerEntity): + """Representation of a NRGkick GPS device tracker.""" + + _attr_translation_key = TRACKER_KEY + _attr_source_type = SourceType.GPS + + def __init__( + self, + coordinator: NRGkickDataUpdateCoordinator, + ) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator, TRACKER_KEY) + + def _gps_float(self, key: str) -> float | None: + """Return a GPS value as float, or None if GPS data is unavailable.""" + value = get_nested_dict_value(self.coordinator.data.info, "gps", key) + return float(value) if value is not None else None + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self._gps_float("latitude") + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self._gps_float("longitude") + + @property + def location_accuracy(self) -> float: + """Return the location accuracy of the device.""" + return self._gps_float("accuracy") or 0.0 diff --git a/homeassistant/components/nrgkick/diagnostics.py b/homeassistant/components/nrgkick/diagnostics.py index cf6c1d6407eb..c9b9716a212e 100644 --- a/homeassistant/components/nrgkick/diagnostics.py +++ b/homeassistant/components/nrgkick/diagnostics.py @@ -6,12 +6,20 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from .coordinator import NRGkickConfigEntry TO_REDACT = { + ATTR_LATITUDE, + ATTR_LONGITUDE, + "altitude", CONF_PASSWORD, CONF_USERNAME, } diff --git a/homeassistant/components/nrgkick/icons.json b/homeassistant/components/nrgkick/icons.json index ff0022f20a8f..4b04a4de4f6c 100644 --- a/homeassistant/components/nrgkick/icons.json +++ b/homeassistant/components/nrgkick/icons.json @@ -5,6 +5,11 @@ "default": "mdi:ev-station" } }, + "device_tracker": { + "gps_tracker": { + "default": "mdi:map-marker" + } + }, "number": { "current_set": { "default": "mdi:current-ac" diff --git a/homeassistant/components/nrgkick/strings.json b/homeassistant/components/nrgkick/strings.json index 65a15cf07e23..3da169ec74f4 100644 --- a/homeassistant/components/nrgkick/strings.json +++ b/homeassistant/components/nrgkick/strings.json @@ -83,6 +83,11 @@ "name": "Charge permitted" } }, + "device_tracker": { + "gps_tracker": { + "name": "GPS tracker" + } + }, "number": { "current_set": { "name": "Charging current" diff --git a/tests/components/nrgkick/fixtures/info.json b/tests/components/nrgkick/fixtures/info.json index f5929e502c1b..0f71494922e5 100644 --- a/tests/components/nrgkick/fixtures/info.json +++ b/tests/components/nrgkick/fixtures/info.json @@ -13,6 +13,12 @@ "phase_count": 3 }, "cellular": { "mode": 3, "rssi": -85, "operator": "Test operator" }, + "gps": { + "latitude": 47.0748, + "longitude": 15.4376, + "altitude": 353.0, + "accuracy": 1.5 + }, "grid": { "voltage": 230, "frequency": 50.0, diff --git a/tests/components/nrgkick/snapshots/test_device_tracker.ambr b/tests/components/nrgkick/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..8bde76d47929 --- /dev/null +++ b/tests/components/nrgkick/snapshots/test_device_tracker.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_device_tracker_entities[device_tracker.nrgkick_test_gps_tracker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.nrgkick_test_gps_tracker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GPS tracker', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'GPS tracker', + 'platform': 'nrgkick', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gps_tracker', + 'unique_id': 'TEST123456_gps_tracker', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_entities[device_tracker.nrgkick_test_gps_tracker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NRGkick Test GPS tracker', + 'gps_accuracy': 1.5, + 'latitude': 47.0748, + 'longitude': 15.4376, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.nrgkick_test_gps_tracker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/nrgkick/snapshots/test_diagnostics.ambr b/tests/components/nrgkick/snapshots/test_diagnostics.ambr index 7d3e131df7b0..a27ec1ba83fa 100644 --- a/tests/components/nrgkick/snapshots/test_diagnostics.ambr +++ b/tests/components/nrgkick/snapshots/test_diagnostics.ambr @@ -27,6 +27,12 @@ 'rated_current': 32.0, 'serial_number': 'TEST123456', }), + 'gps': dict({ + 'accuracy': 1.5, + 'altitude': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), 'grid': dict({ 'frequency': 50.0, 'phases': 7, diff --git a/tests/components/nrgkick/test_device_tracker.py b/tests/components/nrgkick/test_device_tracker.py new file mode 100644 index 000000000000..8a0389dacda5 --- /dev/null +++ b/tests/components/nrgkick/test_device_tracker.py @@ -0,0 +1,86 @@ +"""Tests for the NRGkick device tracker platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_NOT_HOME, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.usefixtures("entity_registry_enabled_by_default") + + +async def test_device_tracker_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device tracker entities.""" + await setup_integration( + hass, mock_config_entry, platforms=[Platform.DEVICE_TRACKER] + ) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_device_tracker_not_created_without_sim_module( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, +) -> None: + """Test that the device tracker is not created for non-SIM models.""" + mock_nrgkick_api.get_info.return_value["general"]["model_type"] = "NRGkick Gen2" + + await setup_integration( + hass, mock_config_entry, platforms=[Platform.DEVICE_TRACKER] + ) + + assert hass.states.get("device_tracker.nrgkick_test_gps_tracker") is None + + +async def test_device_tracker_no_gps_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, +) -> None: + """Test device tracker when GPS data is not available.""" + mock_nrgkick_api.get_info.return_value.pop("gps", None) + + await setup_integration( + hass, mock_config_entry, platforms=[Platform.DEVICE_TRACKER] + ) + + state = hass.states.get("device_tracker.nrgkick_test_gps_tracker") + assert state is not None + assert state.state == STATE_UNKNOWN + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + + +async def test_device_tracker_with_gps_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, +) -> None: + """Test device tracker with valid GPS coordinates.""" + await setup_integration( + hass, mock_config_entry, platforms=[Platform.DEVICE_TRACKER] + ) + + state = hass.states.get("device_tracker.nrgkick_test_gps_tracker") + assert state is not None + assert state.state == STATE_NOT_HOME + assert state.attributes["latitude"] == 47.0748 + assert state.attributes["longitude"] == 15.4376 + assert state.attributes["gps_accuracy"] == 1.5 + assert state.attributes["source_type"] == "gps"