From b7fd1276aa01d857ed71d48df24354e12cc138f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Feb 2026 16:32:11 +0100 Subject: [PATCH 01/17] Roborock: Q7 Model Split and Refactor (#163769) Co-authored-by: Luke Lashley Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/roborock/__init__.py | 8 ++++---- homeassistant/components/roborock/coordinator.py | 6 +++--- homeassistant/components/roborock/entity.py | 10 +++++----- homeassistant/components/roborock/select.py | 7 +++---- homeassistant/components/roborock/sensor.py | 14 +++++++------- homeassistant/components/roborock/vacuum.py | 9 ++++----- tests/components/roborock/test_vacuum.py | 4 ++-- 7 files changed, 28 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index b293620424d74..4dc2697a1d040 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -144,18 +144,18 @@ async def shutdown_roborock(_: Event | None = None) -> None: for coord in coordinators if isinstance(coord, RoborockDataUpdateCoordinatorA01) ] - b01_coords = [ + b01_q7_coords = [ coord for coord in coordinators - if isinstance(coord, RoborockDataUpdateCoordinatorB01) + if isinstance(coord, RoborockB01Q7UpdateCoordinator) ] - if len(v1_coords) + len(a01_coords) + len(b01_coords) == 0: + if len(v1_coords) + len(a01_coords) + len(b01_q7_coords) == 0: raise ConfigEntryNotReady( "No devices were able to successfully setup", translation_domain=DOMAIN, translation_key="no_coordinators", ) - entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords, b01_coords) + entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords, b01_q7_coords) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6100d997d63ce..c156eaa0f5347 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -64,17 +64,17 @@ class RoborockCoordinators: v1: list[RoborockDataUpdateCoordinator] a01: list[RoborockDataUpdateCoordinatorA01] - b01: list[RoborockDataUpdateCoordinatorB01] + b01_q7: list[RoborockB01Q7UpdateCoordinator] def values( self, ) -> list[ RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 - | RoborockDataUpdateCoordinatorB01 + | RoborockB01Q7UpdateCoordinator ]: """Return all coordinators.""" - return self.v1 + self.a01 + self.b01 + return self.v1 + self.a01 + self.b01_q7 type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index 2dea15e1e96d9..bb2c22195fb9e 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -14,9 +14,9 @@ from .const import DOMAIN from .coordinator import ( + RoborockB01Q7UpdateCoordinator, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, - RoborockDataUpdateCoordinatorB01, ) @@ -130,21 +130,21 @@ def __init__( self._attr_unique_id = unique_id -class RoborockCoordinatedEntityB01( - RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinatorB01] +class RoborockCoordinatedEntityB01Q7( + RoborockEntity, CoordinatorEntity[RoborockB01Q7UpdateCoordinator] ): """Representation of coordinated Roborock Entity.""" def __init__( self, unique_id: str, - coordinator: RoborockDataUpdateCoordinatorB01, + coordinator: RoborockB01Q7UpdateCoordinator, ) -> None: """Initialize the coordinated Roborock Device.""" + CoordinatorEntity.__init__(self, coordinator=coordinator) RoborockEntity.__init__( self, unique_id=unique_id, device_info=coordinator.device_info, ) - CoordinatorEntity.__init__(self, coordinator=coordinator) self._attr_unique_id = unique_id diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 341dea0b267ef..cc22d016fd7fc 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -26,7 +26,7 @@ RoborockConfigEntry, RoborockDataUpdateCoordinator, ) -from .entity import RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1 PARALLEL_UPDATES = 0 @@ -159,14 +159,13 @@ async def async_setup_entry( ) async_add_entities( RoborockB01SelectEntity(coordinator, description, options) - for coordinator in config_entry.runtime_data.b01 + for coordinator in config_entry.runtime_data.b01_q7 for description in B01_SELECT_DESCRIPTIONS - if isinstance(coordinator, RoborockB01Q7UpdateCoordinator) if (options := description.options_lambda(coordinator.api)) is not None ) -class RoborockB01SelectEntity(RoborockCoordinatedEntityB01, SelectEntity): +class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity): """Select entity for Roborock B01 devices.""" entity_description: RoborockB01SelectDescription diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 0b05996cf8c6a..9b2cc3ad51384 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -33,14 +33,14 @@ from homeassistant.helpers.typing import StateType from .coordinator import ( + RoborockB01Q7UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, - RoborockDataUpdateCoordinatorB01, ) from .entity import ( RoborockCoordinatedEntityA01, - RoborockCoordinatedEntityB01, + RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1, RoborockEntity, ) @@ -422,8 +422,8 @@ async def async_setup_entry( if description.data_protocol in coordinator.request_protocols ) entities.extend( - RoborockSensorEntityB01(coordinator, description) - for coordinator in coordinators.b01 + RoborockSensorEntityB01Q7(coordinator, description) + for coordinator in coordinators.b01_q7 for description in Q7_B01_SENSOR_DESCRIPTIONS if description.value_fn(coordinator.data) is not None ) @@ -515,14 +515,14 @@ def native_value(self) -> StateType: return self.coordinator.data[self.entity_description.data_protocol] -class RoborockSensorEntityB01(RoborockCoordinatedEntityB01, SensorEntity): - """Representation of a B01 Roborock sensor.""" +class RoborockSensorEntityB01Q7(RoborockCoordinatedEntityB01Q7, SensorEntity): + """Representation of a B01 Q7 Roborock sensor.""" entity_description: RoborockSensorDescriptionB01 def __init__( self, - coordinator: RoborockDataUpdateCoordinatorB01, + coordinator: RoborockB01Q7UpdateCoordinator, description: RoborockSensorDescriptionB01, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 0f4429a5ee3a9..a60bee258811c 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -24,7 +24,7 @@ RoborockConfigEntry, RoborockDataUpdateCoordinator, ) -from .entity import RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1 _LOGGER = logging.getLogger(__name__) @@ -84,8 +84,7 @@ async def async_setup_entry( ) async_add_entities( RoborockQ7Vacuum(coordinator) - for coordinator in config_entry.runtime_data.b01 - if isinstance(coordinator, RoborockB01Q7UpdateCoordinator) + for coordinator in config_entry.runtime_data.b01_q7 ) @@ -303,7 +302,7 @@ async def get_vacuum_current_position(self) -> ServiceResponse: } -class RoborockQ7Vacuum(RoborockCoordinatedEntityB01, StateVacuumEntity): +class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity): """General Representation of a Roborock vacuum.""" _attr_icon = "mdi:robot-vacuum" @@ -327,7 +326,7 @@ def __init__( ) -> None: """Initialize a vacuum.""" StateVacuumEntity.__init__(self) - RoborockCoordinatedEntityB01.__init__( + RoborockCoordinatedEntityB01Q7.__init__( self, coordinator.duid_slug, coordinator, diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 79cdfa15fdda9..0aeeae8717a85 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -609,7 +609,7 @@ async def test_q7_state_changing_commands( # Verify the entity state was updated assert fake_q7_vacuum.b01_q7_properties is not None # Force coordinator refresh to get updated state - coordinator = setup_entry.runtime_data.b01[0] + coordinator = setup_entry.runtime_data.b01_q7[0] await coordinator.async_refresh() await hass.async_block_till_done() @@ -735,7 +735,7 @@ async def test_q7_activity_none_status( fake_q7_vacuum.b01_q7_properties._props_data.status = None # Force coordinator refresh to get updated state - coordinator = setup_entry.runtime_data.b01[0] + coordinator = setup_entry.runtime_data.b01_q7[0] await coordinator.async_refresh() await hass.async_block_till_done() From 8c41e21b7fdbf26c45025156302477fd23e8a721 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 22 Feb 2026 10:44:29 -0500 Subject: [PATCH 02/17] Bump python-robroock to 4.17.1 (#163765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ --- homeassistant/components/roborock/entity.py | 4 ++-- .../components/roborock/manifest.json | 2 +- homeassistant/components/roborock/models.py | 4 ++-- homeassistant/components/roborock/select.py | 20 ++++++++++------ .../components/roborock/strings.json | 4 ++++ homeassistant/components/roborock/vacuum.py | 10 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/conftest.py | 23 ++++++++++++++++++- 9 files changed, 53 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index bb2c22195fb9e..0f780dd9d8129 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -2,8 +2,8 @@ from typing import Any -from roborock.data import Status from roborock.devices.traits.v1.command import CommandTrait +from roborock.devices.traits.v1.status import StatusTrait from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand @@ -94,7 +94,7 @@ def __init__( self._attr_unique_id = unique_id @property - def _device_status(self) -> Status: + def _device_status(self) -> StatusTrait: """Return the status of the device.""" data = self.coordinator.data return data.status diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index f84a22a2d08d3..a17f9e5c7dc8e 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==4.15.0", + "python-roborock==4.17.1", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 6715e370a5d6f..4da759ede2bbc 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -12,8 +12,8 @@ HomeDataDevice, HomeDataProduct, NetworkInfo, - Status, ) +from roborock.devices.traits.v1.status import StatusTrait from vacuum_map_parser_base.map_data import MapData _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ class DeviceState: """Data about the current state of a device.""" - status: Status + status: StatusTrait dnd_timer: DnDTimer consumable: Consumable clean_summary: CleanSummaryWithDetail diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index cc22d016fd7fc..b63217c0e4384 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -92,25 +92,31 @@ class RoborockB01SelectDescription(SelectEntityDescription): key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda api: api.status.water_box_mode_name, + value_fn=lambda api: api.status.water_mode_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( - api.status.water_box_mode.keys() - if api.status.water_box_mode is not None + [mode.value for mode in api.status.water_mode_options] + if api.status.water_mode_options else None ), - parameter_lambda=lambda key, api: [api.status.get_mop_intensity_code(key)], + parameter_lambda=lambda key, api: [ + {v: k for k, v in api.status.water_mode_mapping.items()}[key] + ], ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda api: api.status.mop_mode_name, + value_fn=lambda api: api.status.mop_route_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( - api.status.mop_mode.keys() if api.status.mop_mode is not None else None + [mode.value for mode in api.status.mop_route_options] + if api.status.mop_route_options + else None ), - parameter_lambda=lambda key, api: [api.status.get_mop_mode_code(key)], + parameter_lambda=lambda key, api: [ + {v: k for k, v in api.status.mop_route_mapping.items()}[key] + ], ), RoborockSelectDescription( key="dust_collection_mode", diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index a40178670b8a5..7609ec9cf4290 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -118,9 +118,12 @@ "max": "Max", "medium": "[%key:common::state::medium%]", "mild": "Mild", + "min": "Min", "moderate": "Moderate", "off": "[%key:common::state::off%]", + "slight": "Slight", "smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]", + "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", "vac_followed_by_mop": "Vacuum followed by mop" } }, @@ -448,6 +451,7 @@ "max_plus": "Max plus", "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]", + "off_raise_main_brush": "Off (raised brush)", "quiet": "Quiet", "silent": "Silent", "smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]", diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index a60bee258811c..9b95e7f28bd10 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -124,7 +124,7 @@ def __init__( @property def fan_speed_list(self) -> list[str]: """Get the list of available fan speeds.""" - return self._device_status.fan_power_options + return [mode.value for mode in self._device_status.fan_speed_options] @property def activity(self) -> VacuumActivity | None: @@ -135,7 +135,7 @@ def activity(self) -> VacuumActivity | None: @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self._device_status.fan_power_name + return self._device_status.fan_speed_name async def async_start(self) -> None: """Start the vacuum.""" @@ -174,7 +174,11 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set vacuum fan speed.""" await self.send( RoborockCommand.SET_CUSTOM_MODE, - [self._device_status.get_fan_speed_code(fan_speed)], + [ + {v: k for k, v in self._device_status.fan_speed_mapping.items()}[ + fan_speed + ] + ], ) async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 239f949d95b08..c7470902aef1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2627,7 +2627,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==4.15.0 +python-roborock==4.17.1 # homeassistant.components.smarttub python-smarttub==0.0.47 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a399c7a47eea..2498c0a4b4ffa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2220,7 +2220,7 @@ python-pooldose==0.8.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==4.15.0 +python-roborock==4.17.1 # homeassistant.components.smarttub python-smarttub==0.0.47 diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 7e3655782d4f2..cf2c499a7cac4 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -10,7 +10,14 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest -from roborock import HomeDataRoom, MultiMapsListMapInfo, RoborockCategory +from roborock import ( + CleanRoutes, + HomeDataRoom, + MultiMapsListMapInfo, + RoborockCategory, + VacuumModes, + WaterModes, +) from roborock.data import ( CombinedMapInfo, DnDTimer, @@ -307,6 +314,20 @@ def create_v1_properties(network_info: NetworkInfo) -> AsyncMock: trait_spec=StatusTrait, dataclass_template=STATUS, ) + _fan_speed_mapping = {m.code: m.value for m in VacuumModes} + _water_mode_mapping = {m.code: m.value for m in WaterModes} + _mop_route_mapping = {m.code: m.value for m in CleanRoutes} + v1_properties.status.fan_speed_options = list(VacuumModes) + v1_properties.status.fan_speed_mapping = _fan_speed_mapping + v1_properties.status.fan_speed_name = _fan_speed_mapping.get(STATUS.fan_power) + v1_properties.status.water_mode_options = list(WaterModes) + v1_properties.status.water_mode_mapping = _water_mode_mapping + v1_properties.status.water_mode_name = _water_mode_mapping.get( + STATUS.water_box_mode + ) + v1_properties.status.mop_route_options = list(CleanRoutes) + v1_properties.status.mop_route_mapping = _mop_route_mapping + v1_properties.status.mop_route_name = _mop_route_mapping.get(STATUS.mop_mode) v1_properties.dnd = make_dnd_timer(dataclass_template=DND_TIMER) v1_properties.clean_summary = make_mock_trait( trait_spec=CleanSummaryTrait, From e1fd60aa18b80e0aecea601cd3ee50c473f5e7f2 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 22 Feb 2026 16:04:46 +0000 Subject: [PATCH 03/17] Bump systembridgeconnector to 5.4.3 (#163784) --- homeassistant/components/system_bridge/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 5243083e6dc82..4b4289c3cb912 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -9,6 +9,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["systembridgeconnector"], - "requirements": ["systembridgeconnector==5.3.1"], + "requirements": ["systembridgeconnector==5.4.3"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c7470902aef1b..fa00393e7796e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3014,7 +3014,7 @@ switchbot-api==2.10.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==5.3.1 +systembridgeconnector==5.4.3 # homeassistant.components.tailscale tailscale==0.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2498c0a4b4ffa..a966fa131da06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ surepy==0.9.0 switchbot-api==2.10.0 # homeassistant.components.system_bridge -systembridgeconnector==5.3.1 +systembridgeconnector==5.4.3 # homeassistant.components.tailscale tailscale==0.6.2 From 00e441b90d101710fa986017d8649e329272520e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:05:20 +0100 Subject: [PATCH 04/17] Update pylint to 4.0.5 (#163777) --- homeassistant/components/tplink_omada/coordinator.py | 2 +- requirements_test.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 956d53558096f..8191a47c6c775 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -159,7 +159,7 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-class-module +class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): """Coordinator for getting details about available firmware updates for Omada devices.""" def __init__( diff --git a/requirements_test.txt b/requirements_test.txt index da814b919ea5a..dd7c0aa4adfdb 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==4.0.1 +astroid==4.0.4 coverage==7.10.6 freezegun==1.5.2 # librt is an internal mypy dependency @@ -17,7 +17,7 @@ mock-open==1.4.0 mypy==1.19.1 prek==0.2.28 pydantic==2.12.2 -pylint==4.0.1 +pylint==4.0.5 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 pytest-asyncio==1.3.0 From d04fb59d568f2b07a62b61e3ec66c1484ae8dc83 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:05:45 +0100 Subject: [PATCH 05/17] Update sqlparse to 0.5.5 (#163774) --- homeassistant/components/sql/manifest.json | 2 +- homeassistant/components/sql/util.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 244334565657e..44ee32ec8e8c6 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.41", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.41", "sqlparse==0.5.5"] } diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 636065e404e7d..7433462f125a8 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -261,7 +261,7 @@ def check_and_render_sql_query(hass: HomeAssistant, query: Template | str) -> st raise MultipleQueryError("Multiple SQL statements are not allowed") if ( len(rendered_queries) == 0 - or (query_type := rendered_queries[0].get_type()) == "UNKNOWN" + or (query_type := rendered_queries[0].get_type()) == "UNKNOWN" # type: ignore[no-untyped-call] ): raise UnknownQueryTypeError("SQL query is empty or unknown type") if query_type != "SELECT": diff --git a/requirements_all.txt b/requirements_all.txt index fa00393e7796e..42510a37e913c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2969,7 +2969,7 @@ speedtest-cli==2.1.3 spotifyaio==1.0.0 # homeassistant.components.sql -sqlparse==0.5.0 +sqlparse==0.5.5 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a966fa131da06..10efa00a56c80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2502,7 +2502,7 @@ speedtest-cli==2.1.3 spotifyaio==1.0.0 # homeassistant.components.sql -sqlparse==0.5.0 +sqlparse==0.5.5 # homeassistant.components.srp_energy srpenergy==1.3.6 From d767a1ca6575ed6af74bb6dcccea595fbf7a91a9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:06:08 +0100 Subject: [PATCH 06/17] Update pillow to 12.1.1 (#163773) --- homeassistant/components/cloud/ai_task.py | 1 + homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 14 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/cloud/ai_task.py b/homeassistant/components/cloud/ai_task.py index a92060db7b14b..7123b5cd32b9f 100644 --- a/homeassistant/components/cloud/ai_task.py +++ b/homeassistant/components/cloud/ai_task.py @@ -31,6 +31,7 @@ def _convert_image_for_editing(data: bytes) -> tuple[bytes, str]: """Ensure the image data is in a format accepted by OpenAI image edits.""" + img: Image.Image stream = io.BytesIO(data) with Image.open(stream) as img: mode = img.mode diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index f4bfc61560822..6505f63d36395 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==12.0.0"] + "requirements": ["pydoods==1.0.2", "Pillow==12.1.1"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 33e1afeb18f79..b6d354b6f605d 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==16.0.1", "Pillow==12.0.0"] + "requirements": ["av==16.0.1", "Pillow==12.1.1"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index a37ab4c010a03..394e1871d2991 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==12.0.0"] + "requirements": ["Pillow==12.1.1"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 3e0baa5e1be6a..2ad943a84903b 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0", "aiofiles==24.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==12.1.1", "aiofiles==24.1.0"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index c586df030c13d..dfdb172f6755d 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0"] + "requirements": ["Pillow==12.1.1"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 6cc68e531514e..25cce8f09c4e3 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==12.1.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 1aa2b4fea69e8..745b96bb2eb4e 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0"] + "requirements": ["Pillow==12.1.1"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 596e9c1751a84..64ba7361aeb66 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0", "simplehound==0.3"] + "requirements": ["Pillow==12.1.1", "simplehound==0.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4607f0cca0dd7..6e45cb30c21c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -51,7 +51,7 @@ openai==2.21.0 orjson==3.11.5 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==12.0.0 +Pillow==12.1.1 propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 diff --git a/pyproject.toml b/pyproject.toml index fd4d2cf1e13b4..1b97a7e7faa35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ dependencies = [ "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==46.0.5", - "Pillow==12.0.0", + "Pillow==12.1.1", "propcache==0.4.1", "pyOpenSSL==25.3.0", "orjson==3.11.5", diff --git a/requirements.txt b/requirements.txt index ab83f697a8255..001c32437edc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ lru-dict==1.3.0 mutagen==1.47.0 orjson==3.11.5 packaging>=23.1 -Pillow==12.0.0 +Pillow==12.1.1 propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 diff --git a/requirements_all.txt b/requirements_all.txt index 42510a37e913c..0593cf24d99ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -38,7 +38,7 @@ PSNAWP==3.0.1 # homeassistant.components.qrcode # homeassistant.components.seven_segments # homeassistant.components.sighthound -Pillow==12.0.0 +Pillow==12.1.1 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10efa00a56c80..735117a278caf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -38,7 +38,7 @@ PSNAWP==3.0.1 # homeassistant.components.qrcode # homeassistant.components.seven_segments # homeassistant.components.sighthound -Pillow==12.0.0 +Pillow==12.1.1 # homeassistant.components.plex PlexAPI==4.15.16 From a312f9f5bc0889823e9952bf3cce283f544dda56 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:08:42 +0100 Subject: [PATCH 07/17] Improve type hints in lights (#163792) --- homeassistant/components/blebox/light.py | 2 +- homeassistant/components/control4/light.py | 2 +- homeassistant/components/decora_wifi/light.py | 2 +- homeassistant/components/eufy/light.py | 4 ++-- homeassistant/components/iglo/light.py | 8 ++++---- homeassistant/components/insteon/light.py | 2 +- homeassistant/components/osramlightify/light.py | 2 +- homeassistant/components/pilight/light.py | 2 +- homeassistant/components/qwikswitch/light.py | 2 +- homeassistant/components/rflink/light.py | 2 +- homeassistant/components/sisyphus/light.py | 2 +- homeassistant/components/smarttub/light.py | 6 +++--- homeassistant/components/tellduslive/light.py | 2 +- homeassistant/components/tellstick/light.py | 2 +- homeassistant/components/xiaomi_aqara/light.py | 4 ++-- homeassistant/components/xiaomi_miio/light.py | 6 +++--- 16 files changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 75900ca7d97ba..4db64d998f53f 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -74,7 +74,7 @@ def is_on(self) -> bool: return self._feature.is_on @property - def brightness(self): + def brightness(self) -> int | None: """Return the name.""" return self._feature.brightness diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 2b4d6e7b45ea1..2e9528063d130 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -199,7 +199,7 @@ def is_on(self) -> bool: return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" if self._is_dimmer: for var in CONTROL4_DIMMER_VARS: diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index cf17b61341668..4ec9a1e4246da 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -132,7 +132,7 @@ def unique_id(self): return self._switch.serial @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the dimmer switch.""" return int(self._switch.brightness * 255 / 100) diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index d782dadba6cc0..48ba97c01df5b 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -75,7 +75,7 @@ def update(self) -> None: self._attr_is_on = self._bulb.power @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(self._brightness * 255 / 100) @@ -88,7 +88,7 @@ def color_temp_kelvin(self) -> int: ) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float] | None: """Return the color of this light.""" return self._hs diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index 1989bcd8eccdb..3fb09f0eac62b 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -68,7 +68,7 @@ def name(self): return self._name @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int((self._lamp.state()["brightness"] / 200.0) * 255) @@ -97,17 +97,17 @@ def min_color_temp_kelvin(self) -> int: return self._lamp.min_kelvin @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hs value.""" return color_util.color_RGB_to_hs(*self._lamp.state()["rgb"]) @property - def effect(self): + def effect(self) -> str: """Return the current effect.""" return self._lamp.state()["effect"] @property - def effect_list(self): + def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._lamp.effect_list() diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index e4f09fe56894d..c617f7c55926d 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -61,7 +61,7 @@ def __init__(self, device: InsteonDevice, group: int) -> None: self._attr_supported_color_modes = {ColorMode.ONOFF} @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._insteon_device_group.value diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index a55ed36518c1a..8dad03d4bba22 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -244,7 +244,7 @@ def name(self): return self._luminary.name() @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return last hs color value set.""" return color_util.color_RGB_to_hs(*self._rgb_color) diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index 9e1ecbf59d463..dd10cb1226636 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -62,7 +62,7 @@ def __init__(self, hass, name, config): self._dimlevel_max = config.get(CONF_DIMLEVEL_MAX) @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness.""" return self._brightness diff --git a/homeassistant/components/qwikswitch/light.py b/homeassistant/components/qwikswitch/light.py index 0f91faeedc8fd..9de959d700975 100644 --- a/homeassistant/components/qwikswitch/light.py +++ b/homeassistant/components/qwikswitch/light.py @@ -30,7 +30,7 @@ class QSLight(QSToggleEntity, LightEntity): """Light based on a Qwikswitch relay/dimmer module.""" @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light (0-255).""" return self.device.value if self.device.is_dimmer else None diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 7eb53433d881f..24bbf06c04967 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -226,7 +226,7 @@ def _handle_event(self, event): self._state = True @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._brightness diff --git a/homeassistant/components/sisyphus/light.py b/homeassistant/components/sisyphus/light.py index 9a649c0b64547..c89d8d11d5421 100644 --- a/homeassistant/components/sisyphus/light.py +++ b/homeassistant/components/sisyphus/light.py @@ -78,7 +78,7 @@ def is_on(self) -> bool: return not self._table.is_sleeping @property - def brightness(self): + def brightness(self) -> int: """Return the current brightness of the table's ring light.""" return self._table.brightness * 255 diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index 0c58460640d5b..42c644fddd40e 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -65,7 +65,7 @@ def light(self) -> SpaLight: return self.coordinator.data[self.spa.id][ATTR_LIGHTS][self.light_zone] @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" # SmartTub intensity is 0..100 @@ -87,7 +87,7 @@ def is_on(self) -> bool: return self.light.mode != SpaLight.LightMode.OFF @property - def effect(self): + def effect(self) -> str | None: """Return the current effect.""" mode = self.light.mode.name.lower() if mode in self.effect_list: @@ -95,7 +95,7 @@ def effect(self): return None @property - def effect_list(self): + def effect_list(self) -> list[str]: """Return the list of supported effects.""" return [ effect diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 4a3c14b141b3a..86fdb4d1d64de 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -53,7 +53,7 @@ def changed(self): self.schedule_update_ha_state() @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self.device.dim_level diff --git a/homeassistant/components/tellstick/light.py b/homeassistant/components/tellstick/light.py index 72ff8e4df0571..4b335f6955866 100644 --- a/homeassistant/components/tellstick/light.py +++ b/homeassistant/components/tellstick/light.py @@ -52,7 +52,7 @@ def __init__(self, tellcore_device, signal_repetitions): self._brightness = 255 @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._brightness diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 47b9e5a673058..585ab39ba6bd1 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -91,12 +91,12 @@ def parse_data(self, data, raw_data): return True @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(255 * self._brightness / 100) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hs color value.""" return self._hs diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 0ff6df93d3e73..4c08dae6f525b 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -1041,12 +1041,12 @@ def device_info(self) -> DeviceInfo: ) @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(255 * self._brightness_pct / 100) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hs color value.""" return self._hs @@ -1102,7 +1102,7 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): _sub_device: LightBulb @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" return round((self._sub_device.status["brightness"] * 255) / 100) From 9f25b4702d291988a8cf17eeadcdb2354a37aa45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Feb 2026 17:09:49 +0100 Subject: [PATCH 08/17] Remove CumulativeEnergyExported in fixtures where not needed (#163775) --- .../fixtures/nodes/eve_energy_plug_patched.json | 13 ++++++------- .../matter/fixtures/nodes/silabs_water_heater.json | 3 +-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/components/matter/fixtures/nodes/eve_energy_plug_patched.json b/tests/components/matter/fixtures/nodes/eve_energy_plug_patched.json index 18c4a8c68efcd..a70e2ef8ace27 100644 --- a/tests/components/matter/fixtures/nodes/eve_energy_plug_patched.json +++ b/tests/components/matter/fixtures/nodes/eve_energy_plug_patched.json @@ -380,16 +380,15 @@ } ] }, - "2/145/65533": 1, - "2/145/65532": 7, - "2/145/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], - "2/145/65530": [0], - "2/145/65529": [], - "2/145/65528": [], "2/145/1": { "0": 2500 }, - "2/145/2": null + "2/145/65533": 1, + "2/145/65532": 5, + "2/145/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "2/145/65530": [0], + "2/145/65529": [], + "2/145/65528": [] }, "attribute_subscriptions": [], "last_subscription_attempt": 0 diff --git a/tests/components/matter/fixtures/nodes/silabs_water_heater.json b/tests/components/matter/fixtures/nodes/silabs_water_heater.json index 2fe9b9e09b6ed..f0b7b549517c7 100644 --- a/tests/components/matter/fixtures/nodes/silabs_water_heater.json +++ b/tests/components/matter/fixtures/nodes/silabs_water_heater.json @@ -360,7 +360,6 @@ ] }, "2/145/1": null, - "2/145/2": null, "2/145/3": null, "2/145/4": null, "2/145/5": { @@ -373,7 +372,7 @@ "2/145/65533": 1, "2/145/65528": [], "2/145/65529": [], - "2/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/145/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], "2/148/0": 1, "2/148/1": 0, "2/148/2": 200, From 49f7c246014bb3e0c53ebd85d957e751464adef8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 22 Feb 2026 17:10:27 +0100 Subject: [PATCH 09/17] Replace "add-on" with "app" in `homeassistant_yellow` (#163715) --- homeassistant/components/homeassistant_yellow/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index ed74b5f07af23..aacf51da97d4d 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -25,7 +25,7 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "read_hw_settings_error": "Failed to read hardware settings", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device.", "write_hw_settings_error": "Failed to write hardware settings", "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]" From 309b4397444832585c4cb21ae32d90b6de036ef3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 22 Feb 2026 17:11:00 +0100 Subject: [PATCH 10/17] Replace "add-on" with "app" in `recorder` (#163714) --- homeassistant/components/recorder/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index d830c5bd304f4..3528683631863 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -5,7 +5,7 @@ "title": "Database backup failed due to lack of resources" }, "maria_db_range_index_regression": { - "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version.", + "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB Core app, make sure to update it to the latest version.", "title": "Update MariaDB to {min_version} or later resolve a significant performance issue" } }, From 15d0241158677f7174d9e37b8f418581fe979bc7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 22 Feb 2026 17:12:27 +0100 Subject: [PATCH 11/17] Replace "add-on" with "app" in `zwave_me` (user-facing strings only) (#163703) --- homeassistant/components/zwave_me/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_me/strings.json b/homeassistant/components/zwave_me/strings.json index 3b7e1033c09ba..28bb59419583d 100644 --- a/homeassistant/components/zwave_me/strings.json +++ b/homeassistant/components/zwave_me/strings.json @@ -13,7 +13,7 @@ "token": "[%key:common::config_flow::data::api_token%]", "url": "[%key:common::config_flow::data::url%]" }, - "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an add-on:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log in to Z-Way via find.z-wave.me for this)." + "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an app:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log in to Z-Way via find.z-wave.me for this)." } } } From 11edd214a18be8526f5f3ea99b04b277d64a5465 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:13:14 +0100 Subject: [PATCH 12/17] Improve type hints in igloohome lock (#163795) --- homeassistant/components/igloohome/lock.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/igloohome/lock.py b/homeassistant/components/igloohome/lock.py index b434c055145ad..dc79bba9c6335 100644 --- a/homeassistant/components/igloohome/lock.py +++ b/homeassistant/components/igloohome/lock.py @@ -1,6 +1,7 @@ """Implementation of the lock platform.""" from datetime import timedelta +from typing import Any from aiohttp import ClientError from igloohome_api import ( @@ -63,7 +64,7 @@ def __init__( ) self.bridge_id = bridge_id - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock this lock.""" try: await self.api.create_bridge_proxied_job( @@ -72,7 +73,7 @@ async def async_lock(self, **kwargs): except (ApiException, ClientError) as err: raise HomeAssistantError from err - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock this lock.""" try: await self.api.create_bridge_proxied_job( @@ -81,7 +82,7 @@ async def async_unlock(self, **kwargs): except (ApiException, ClientError) as err: raise HomeAssistantError from err - async def async_open(self, **kwargs): + async def async_open(self, **kwargs: Any) -> None: """Open (unlatch) this lock.""" try: await self.api.create_bridge_proxied_job( From b5d8c1e89338b82c7341773da2a476f6d6599e97 Mon Sep 17 00:00:00 2001 From: Harry Heymann Date: Sun, 22 Feb 2026 11:47:59 -0500 Subject: [PATCH 13/17] Require product_id for Inovelli LED intensity Matter Number entities (#163680) --- homeassistant/components/matter/number.py | 2 + .../matter/snapshots/test_number.ambr | 116 ------------------ 2 files changed, 2 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 3820c30312619..b9e47a83474f2 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -498,6 +498,7 @@ def _update_from_device(self) -> None: required_attributes=( custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOff, ), + product_id=(2, 16), ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -514,6 +515,7 @@ def _update_from_device(self) -> None: required_attributes=( custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOn, ), + product_id=(2, 16), ), MatterDiscoverySchema( platform=Platform.NUMBER, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index efbe4bcdf7f9a..f75a5d158344a 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -1176,122 +1176,6 @@ 'state': 'unknown', }) # --- -# name: test_numbers[inovelli_vtm31][number.inovelli_led_off_intensity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 75, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.inovelli_led_off_intensity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'LED off intensity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'LED off intensity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'led_indicator_intensity_off', - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOff-305134641-305070178', - 'unit_of_measurement': None, - }) -# --- -# name: test_numbers[inovelli_vtm31][number.inovelli_led_off_intensity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Inovelli LED off intensity', - 'max': 75, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'context': , - 'entity_id': 'number.inovelli_led_off_intensity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_numbers[inovelli_vtm31][number.inovelli_led_on_intensity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 75, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.inovelli_led_on_intensity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'LED on intensity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'LED on intensity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'led_indicator_intensity_on', - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOn-305134641-305070177', - 'unit_of_measurement': None, - }) -# --- -# name: test_numbers[inovelli_vtm31][number.inovelli_led_on_intensity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Inovelli LED on intensity', - 'max': 75, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'context': , - 'entity_id': 'number.inovelli_led_on_intensity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '33', - }) -# --- # name: test_numbers[inovelli_vtm31][number.inovelli_off_transition_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 383f9c203d6b0fc0ef0cfb0a9357eb9930758b28 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:48:22 +0100 Subject: [PATCH 14/17] Unifiprotect ptz support (#161353) Co-authored-by: RaHehl Co-authored-by: J. Nick Koston --- .../components/unifiprotect/__init__.py | 3 + homeassistant/components/unifiprotect/data.py | 38 ++- .../components/unifiprotect/icons.json | 6 + .../components/unifiprotect/select.py | 93 +++++++- .../components/unifiprotect/services.py | 71 ++++++ .../components/unifiprotect/services.yaml | 14 ++ .../components/unifiprotect/strings.json | 26 +++ tests/components/unifiprotect/conftest.py | 19 ++ tests/components/unifiprotect/test_select.py | 204 +++++++++++++++++ .../components/unifiprotect/test_services.py | 216 +++++++++++++++++- tests/components/unifiprotect/utils.py | 2 + 11 files changed, 686 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index c312ceda547e7..9e359de481a08 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -161,6 +161,9 @@ async def _async_setup_entry( await async_migrate_data(hass, entry, data_service.api, bootstrap) data_service.async_setup() + # Load PTZ patrol data before loading platforms + await data_service.async_load_ptz_patrols() + # Create the NVR device before loading platforms # This ensures via_device references work for all device entities nvr = bootstrap.nvr diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 1c03febe74bf2..1cb56b7311f5f 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections import defaultdict from collections.abc import Callable, Generator, Iterable from datetime import datetime, timedelta @@ -17,6 +18,7 @@ EventType, ModelType, ProtectAdoptableDeviceModel, + PTZPatrol, WSSubscriptionMessage, ) from uiprotect.exceptions import ClientError, NotAuthorized @@ -89,6 +91,8 @@ def __init__( self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) self.add_signal = _async_dispatch_id(entry, DISPATCH_ADD) self.channels_signal = _async_dispatch_id(entry, DISPATCH_CHANNELS) + # PTZ patrol cache: camera_id -> list of patrols + self.ptz_patrols: dict[str, list[PTZPatrol]] = {} @property def disable_stream(self) -> bool: @@ -126,6 +130,27 @@ def get_cameras(self, ignore_unadopted: bool = True) -> Generator[Camera]: Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) ) + async def async_load_ptz_patrols(self) -> None: + """Load PTZ patrols for all PTZ cameras.""" + await asyncio.gather( + *( + self.async_load_ptz_patrols_for_camera(camera) + for camera in self.get_cameras() + ) + ) + + async def async_load_ptz_patrols_for_camera(self, camera: Camera) -> None: + """Load PTZ patrols for a specific camera.""" + if camera.feature_flags.is_ptz: + try: + self.ptz_patrols[camera.id] = await camera.get_ptz_patrols() + except ClientError: + _LOGGER.debug( + "Failed to load PTZ patrols for camera %s", + camera.display_name, + ) + self.ptz_patrols[camera.id] = [] + @callback def async_setup(self) -> None: """Subscribe and do the refresh.""" @@ -208,11 +233,22 @@ def async_add_pending_camera_id(self, camera_id: str) -> None: def _async_add_device(self, device: ProtectAdoptableDeviceModel) -> None: if device.is_adopted_by_us: _LOGGER.debug("Device adopted: %s", device.id) - async_dispatcher_send(self._hass, self.adopt_signal, device) + if isinstance(device, Camera) and device.feature_flags.is_ptz: + self._hass.async_create_task( + self._async_adopt_ptz_camera(device), + name="unifiprotect_adopt_ptz_camera", + ) + else: + async_dispatcher_send(self._hass, self.adopt_signal, device) else: _LOGGER.debug("New device detected: %s", device.id) async_dispatcher_send(self._hass, self.add_signal, device) + async def _async_adopt_ptz_camera(self, camera: Camera) -> None: + """Load PTZ patrol data and dispatch adopt signal for a PTZ camera.""" + await self.async_load_ptz_patrols_for_camera(camera) + async_dispatcher_send(self._hass, self.adopt_signal, camera) + @callback def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None: registry = dr.async_get(self._hass) diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index 9fccfcf97ac07..f66a963da4e39 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -246,6 +246,9 @@ "paired_camera": { "default": "mdi:cctv" }, + "ptz_patrol": { + "default": "mdi:rotate-360" + }, "recording_mode": { "default": "mdi:video-outline" } @@ -439,6 +442,9 @@ "get_user_keyring_info": { "service": "mdi:key-chain" }, + "ptz_goto_preset": { + "service": "mdi:camera-marker" + }, "remove_doorbell_text": { "service": "mdi:message-minus" }, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index bcfd67ca215e7..24a2791c88bff 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -21,6 +21,7 @@ ModelType, MountType, ProtectAdoptableDeviceModel, + PTZPatrol, RecordingMode, Sensor, Viewer, @@ -98,6 +99,9 @@ {"id": LightModeType.MANUAL.value, "name": LIGHT_MODE_OFF}, ] +PTZ_PATROL_STOP = "stop" +_KEY_PTZ_PATROL = "ptz_patrol" + DEVICE_RECORDING_MODES = [ {"id": mode.value, "name": mode.value} for mode in list(RecordingMode) ] @@ -185,10 +189,29 @@ async def _set_doorbell_message(obj: Camera, message: str) -> None: async def _set_liveview(obj: Viewer, liveview_id: str) -> None: + """Set the liveview for a viewer.""" liveview = obj.api.bootstrap.liveviews[liveview_id] await obj.set_liveview(liveview) +async def _set_ptz_patrol(obj: Camera, patrol_slot: str) -> None: + """Start or stop PTZ patrol.""" + if patrol_slot == PTZ_PATROL_STOP: + await obj.ptz_patrol_stop_public() + else: + slot = int(patrol_slot) + await obj.ptz_patrol_start_public(slot=slot) + + +PTZ_PATROL_DESCRIPTION = ProtectSelectEntityDescription[Camera]( + key=_KEY_PTZ_PATROL, + translation_key="ptz_patrol", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.is_ptz", + ufp_set_method_fn=_set_ptz_patrol, + ufp_perm=PermRequired.WRITE, +) + CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="recording_mode", @@ -330,7 +353,7 @@ async def async_setup_entry( @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - async_add_entities( + entities = list( async_all_device_entities( data, ProtectSelects, @@ -338,14 +361,26 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: ufp_device=device, ) ) + if isinstance(device, Camera) and device.feature_flags.is_ptz: + patrols = data.ptz_patrols.get(device.id, []) + entities.append(ProtectPTZPatrolSelect(data, device, patrols)) + async_add_entities(entities) data.async_subscribe_adopt(_add_new_device) - async_add_entities( + + entities = list( async_all_device_entities( data, ProtectSelects, model_descriptions=_MODEL_DESCRIPTIONS ) ) + for camera in data.api.bootstrap.cameras.values(): + if camera.feature_flags.is_ptz and camera.is_adopted_by_us: + patrols = data.ptz_patrols.get(camera.id, []) + entities.append(ProtectPTZPatrolSelect(data, camera, patrols)) + + async_add_entities(entities) + class ProtectSelects(ProtectDeviceEntity, SelectEntity): """A UniFi Protect Select Entity.""" @@ -411,3 +446,57 @@ async def async_select_option(self, option: str) -> None: if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) + + +class ProtectPTZPatrolSelect(ProtectDeviceEntity, SelectEntity): + """A UniFi Protect PTZ Patrol Select Entity.""" + + device: Camera + _attr_current_option: str | None = None + _state_attrs = ("_attr_available", "_attr_options", "_attr_current_option") + + def __init__( + self, + data: ProtectData, + device: Camera, + patrols: list[PTZPatrol], + ) -> None: + """Initialize the PTZ patrol select entity.""" + # Build options from cached patrols + self._hass_to_unifi_options: dict[str, str] = {PTZ_PATROL_STOP: PTZ_PATROL_STOP} + self._hass_to_unifi_options.update( + {patrol.name: str(patrol.slot) for patrol in patrols} + ) + self._unifi_to_hass_options = { + v: k for k, v in self._hass_to_unifi_options.items() + } + self._attr_options = list(self._hass_to_unifi_options) + + super().__init__(data, device, PTZ_PATROL_DESCRIPTION) + # Set initial state based on active patrol + self._update_patrol_state() + + def _update_patrol_state(self) -> None: + """Update the patrol state based on active_patrol_slot.""" + if self.device.active_patrol_slot is not None: + # A patrol is running - show which one + slot_str = str(self.device.active_patrol_slot) + self._attr_current_option = self._unifi_to_hass_options.get(slot_str) + else: + # No patrol running - show Stop + self._attr_current_option = PTZ_PATROL_STOP + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + super()._async_update_device_from_protect(device) + # Update patrol state from websocket updates + self._update_patrol_state() + + @async_ufp_instance_command + async def async_select_option(self, option: str) -> None: + """Start or stop a PTZ patrol.""" + # Home Assistant validates options before calling this method, + # so we can safely assume the option is valid + unifi_value = self._hass_to_unifi_options[option] + await _set_ptz_patrol(self.device, unifi_value) + # State will be updated via websocket when active_patrol_slot changes diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 9c651488d1eec..3737bde8ffefe 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine import logging from typing import Any, cast @@ -54,6 +55,9 @@ SERVICE_REMOVE_PRIVACY_ZONE = "remove_privacy_zone" SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells" SERVICE_GET_USER_KEYRING_INFO = "get_user_keyring_info" +SERVICE_PTZ_GOTO_PRESET = "ptz_goto_preset" + +ATTR_PRESET = "preset" ALL_GLOBAL_SERVICES = [ SERVICE_ADD_DOORBELL_TEXT, @@ -61,6 +65,7 @@ SERVICE_SET_CHIME_PAIRED, SERVICE_REMOVE_PRIVACY_ZONE, SERVICE_GET_USER_KEYRING_INFO, + SERVICE_PTZ_GOTO_PRESET, ] DOORBELL_TEXT_SCHEMA = vol.Schema( @@ -90,6 +95,13 @@ }, ) +PTZ_GOTO_PRESET_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PRESET): cv.string, + }, +) + @callback def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient: @@ -245,6 +257,59 @@ async def set_chime_paired_doorbells(call: ServiceCall) -> None: await chime.save_device(data_before_changed) +@callback +def _async_get_ptz_camera(call: ServiceCall) -> Camera: + """Get a PTZ camera from a service call, validating PTZ support.""" + camera = _async_get_ufp_camera(call) + if not camera.feature_flags.is_ptz: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_ptz_camera", + translation_placeholders={"camera_name": camera.display_name}, + ) + return camera + + +async def _async_ptz_command( + func: Callable[..., Coroutine[Any, Any, Any]], **kwargs: Any +) -> Any: + """Execute a PTZ command with error handling.""" + try: + return await func(**kwargs) + except (ClientError, ValidationError) as err: + _LOGGER.debug("Error calling UniFi Protect PTZ command: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_error", + ) from err + + +async def ptz_goto_preset(call: ServiceCall) -> None: + """Move a PTZ camera to a preset position.""" + camera = _async_get_ptz_camera(call) + preset_name: str = call.data[ATTR_PRESET] + + if preset_name.lower() == "home": + await _async_ptz_command(camera.ptz_goto_preset_public, slot=-1) + return + + presets = await _async_ptz_command(camera.get_ptz_presets) + + for preset in presets: + if preset.name == preset_name: + await _async_ptz_command(camera.ptz_goto_preset_public, slot=preset.slot) + return + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="ptz_preset_not_found", + translation_placeholders={ + "preset_name": preset_name, + "camera_name": camera.display_name, + }, + ) + + async def get_user_keyring_info(call: ServiceCall) -> ServiceResponse: """Get the user keyring info.""" camera = _async_get_ufp_camera(call) @@ -316,6 +381,12 @@ async def get_user_keyring_info(call: ServiceCall) -> ServiceResponse: GET_USER_KEYRING_INFO_SCHEMA, SupportsResponse.ONLY, ), + ( + SERVICE_PTZ_GOTO_PRESET, + ptz_goto_preset, + PTZ_GOTO_PRESET_SCHEMA, + SupportsResponse.NONE, + ), ] diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index 57d32e24993e3..d9d088e02f06e 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -58,3 +58,17 @@ get_user_keyring_info: selector: device: integration: unifiprotect + +ptz_goto_preset: + fields: + device_id: + required: true + selector: + device: + integration: unifiprotect + entity: + domain: camera + preset: + required: true + selector: + text: diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 0d9812abcd394..69ac175ae39aa 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -406,6 +406,12 @@ "paired_camera": { "name": "Paired camera" }, + "ptz_patrol": { + "name": "PTZ patrol", + "state": { + "stop": "[%key:common::state::stopped%]" + } + }, "recording_mode": { "name": "Recording mode", "state": { @@ -668,6 +674,9 @@ "not_authorized": { "message": "Not authorized to perform this action on the UniFi Protect controller" }, + "not_ptz_camera": { + "message": "Camera {camera_name} does not support PTZ" + }, "only_music_supported": { "message": "Only music media type is supported" }, @@ -677,6 +686,9 @@ "protect_version": { "message": "Your UniFi Protect version ({current_version}) is too old. Minimum required: {min_version}" }, + "ptz_preset_not_found": { + "message": "Could not find PTZ preset with name {preset_name} on camera {camera_name}" + }, "service_error": { "message": "Error calling UniFi Protect service, check the logs for more details" }, @@ -776,6 +788,20 @@ }, "name": "Get user keyring info" }, + "ptz_goto_preset": { + "description": "Moves a PTZ camera to a saved preset position.", + "fields": { + "device_id": { + "description": "The PTZ camera to move.", + "name": "[%key:component::camera::title%]" + }, + "preset": { + "description": "The name of the preset position to move to. Use 'Home' for the home position.", + "name": "Preset" + } + }, + "name": "PTZ go to preset" + }, "remove_doorbell_text": { "description": "Removes an existing custom message for doorbells.", "fields": { diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index fd07c88e8b3af..d2f54cae580ee 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -283,6 +283,25 @@ def doorbell_fixture(camera: Camera, fixed_now: datetime): return doorbell +@pytest.fixture(name="ptz_camera") +def ptz_camera_fixture(camera: Camera): + """Mock UniFi Protect PTZ Camera device.""" + ptz_cam = camera.model_copy() + ptz_cam.channels = [c.model_copy() for c in ptz_cam.channels] + ptz_cam.name = "PTZ Camera" + ptz_cam.feature_flags.is_ptz = True + ptz_cam.active_patrol_slot = None + + # Disable pydantic validation on this instance so we can mock methods + object.__setattr__(ptz_cam, "get_ptz_presets", AsyncMock(return_value=[])) + object.__setattr__(ptz_cam, "get_ptz_patrols", AsyncMock(return_value=[])) + object.__setattr__(ptz_cam, "ptz_goto_preset_public", AsyncMock()) + object.__setattr__(ptz_cam, "ptz_patrol_start_public", AsyncMock()) + object.__setattr__(ptz_cam, "ptz_patrol_stop_public", AsyncMock()) + + return ptz_cam + + @pytest.fixture def unadopted_camera(camera: Camera): """Mock UniFi Protect Camera device (unadopted).""" diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index fdf3b7bb70af9..b8cd4dc6dd7ba 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -14,6 +14,7 @@ LightModeEnableType, LightModeType, Liveview, + PTZPatrol, RecordingMode, Viewer, ) @@ -25,6 +26,7 @@ CAMERA_SELECTS, LIGHT_MODE_OFF, LIGHT_SELECTS, + PTZ_PATROL_STOP, VIEWER_SELECTS, ) from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, Platform @@ -554,3 +556,205 @@ async def test_select_set_option_viewer( ) mock_method.assert_called_once_with(liveview) + + +# --- PTZ Patrol Test Helpers --- + + +def _get_ptz_entity_id(hass: HomeAssistant, camera: Camera, key: str) -> str | None: + """Get PTZ entity ID by unique_id from entity registry.""" + entity_registry = er.async_get(hass) + unique_id = f"{camera.mac}_{key}" + return entity_registry.async_get_entity_id( + Platform.SELECT, "unifiprotect", unique_id + ) + + +def _make_patrols(camera_id: str) -> list[PTZPatrol]: + """Create mock PTZ patrols.""" + return [ + PTZPatrol( + id="patrol1", + name="Patrol 1", + slot=0, + presets=[0, 1], + presetDurationSeconds=10, + camera=camera_id, + ), + PTZPatrol( + id="patrol2", + name="Patrol 2", + slot=1, + presets=[0], + presetDurationSeconds=5, + camera=camera_id, + ), + ] + + +async def _setup_ptz_camera( + hass: HomeAssistant, + ufp: MockUFPFixture, + ptz_camera: Camera, + *, + patrols: list[PTZPatrol] | None = None, +) -> None: + """Set up PTZ camera with mocked patrols.""" + ptz_camera.get_ptz_patrols.return_value = patrols or [] + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [ptz_camera]) + + +# --- PTZ Patrol Tests --- + + +async def test_select_ptz_patrol_setup( + hass: HomeAssistant, ufp: MockUFPFixture, ptz_camera: Camera +) -> None: + """Test PTZ patrol select entity setup.""" + await _setup_ptz_camera(hass, ufp, ptz_camera, patrols=_make_patrols(ptz_camera.id)) + + # PTZ camera should have 1 additional select entity (patrol) + # Regular camera has 2 (recording_mode, infrared_mode), PTZ has 2 + 1 = 3 + assert_entity_counts(hass, Platform.SELECT, 3, 3) + + entity_id = _get_ptz_entity_id(hass, ptz_camera, "ptz_patrol") + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.state == PTZ_PATROL_STOP + options = state.attributes.get(ATTR_OPTIONS, []) + assert options == ["stop", "Patrol 1", "Patrol 2"] + + +async def test_select_ptz_patrol_start( + hass: HomeAssistant, ufp: MockUFPFixture, ptz_camera: Camera +) -> None: + """Test starting a PTZ patrol.""" + await _setup_ptz_camera( + hass, ufp, ptz_camera, patrols=_make_patrols(ptz_camera.id)[:1] + ) + + entity_id = _get_ptz_entity_id(hass, ptz_camera, "ptz_patrol") + assert entity_id is not None + with patch_ufp_method( + ptz_camera, "ptz_patrol_start_public", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Patrol 1"}, + blocking=True, + ) + mock_method.assert_called_once_with(slot=0) + + +async def test_select_ptz_patrol_stop( + hass: HomeAssistant, ufp: MockUFPFixture, ptz_camera: Camera +) -> None: + """Test stopping a PTZ patrol.""" + await _setup_ptz_camera( + hass, ufp, ptz_camera, patrols=_make_patrols(ptz_camera.id)[:1] + ) + + entity_id = _get_ptz_entity_id(hass, ptz_camera, "ptz_patrol") + assert entity_id is not None + with patch_ufp_method( + ptz_camera, "ptz_patrol_stop_public", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "stop"}, + blocking=True, + ) + mock_method.assert_called_once() + + +async def test_select_ptz_patrol_active_state( + hass: HomeAssistant, ufp: MockUFPFixture, ptz_camera: Camera +) -> None: + """Test PTZ patrol shows active patrol from device state.""" + patrols = _make_patrols(ptz_camera.id) + ptz_camera.active_patrol_slot = 0 + + await _setup_ptz_camera(hass, ufp, ptz_camera, patrols=patrols) + + entity_id = _get_ptz_entity_id(hass, ptz_camera, "ptz_patrol") + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "Patrol 1" + + +async def test_select_ptz_patrol_websocket_update( + hass: HomeAssistant, ufp: MockUFPFixture, ptz_camera: Camera +) -> None: + """Test PTZ patrol state updates via websocket.""" + patrols = _make_patrols(ptz_camera.id) + await _setup_ptz_camera(hass, ufp, ptz_camera, patrols=patrols) + + entity_id = _get_ptz_entity_id(hass, ptz_camera, "ptz_patrol") + assert entity_id is not None + + # Initially stopped + state = hass.states.get(entity_id) + assert state is not None + assert state.state == PTZ_PATROL_STOP + + # Simulate websocket update: patrol starts + new_camera = ptz_camera.model_copy() + new_camera.active_patrol_slot = 1 + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "Patrol 2" + + # Simulate websocket update: patrol stops + new_camera2 = ptz_camera.model_copy() + new_camera2.active_patrol_slot = None + + mock_msg2 = Mock() + mock_msg2.changed_data = {} + mock_msg2.new_obj = new_camera2 + + ufp.api.bootstrap.cameras = {new_camera2.id: new_camera2} + ufp.ws_msg(mock_msg2) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == PTZ_PATROL_STOP + + +async def test_select_ptz_camera_adopt( + hass: HomeAssistant, ufp: MockUFPFixture, ptz_camera: Camera +) -> None: + """Test adopting a new PTZ camera creates patrol entity.""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.SELECT, 0, 0) + + ptz_camera._api = ufp.api + for channel in ptz_camera.channels: + channel._api = ufp.api + + ptz_camera.get_ptz_patrols.return_value = _make_patrols(ptz_camera.id) + + await adopt_devices(hass, ufp, [ptz_camera]) + await hass.async_block_till_done() + + # Should have 2 regular camera selects + 1 patrol select = 3 + assert_entity_counts(hass, Platform.SELECT, 3, 3) + + patrol_entity_id = _get_ptz_entity_id(hass, ptz_camera, "ptz_patrol") + assert patrol_entity_id is not None diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 23db8df4fe6a4..19a7a63b67459 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -5,9 +5,9 @@ from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import Camera, Chime, Color, Light, ModelType +from uiprotect.data import Camera, Chime, Color, Light, ModelType, PTZPreset from uiprotect.data.devices import CameraZone -from uiprotect.exceptions import BadRequest +from uiprotect.exceptions import BadRequest, ClientError from homeassistant.components.unifiprotect.const import ( ATTR_MESSAGE, @@ -20,8 +20,10 @@ KEYRINGS_USER_STATUS, ) from homeassistant.components.unifiprotect.services import ( + ATTR_PRESET, SERVICE_ADD_DOORBELL_TEXT, SERVICE_GET_USER_KEYRING_INFO, + SERVICE_PTZ_GOTO_PRESET, SERVICE_REMOVE_DOORBELL_TEXT, SERVICE_REMOVE_PRIVACY_ZONE, SERVICE_SET_CHIME_PAIRED, @@ -29,7 +31,7 @@ from homeassistant.config_entries import ConfigEntryDisabler from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import patch_ufp_method @@ -340,3 +342,211 @@ async def test_get_user_keyring_info_no_users( blocking=True, return_response=True, ) + + +# --- PTZ Preset Service Tests --- + + +def _make_presets() -> list[PTZPreset]: + """Create mock PTZ presets.""" + return [ + PTZPreset( + id="preset1", + name="Preset 1", + slot=0, + ptz={"pan": 100, "tilt": 50, "zoom": 0}, + ), + PTZPreset( + id="preset2", + name="Preset 2", + slot=1, + ptz={"pan": 200, "tilt": 100, "zoom": 50}, + ), + ] + + +async def test_ptz_goto_preset( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + ptz_camera: Camera, +) -> None: + """Test ptz_goto_preset service with a named preset.""" + ptz_camera.get_ptz_presets.return_value = _make_presets() + ptz_camera.get_ptz_patrols.return_value = [] + await init_entry(hass, ufp, [ptz_camera]) + + camera_entry = entity_registry.async_get( + "camera.ptz_camera_high_resolution_channel" + ) + + with patch_ufp_method( + ptz_camera, "ptz_goto_preset_public", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_GOTO_PRESET, + {ATTR_DEVICE_ID: camera_entry.device_id, ATTR_PRESET: "Preset 1"}, + blocking=True, + ) + mock_method.assert_called_once_with(slot=0) + + +async def test_ptz_goto_preset_home( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + ptz_camera: Camera, +) -> None: + """Test ptz_goto_preset service with home preset.""" + ptz_camera.get_ptz_patrols.return_value = [] + await init_entry(hass, ufp, [ptz_camera]) + + camera_entry = entity_registry.async_get( + "camera.ptz_camera_high_resolution_channel" + ) + + with patch_ufp_method( + ptz_camera, "ptz_goto_preset_public", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_GOTO_PRESET, + {ATTR_DEVICE_ID: camera_entry.device_id, ATTR_PRESET: "Home"}, + blocking=True, + ) + mock_method.assert_called_once_with(slot=-1) + + +async def test_ptz_goto_preset_not_found( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + ptz_camera: Camera, +) -> None: + """Test ptz_goto_preset service with non-existent preset.""" + ptz_camera.get_ptz_presets.return_value = [] + ptz_camera.get_ptz_patrols.return_value = [] + await init_entry(hass, ufp, [ptz_camera]) + + camera_entry = entity_registry.async_get( + "camera.ptz_camera_high_resolution_channel" + ) + + with pytest.raises(ServiceValidationError, match="Could not find PTZ preset"): + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_GOTO_PRESET, + { + ATTR_DEVICE_ID: camera_entry.device_id, + ATTR_PRESET: "Does Not Exist", + }, + blocking=True, + ) + + +async def test_ptz_goto_preset_not_ptz_camera( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, +) -> None: + """Test ptz_goto_preset service on a non-PTZ camera.""" + await init_entry(hass, ufp, [doorbell]) + + camera_entry = entity_registry.async_get("binary_sensor.test_camera_doorbell") + + with pytest.raises(ServiceValidationError, match="does not support PTZ"): + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_GOTO_PRESET, + {ATTR_DEVICE_ID: camera_entry.device_id, ATTR_PRESET: "Home"}, + blocking=True, + ) + + +async def test_ptz_goto_preset_client_error( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + ptz_camera: Camera, +) -> None: + """Test ptz_goto_preset service when get_ptz_presets raises ClientError.""" + ptz_camera.get_ptz_presets.side_effect = ClientError("Connection failed") + ptz_camera.get_ptz_patrols.return_value = [] + await init_entry(hass, ufp, [ptz_camera]) + + camera_entry = entity_registry.async_get( + "camera.ptz_camera_high_resolution_channel" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_GOTO_PRESET, + {ATTR_DEVICE_ID: camera_entry.device_id, ATTR_PRESET: "Preset 1"}, + blocking=True, + ) + + +async def test_ptz_goto_preset_public_client_error( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + ptz_camera: Camera, +) -> None: + """Test ptz_goto_preset service when ptz_goto_preset_public raises ClientError.""" + ptz_camera.get_ptz_presets.return_value = _make_presets() + ptz_camera.get_ptz_patrols.return_value = [] + await init_entry(hass, ufp, [ptz_camera]) + + camera_entry = entity_registry.async_get( + "camera.ptz_camera_high_resolution_channel" + ) + + with ( + patch_ufp_method( + ptz_camera, + "ptz_goto_preset_public", + new_callable=AsyncMock, + side_effect=ClientError("Connection failed"), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_GOTO_PRESET, + {ATTR_DEVICE_ID: camera_entry.device_id, ATTR_PRESET: "Preset 1"}, + blocking=True, + ) + + +async def test_ptz_goto_home_preset_client_error( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + ptz_camera: Camera, +) -> None: + """Test ptz_goto_preset service with home preset when ptz_goto_preset_public raises ClientError.""" + ptz_camera.get_ptz_patrols.return_value = [] + await init_entry(hass, ufp, [ptz_camera]) + + camera_entry = entity_registry.async_get( + "camera.ptz_camera_high_resolution_channel" + ) + + with ( + patch_ufp_method( + ptz_camera, + "ptz_goto_preset_public", + new_callable=AsyncMock, + side_effect=ClientError("Connection failed"), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_GOTO_PRESET, + {ATTR_DEVICE_ID: camera_entry.device_id, ATTR_PRESET: "Home"}, + blocking=True, + ) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index cd7a78186f503..a99ad68e785bd 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -244,6 +244,8 @@ async def adopt_devices( devices = getattr(ufp.api.bootstrap, f"{ufp_device.model.value}s") devices[ufp_device.id] = ufp_device + # Add to id_lookup so get_device_from_id works + add_device_ref(ufp.api.bootstrap, ufp_device) mock_msg = Mock() mock_msg.changed_data = {} From 959bafe78b21eec12a62ba01117c088bd22393c0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 22 Feb 2026 19:47:13 +0100 Subject: [PATCH 15/17] Fix grammar of `amcrest.ptz_control` action description (#163802) --- homeassistant/components/amcrest/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/amcrest/strings.json b/homeassistant/components/amcrest/strings.json index 3071b249dc2df..20d576d362f58 100644 --- a/homeassistant/components/amcrest/strings.json +++ b/homeassistant/components/amcrest/strings.json @@ -75,7 +75,7 @@ "name": "Go to preset" }, "ptz_control": { - "description": "Moves (pan/tilt) and/or zoom a PTZ camera.", + "description": "Moves (pan/tilt) and/or zooms a PTZ camera.", "fields": { "entity_id": { "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]", From abdd51c266ff0fdba1cf41b7c34a63644d5f86d8 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 22 Feb 2026 20:56:27 +0100 Subject: [PATCH 16/17] Allow unit of measurement translation in Analytics Insights (#163811) --- .../components/analytics_insights/sensor.py | 5 ----- .../components/analytics_insights/strings.json | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index 8664e8388848e..d5a64e93b0ad3 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -38,7 +38,6 @@ def get_app_entity_description( translation_key="apps", name=name_slug, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.apps.get(name_slug), ) @@ -52,7 +51,6 @@ def get_core_integration_entity_description( translation_key="core_integrations", name=name, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.core_integrations.get(domain), ) @@ -66,7 +64,6 @@ def get_custom_integration_entity_description( translation_key="custom_integrations", translation_placeholders={"custom_integration_domain": domain}, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.custom_integrations.get(domain), ) @@ -77,7 +74,6 @@ def get_custom_integration_entity_description( translation_key="total_active_installations", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.active_installations, ), AnalyticsSensorEntityDescription( @@ -85,7 +81,6 @@ def get_custom_integration_entity_description( translation_key="total_reports_integrations", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.reports_integrations, ), ] diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index b5c4307cf8ff0..e01c8bdfd311a 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -24,14 +24,23 @@ }, "entity": { "sensor": { + "apps": { + "unit_of_measurement": "active installations" + }, + "core_integrations": { + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" + }, "custom_integrations": { - "name": "{custom_integration_domain} (custom)" + "name": "{custom_integration_domain} (custom)", + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" }, "total_active_installations": { - "name": "Total active installations" + "name": "Total active installations", + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" }, "total_reports_integrations": { - "name": "Total reported integrations" + "name": "Total reported integrations", + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" } } }, From 19b606841d4eb73184fcf0058abfc90e43064a30 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:44:53 +0100 Subject: [PATCH 17/17] Mark fan entity type hints as mandatory (#163789) --- pylint/plugins/hass_enforce_type_hints.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index e76db49fe72b4..e4e0c9fd1856a 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1597,6 +1597,7 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="percentage", return_type=["int", None], + mandatory=True, ), TypeHintMatch( function_name="speed_count", @@ -1611,10 +1612,12 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="current_direction", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="oscillating", return_type=["bool", None], + mandatory=True, ), TypeHintMatch( function_name="preset_mode", @@ -1624,6 +1627,7 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="preset_modes", return_type=["list[str]", None], + mandatory=True, ), TypeHintMatch( function_name="supported_features",