Remove deprecated battery properties from vacuum (#175682)

This commit is contained in:
G Johansson
2026-08-15 17:06:15 +02:00
committed by GitHub
parent 500548f43b
commit caf876d6ee
10 changed files with 2 additions and 588 deletions
@@ -73,7 +73,6 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_BATTERY_LEVEL,
ATTR_CODE,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
@@ -848,65 +847,6 @@ class LocatorTrait(_Trait):
)
@register_trait
class EnergyStorageTrait(_Trait):
"""Trait to offer EnergyStorage functionality.
https://developers.google.com/actions/smarthome/traits/energystorage
"""
name = TRAIT_ENERGY_STORAGE
commands = [COMMAND_CHARGE]
@staticmethod
@override
def supported(domain, features, device_class, _):
"""Test if state is supported."""
return domain == VACUUM_DOMAIN and features & VacuumEntityFeature.BATTERY
@override
def sync_attributes(self) -> dict[str, Any]:
"""Return EnergyStorage attributes for a sync request."""
return {
"isRechargeable": True,
"queryOnlyEnergyStorage": True,
}
@override
def query_attributes(self) -> dict[str, Any]:
"""Return EnergyStorage query attributes."""
battery_level = self.state.attributes.get(ATTR_BATTERY_LEVEL)
if battery_level is None:
return {}
if battery_level == 100:
descriptive_capacity_remaining = "FULL"
elif 75 <= battery_level < 100:
descriptive_capacity_remaining = "HIGH"
elif 50 <= battery_level < 75:
descriptive_capacity_remaining = "MEDIUM"
elif 25 <= battery_level < 50:
descriptive_capacity_remaining = "LOW"
elif 0 <= battery_level < 25:
descriptive_capacity_remaining = "CRITICALLY_LOW"
return {
"descriptiveCapacityRemaining": descriptive_capacity_remaining,
"capacityRemaining": [{"rawValue": battery_level, "unit": "PERCENTAGE"}],
"capacityUntilFull": [
{"rawValue": 100 - battery_level, "unit": "PERCENTAGE"}
],
"isCharging": self.state.state == vacuum.VacuumActivity.DOCKED,
"isPluggedIn": self.state.state == vacuum.VacuumActivity.DOCKED,
}
@override
async def execute(self, command, data, params, challenge):
"""Execute a dock command."""
raise SmartHomeError(
ERR_FUNCTION_NOT_SUPPORTED,
"Controlling charging of a vacuum is not yet supported",
)
@register_trait
class StartStopTrait(_Trait):
"""Trait to offer StartStop functionality.
@@ -1908,7 +1848,7 @@ class FanSpeedTrait(_Trait):
name = TRAIT_FAN_SPEED
commands = [COMMAND_SET_FAN_SPEED, COMMAND_REVERSE]
def __init__(self, hass, state, config):
def __init__(self, hass: HomeAssistant, state, config) -> None:
"""Initialize a trait for a state."""
super().__init__(hass, state, config)
if state.domain == FAN_DOMAIN:
-128
View File
@@ -1,6 +1,5 @@
"""Support for vacuum cleaner robots (botvacs)."""
import asyncio
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import timedelta
@@ -13,7 +12,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API
ATTR_BATTERY_LEVEL,
ATTR_COMMAND,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
@@ -29,9 +27,6 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -51,7 +46,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=20)
ATTR_BATTERY_ICON = "battery_icon"
ATTR_CLEANED_AREA = "cleaned_area"
ATTR_FAN_SPEED = "fan_speed"
ATTR_FAN_SPEED_LIST = "fan_speed_list"
@@ -73,8 +67,6 @@ DEFAULT_NAME = "Vacuum cleaner robot"
ISSUE_SEGMENTS_CHANGED = "segments_changed"
_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",)
# mypy: disallow-any-generics
@@ -173,8 +165,6 @@ class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True):
STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = {
"supported_features",
"battery_level",
"battery_icon",
"fan_speed",
"fan_speed_list",
"activity",
@@ -192,8 +182,6 @@ class StateVacuumEntity(
{VacuumEntityCapabilityAttribute.FAN_SPEED_LIST}
)
_attr_battery_icon: str
_attr_battery_level: int | None = None
_attr_fan_speed: str | None = None
_attr_fan_speed_list: list[str]
_attr_activity: VacuumActivity | None = None
@@ -202,121 +190,12 @@ class StateVacuumEntity(
_segments_not_configured_issue_created: bool = False
_segments_changed_last_seen: list[dict[str, Any]] | None = None
__vacuum_legacy_battery_level: bool = False
__vacuum_legacy_battery_icon: bool = False
__vacuum_legacy_battery_feature: bool = False
@override
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if any(
method in cls.__dict__
for method in ("_attr_battery_level", "battery_level")
):
# Integrations should use a separate battery sensor.
cls.__vacuum_legacy_battery_level = True
if any(
method in cls.__dict__ for method in ("_attr_battery_icon", "battery_icon")
):
# Integrations should use a separate battery sensor.
cls.__vacuum_legacy_battery_icon = True
@override
def __setattr__(self, name: str, value: Any) -> None:
"""Set attribute.
Deprecation warning if setting battery icon or battery level
attributes directly unless already reported.
"""
if name in {"_attr_battery_level", "_attr_battery_icon"}:
self._report_deprecated_battery_properties(name[6:])
return super().__setattr__(name, value)
@callback
@override
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.__vacuum_legacy_battery_level:
self._report_deprecated_battery_properties("battery_level")
if self.__vacuum_legacy_battery_icon:
self._report_deprecated_battery_properties("battery_icon")
@callback
@override
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_check_segments_issues()
@callback
def _report_deprecated_battery_properties(self, property: str) -> None:
"""Report on deprecated use of battery properties.
Integrations should implement a sensor instead.
"""
if (
self.platform
and self.platform.platform_name
not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS
):
# Don't report usage until after entity added to hass, after init
report_usage(
f"is setting the {property} which has been deprecated."
f" Integration {self.platform.platform_name} should implement a sensor"
" instead with a correct device class and link it to the same device",
core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.8",
integration_domain=self.platform.platform_name,
exclude_integrations={DOMAIN},
)
@callback
def _report_deprecated_battery_feature(self) -> None:
"""Report on deprecated use of battery supported features.
Integrations should remove the battery supported feature when migrating
battery level and icon to a sensor.
"""
if (
self.platform
and self.platform.platform_name
not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS
):
# Don't report usage until after entity added to hass, after init
report_usage(
f"is setting the battery supported feature which has been deprecated."
f" Integration {self.platform.platform_name}"
" should remove this as part of migrating"
" the battery level and icon to a sensor",
core_behavior=ReportBehavior.LOG,
core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.8",
integration_domain=self.platform.platform_name,
exclude_integrations={DOMAIN},
)
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the vacuum cleaner."""
return self._attr_battery_level
@property
def battery_icon(self) -> str:
"""Return the battery icon for the vacuum cleaner."""
charging = bool(self.activity == VacuumActivity.DOCKED)
return icon_for_battery_level(
battery_level=self.battery_level, charging=charging
)
@property
@override
def capability_attributes(self) -> dict[str, Any] | None:
@@ -342,13 +221,6 @@ class StateVacuumEntity(
data: dict[str, Any] = {}
supported_features = self.supported_features
if VacuumEntityFeature.BATTERY in supported_features:
if self.__vacuum_legacy_battery_feature is False:
self._report_deprecated_battery_feature()
self.__vacuum_legacy_battery_feature = True
data[ATTR_BATTERY_LEVEL] = self.battery_level
data[ATTR_BATTERY_ICON] = self.battery_icon
if VacuumEntityFeature.FAN_SPEED in supported_features:
data[VacuumEntityStateAttribute.FAN_SPEED] = self.fan_speed
-1
View File
@@ -46,7 +46,6 @@ class VacuumEntityFeature(IntFlag):
STOP = 8
RETURN_HOME = 16
FAN_SPEED = 32
BATTERY = 64
STATUS = 128 # Deprecated, not supported by StateVacuumEntity
SEND_COMMAND = 256
LOCATE = 512
@@ -1,59 +0,0 @@
"""Helper to test significant Vacuum state changes."""
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.significant_change import (
check_absolute_change,
check_valid_float,
)
from . import ATTR_BATTERY_LEVEL
from .const import VacuumEntityStateAttribute
SIGNIFICANT_ATTRIBUTES: set[str] = {
ATTR_BATTERY_LEVEL,
VacuumEntityStateAttribute.FAN_SPEED,
}
@callback
def async_check_significant_change(
hass: HomeAssistant,
old_state: str,
old_attrs: dict,
new_state: str,
new_attrs: dict,
**kwargs: Any,
) -> bool | None:
"""Test if state significantly changed."""
if old_state != new_state:
return True
old_attrs_s = set(
{k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items()
)
new_attrs_s = set(
{k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items()
)
changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s}
for attr_name in changed_attrs:
if attr_name != ATTR_BATTERY_LEVEL:
return True
old_attr_value = old_attrs.get(attr_name)
new_attr_value = new_attrs.get(attr_name)
if new_attr_value is None or not check_valid_float(new_attr_value):
# New attribute value is invalid, ignore it
continue
if old_attr_value is None or not check_valid_float(old_attr_value):
# Old attribute value was invalid, we should report again
return True
if check_absolute_change(old_attr_value, new_attr_value, 1.0):
return True
# no significant attribute change detected
return False
@@ -58,7 +58,6 @@ from homeassistant.components.valve import ValveEntityFeature
from homeassistant.components.water_heater import WaterHeaterEntityFeature
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_MODE,
@@ -484,74 +483,6 @@ async def test_locate_vacuum(hass: HomeAssistant) -> None:
assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED
async def test_energystorage_vacuum(hass: HomeAssistant) -> None:
"""Test EnergyStorage trait support for vacuum domain."""
assert helpers.get_google_type(vacuum.DOMAIN, None) is not None
assert trait.EnergyStorageTrait.supported(
vacuum.DOMAIN, VacuumEntityFeature.BATTERY, None, None
)
trt = trait.EnergyStorageTrait(
hass,
State(
"vacuum.bla",
vacuum.VacuumActivity.DOCKED,
{
ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY,
ATTR_BATTERY_LEVEL: 100,
},
),
BASIC_CONFIG,
)
assert trt.sync_attributes() == {
"isRechargeable": True,
"queryOnlyEnergyStorage": True,
}
assert trt.query_attributes() == {
"descriptiveCapacityRemaining": "FULL",
"capacityRemaining": [{"rawValue": 100, "unit": "PERCENTAGE"}],
"capacityUntilFull": [{"rawValue": 0, "unit": "PERCENTAGE"}],
"isCharging": True,
"isPluggedIn": True,
}
trt = trait.EnergyStorageTrait(
hass,
State(
"vacuum.bla",
vacuum.VacuumActivity.CLEANING,
{
ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY,
ATTR_BATTERY_LEVEL: 20,
},
),
BASIC_CONFIG,
)
assert trt.sync_attributes() == {
"isRechargeable": True,
"queryOnlyEnergyStorage": True,
}
assert trt.query_attributes() == {
"descriptiveCapacityRemaining": "CRITICALLY_LOW",
"capacityRemaining": [{"rawValue": 20, "unit": "PERCENTAGE"}],
"capacityUntilFull": [{"rawValue": 80, "unit": "PERCENTAGE"}],
"isCharging": False,
"isPluggedIn": False,
}
with pytest.raises(helpers.SmartHomeError) as err:
await trt.execute(trait.COMMAND_CHARGE, BASIC_DATA, {"charge": True}, {})
assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED
with pytest.raises(helpers.SmartHomeError) as err:
await trt.execute(trait.COMMAND_CHARGE, BASIC_DATA, {"charge": False}, {})
assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED
async def test_startstop_vacuum(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
+1 -2
View File
@@ -705,8 +705,7 @@ async def test_status(
(
{
mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings(
mqttvacuum.DEFAULT_SERVICES
| vacuum.VacuumEntityFeature.BATTERY,
mqttvacuum.DEFAULT_SERVICES,
SERVICE_TO_STRING,
)
},
-2
View File
@@ -23,13 +23,11 @@ class MockVacuum(MockEntity, StateVacuumEntity):
| VacuumEntityFeature.STOP
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.CLEAN_SPOT
| VacuumEntityFeature.MAP
| VacuumEntityFeature.STATE
| VacuumEntityFeature.START
)
_attr_battery_level = 99
_attr_fan_speed_list = ["slow", "fast"]
def __init__(self, **values: Any) -> None:
-1
View File
@@ -47,7 +47,6 @@ async def vacuum_supported_features() -> VacuumEntityFeature:
| VacuumEntityFeature.STOP
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.CLEAN_SPOT
| VacuumEntityFeature.MAP
| VacuumEntityFeature.STATE
-213
View File
@@ -1,7 +1,6 @@
"""The tests for the Vacuum entity integration."""
from dataclasses import asdict
import logging
from typing import Any
import pytest
@@ -31,7 +30,6 @@ from . import (
help_async_setup_entry_init,
help_async_unload_entry,
)
from .common import async_start
from tests.common import (
MockConfigEntry,
@@ -552,214 +550,3 @@ async def test_segments_changed_issue(
await hass.async_block_till_done()
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None # pylint: disable=home-assistant-tests-registry-fixtures
@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)])
async def test_vacuum_log_deprecated_battery_using_properties(
hass: HomeAssistant,
config_flow_fixture: None,
caplog: pytest.LogCaptureFixture,
is_built_in: bool,
log_warnings: int,
) -> None:
"""Test incorrectly using battery properties logs warning."""
class MockLegacyVacuum(MockVacuum):
"""Mocked vacuum entity."""
@property
def activity(self) -> VacuumActivity:
"""Return the state of the entity."""
return VacuumActivity.CLEANING
@property
def battery_level(self) -> int:
"""Return the battery level of the vacuum."""
return 50
@property
def battery_icon(self) -> str:
"""Return the battery icon of the vacuum."""
return "mdi:battery-50"
entity = MockLegacyVacuum(
name="Testing",
entity_id="vacuum.test",
)
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
mock_integration(
hass,
MockModule(
"test",
async_setup_entry=help_async_setup_entry_init,
async_unload_entry=help_async_unload_entry,
),
built_in=is_built_in,
)
setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True)
assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
assert (
len([record for record in caplog.records if record.levelno >= logging.WARNING])
== log_warnings
)
assert (
"integration 'test' is setting the battery_icon which has been deprecated."
in caplog.text
) != is_built_in
assert (
"integration 'test' is setting the battery_level which has been deprecated."
in caplog.text
) != is_built_in
@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)])
async def test_vacuum_log_deprecated_battery_using_attr(
hass: HomeAssistant,
config_flow_fixture: None,
caplog: pytest.LogCaptureFixture,
is_built_in: bool,
log_warnings: int,
) -> None:
"""Test _attr_battery_* attribute logs issue and raises repair."""
class MockLegacyVacuum(MockVacuum):
"""Mocked vacuum entity."""
def start(self) -> None:
"""Start cleaning."""
self._attr_battery_level = 50
self._attr_battery_icon = "mdi:battery-50"
entity = MockLegacyVacuum(
name="Testing",
entity_id="vacuum.test",
)
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
mock_integration(
hass,
MockModule(
"test",
async_setup_entry=help_async_setup_entry_init,
async_unload_entry=help_async_unload_entry,
),
built_in=is_built_in,
)
setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True)
assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
entity.start()
assert (
len([record for record in caplog.records if record.levelno >= logging.WARNING])
== log_warnings
)
assert (
"integration 'test' is setting the battery_level which has been deprecated."
in caplog.text
) != is_built_in
assert (
"integration 'test' is setting the battery_icon which has been deprecated."
in caplog.text
) != is_built_in
await async_start(hass, entity.entity_id)
caplog.clear()
await async_start(hass, entity.entity_id)
# Test we only log once
assert (
len([record for record in caplog.records if record.levelno >= logging.WARNING])
== 0
)
@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 1)])
async def test_vacuum_log_deprecated_battery_supported_feature(
hass: HomeAssistant,
config_flow_fixture: None,
caplog: pytest.LogCaptureFixture,
is_built_in: bool,
log_warnings: int,
) -> None:
"""Test incorrectly setting battery supported feature logs warning."""
class MockVacuum(StateVacuumEntity):
"""Mock vacuum class."""
_attr_supported_features = (
VacuumEntityFeature.STATE | VacuumEntityFeature.BATTERY
)
_attr_name = "Testing"
entity = MockVacuum()
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
mock_integration(
hass,
MockModule(
"test",
async_setup_entry=help_async_setup_entry_init,
async_unload_entry=help_async_unload_entry,
),
built_in=is_built_in,
)
setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True)
assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
assert (
len([record for record in caplog.records if record.levelno >= logging.WARNING])
== log_warnings
)
assert (
"integration 'test' is setting the battery supported feature" in caplog.text
) != is_built_in
async def test_vacuum_not_log_deprecated_battery_properties_during_init(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test not logging deprecation until after added to hass."""
class MockLegacyVacuum(MockVacuum):
"""Mocked vacuum entity."""
def __init__(self, **kwargs: Any) -> None:
"""Initialize a mock vacuum entity."""
super().__init__(**kwargs)
self._attr_battery_level = 50
@property
def activity(self) -> VacuumActivity:
"""Return the state of the entity."""
return VacuumActivity.CLEANING
entity = MockLegacyVacuum(
name="Testing",
entity_id="vacuum.test",
)
assert entity.battery_level == 50
assert (
len([record for record in caplog.records if record.levelno >= logging.WARNING])
== 0
)
@@ -1,52 +0,0 @@
"""Test the Vacuum significant change platform."""
import pytest
from homeassistant.components.vacuum import (
ATTR_BATTERY_ICON,
ATTR_BATTERY_LEVEL,
ATTR_FAN_SPEED,
)
from homeassistant.components.vacuum.significant_change import (
async_check_significant_change,
)
async def test_significant_state_change() -> None:
"""Detect Vacuum significant state changes."""
attrs = {}
assert not async_check_significant_change(None, "on", attrs, "on", attrs)
assert async_check_significant_change(None, "on", attrs, "off", attrs)
@pytest.mark.parametrize(
("old_attrs", "new_attrs", "expected_result"),
[
({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "old_value"}, False),
({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "new_value"}, True),
# multiple attributes
(
{ATTR_FAN_SPEED: "old_value", ATTR_BATTERY_LEVEL: 10.0},
{ATTR_FAN_SPEED: "new_value", ATTR_BATTERY_LEVEL: 10.0},
True,
),
# float attributes
({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 11.0}, True),
({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 10.9}, False),
({ATTR_BATTERY_LEVEL: "invalid"}, {ATTR_BATTERY_LEVEL: 10.0}, True),
({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: "invalid"}, False),
# insignificant attributes
({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "new_value"}, False),
({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "old_value"}, False),
({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False),
({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False),
],
)
async def test_significant_atributes_change(
old_attrs: dict, new_attrs: dict, expected_result: bool
) -> None:
"""Detect Vacuum significant attribute changes."""
assert (
async_check_significant_change(None, "state", old_attrs, "state", new_attrs)
== expected_result
)