Compare commits

...

16 Commits

Author SHA1 Message Date
Stefan Agner 5a5eb3af6b Remove unused Silicon Labs Flasher add-on manager
The core_silabs_flasher add-on is no longer used since firmware flashing
moved into Core (using the universal_silabs_flasher library directly).
Two separate flows retired it and left constants behind:

- The ZBT-1/Yellow firmware config flow stopped using
  get_zigbee_flasher_addon_manager() and the ZIGBEE_FLASHER_ADDON_*
  constants in #145019.
- The multiprotocol disable / multi-PAN-to-Zigbee migration flow stopped
  using SILABS_FLASHER_ADDON_SLUG in #168431.

Remove the now-unused get_zigbee_flasher_addon_manager() helper and the
orphaned ZIGBEE_FLASHER_ADDON_* and SILABS_FLASHER_ADDON_SLUG constants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:37:26 +02:00
Bram Kragten 9424427a55 Update frontend to 20260624.2 (#175208) 2026-06-30 17:28:21 +02:00
Joost Lekkerkerker 94c5700f22 Add Iotorero virtual integration (#175204) 2026-06-30 17:11:09 +02:00
Karl Beecken 6b7d2b34f9 Mark Teltonika IQS async-dependency done (#175199) 2026-06-30 17:05:08 +02:00
Ariel Ebersberger 939dd7abce Add mypy plugin: flag ==/!= between same-enum operands (#171551) 2026-06-30 16:59:31 +02:00
Manu 1a330ca23e Refactor coordinator in Steam integration (#174661) 2026-06-30 16:31:21 +02:00
dependabot[bot] 5a1cc024dd Bump actions/cache from 5.0.5 to 6.1.0 (#175171)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 16:17:32 +02:00
Erik Montnemery 16a6824799 Report errors in numerical entity triggers (#175093) 2026-06-30 15:08:44 +01:00
Ronald van der Meer 8cb101ae85 Fix Duco state end time being rounded by the integration (#175124) 2026-06-30 15:47:06 +02:00
Franck Nijhof 7950469e31 Avoid blocking call in Anthropic client construction (#174690)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-30 15:37:05 +02:00
GSzabados 9b08858bf0 Bump aioecowitt to 2026.6.0 (#174903) 2026-06-30 15:35:09 +02:00
renovate[bot] b0355f8a9e Update mutagen to 1.48.0 (#175158) 2026-06-30 14:43:30 +02:00
Michael 1c1d95652a Consider current connection type in reconnect action in FRITZ!Box Tools (#175129) 2026-06-30 14:32:00 +02:00
epenet d1275c1128 Deprecate CONCENTRATION_* constants (#175189) 2026-06-30 14:22:23 +02:00
Markus Tuominen 9c34477559 Add entity-unique-id-redundant-platform pylint check (#174442) 2026-06-30 14:34:59 +03:00
TimL 8fc2c24ea0 Bump SMLIGHT to platinum quality (#175182)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-30 13:13:47 +03:00
110 changed files with 2213 additions and 336 deletions
+1 -1
View File
@@ -783,7 +783,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .mypy_cache
key: >-
+1 -1
View File
@@ -108,7 +108,7 @@ class AdGuardHomeSensor(AdGuardHomeEntity, SensorEntity):
"""Initialize AdGuard Home sensor."""
super().__init__(data, entry)
self.entity_description = description
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
[
DOMAIN,
self.adguard.host,
+1 -1
View File
@@ -103,7 +103,7 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
"""Initialize AdGuard Home switch."""
super().__init__(data, entry)
self.entity_description = description
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
[
DOMAIN,
self.adguard.host,
+1 -1
View File
@@ -46,7 +46,7 @@ class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
"""Initialize AdGuard Home update."""
super().__init__(data, entry)
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_unique_id = "_".join( # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
)
@@ -40,7 +40,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
def __init__(self, coordinator: AirGradientCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.serial_number}-update"
self._attr_unique_id = f"{coordinator.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@cached_property
@override
+1 -1
View File
@@ -56,7 +56,7 @@ class AirOSUpdateEntity(AirOSEntity, UpdateEntity):
self.status = status
self.firmware = firmware
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update"
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
@@ -43,7 +43,7 @@ class IPWebcamCamera(MjpegCamera):
username=coordinator.config_entry.data.get(CONF_USERNAME),
password=coordinator.config_entry.data.get(CONF_PASSWORD, ""),
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-camera"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
name=coordinator.config_entry.data[CONF_HOST],
@@ -28,7 +28,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
@@ -66,7 +65,7 @@ from .const import (
TOOL_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
from .coordinator import async_create_client, model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -95,9 +94,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
client = anthropic.AsyncAnthropic(
api_key=data[CONF_API_KEY], http_client=get_async_client(hass)
)
client = await async_create_client(hass, data[CONF_API_KEY])
await client.models.list(timeout=10.0)
@@ -549,9 +546,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
location_data: dict[str, str] = {}
zone_home = self.hass.states.get(ENTITY_ID_HOME)
if zone_home is not None:
client = anthropic.AsyncAnthropic(
api_key=self._get_entry().data[CONF_API_KEY],
http_client=get_async_client(self.hass),
client = await async_create_client(
self.hass, self._get_entry().data[CONF_API_KEY]
)
location_schema = vol.Schema(
{
@@ -1,6 +1,7 @@
"""Coordinator for the Anthropic integration."""
import datetime
from functools import partial
from typing import override
import anthropic
@@ -20,6 +21,19 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
async def async_create_client(
hass: HomeAssistant, api_key: str
) -> anthropic.AsyncAnthropic:
"""Create an Anthropic client."""
return await hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=api_key,
http_client=get_async_client(hass),
)
)
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
@@ -33,7 +47,8 @@ def model_alias(model_id: str) -> str:
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
"""Coordinator using different intervals after success and failure."""
client: anthropic.AsyncAnthropic
config_entry: AnthropicConfigEntry
_client: anthropic.AsyncAnthropic
def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None:
"""Initialize the coordinator."""
@@ -46,8 +61,17 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
update_method=self.async_update_data,
always_update=False,
)
self.client = anthropic.AsyncAnthropic(
api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass)
@property
def client(self) -> anthropic.AsyncAnthropic:
"""Return the Anthropic client."""
return self._client
@override
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self._client = await async_create_client(
self.hass, self.config_entry.data[CONF_API_KEY]
)
@callback
+1 -1
View File
@@ -34,7 +34,7 @@ class AutomaticBackupEvent(BackupManagerBaseEntity, EventEntity):
def __init__(self, coordinator: BackupDataUpdateCoordinator) -> None:
"""Initialize the automatic backup event."""
super().__init__(coordinator)
self._attr_unique_id = "automatic_backup_event" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_unique_id = "automatic_backup_event" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_translation_key = "automatic_backup_event"
@callback
+1 -1
View File
@@ -56,7 +56,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
super().__init__(coordinator)
Camera.__init__(self)
self._camera = camera
self._attr_unique_id = f"{camera.serial}-camera"
self._attr_unique_id = f"{camera.serial}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, camera.serial)},
serial_number=camera.serial,
+1 -1
View File
@@ -187,7 +187,7 @@ async def async_process_advertisements(
)
stack.callback(unload)
if mode == BluetoothScanningMode.ACTIVE:
if mode is BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
+1 -1
View File
@@ -36,7 +36,7 @@ class BroadlinkTime(BroadlinkEntity, TimeEntity):
"""Initialize the sensor."""
super().__init__(device)
self._attr_unique_id = f"{device.unique_id}-device_time"
self._attr_unique_id = f"{device.unique_id}-device_time" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
def _update_state(self, data: dict[str, Any]) -> None:
+2 -2
View File
@@ -93,9 +93,9 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
# Backward compatible unique ID: circuit 1 keeps old format
if circuit == 1:
self._attr_unique_id = f"{mac}-climate"
self._attr_unique_id = f"{mac}-climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
else:
self._attr_unique_id = f"{mac}-climate-{circuit}"
self._attr_unique_id = f"{mac}-climate-{circuit}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
# Set temperature range from per-circuit static data
if (static := data.static.get(circuit)) is not None:
+21 -4
View File
@@ -8,6 +8,7 @@ from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
)
@@ -30,7 +31,11 @@ class CounterBaseIntegerTrigger(EntityTriggerBase):
_schema = ENTITY_STATE_TRIGGER_SCHEMA
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state is valid."""
return _is_integer_state(state)
@@ -63,7 +68,11 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
return False
@@ -74,7 +83,11 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its minimum value."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
return False
@@ -85,7 +98,11 @@ class CounterResetTrigger(CounterValueBaseTrigger):
"""Trigger for reset of counter entities."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
if (init_state := state.attributes.get(CONF_INITIAL)) is None:
return False
+10 -2
View File
@@ -5,7 +5,11 @@ from typing import override
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec
@@ -24,7 +28,11 @@ class CoverTriggerBase(EntityTriggerBase):
return state.state
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the state matches the target cover state."""
domain_spec = self._domain_specs[state.domain]
return self._get_value(state) == domain_spec.target_value
+1 -1
View File
@@ -149,7 +149,7 @@ class DemoWeather(WeatherEntity):
) -> None:
"""Initialize the Demo weather."""
self._attr_name = f"Demo Weather {name}"
self._attr_unique_id = f"demo-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_unique_id = f"demo-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._condition = condition
self._native_temperature = temperature
self._native_temperature_unit = temperature_unit
+10 -2
View File
@@ -10,7 +10,11 @@ from homeassistant.components.event import (
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
NotTriggeredReasonReporter,
StatelessEntityTriggerBase,
Trigger,
)
class DoorbellRangTrigger(StatelessEntityTriggerBase):
@@ -19,7 +23,11 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase):
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the event type is ring."""
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
+1 -3
View File
@@ -83,9 +83,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
translation_key="time_state_end",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda node: (
dt_util.utc_from_timestamp(node.ventilation.time_state_end).replace(
second=0, microsecond=0
)
dt_util.utc_from_timestamp(node.ventilation.time_state_end)
if node.ventilation and node.ventilation.time_state_end != 0
else None
),
+1 -1
View File
@@ -31,7 +31,7 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity):
"""Initialize the thermostat."""
super().__init__(data, thermostat_index)
self._attr_unique_id = (
f"{self.thermostat['identifier']}_notify_{thermostat_index}"
f"{self.thermostat['identifier']}_notify_{thermostat_index}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
)
@override
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aioecowitt==2025.9.2"]
"requirements": ["aioecowitt==2026.6.0"]
}
+6 -1
View File
@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
NotTriggeredReasonReporter,
StatelessEntityTriggerBase,
Trigger,
TriggerConfig,
@@ -42,7 +43,11 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
self._event_types = set(self._options[CONF_EVENT_TYPE])
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the event type matches one of the configured types."""
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
+1 -1
View File
@@ -40,7 +40,7 @@ class FibaroScene(Scene):
self._attr_name = f"{room_name} {fibaro_scene.name}"
self._attr_unique_id = (
f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}"
f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
)
self._attr_extra_state_attributes = {"fibaro_id": fibaro_scene.fibaro_id}
# propagate hidden attribute set in fibaro home center to HA
+14 -5
View File
@@ -11,7 +11,7 @@ from typing import Any, TypedDict, cast, override
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError
from fritzconnection.core.exceptions import FritzActionError, FritzConnectionException
from fritzconnection.lib.fritzcall import FritzCall
from fritzconnection.lib.fritzhosts import FritzHosts
from fritzconnection.lib.fritzstatus import FritzStatus
@@ -267,9 +267,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
) = self._update_device_info()
if self.fritz_status.has_wan_support:
self.device_conn_type = (
self.fritz_status.get_default_connection_service().connection_service
)
self.device_conn_type = self.fritz_status.connection_service
self.device_is_router = self.fritz_status.has_wan_enabled
self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services
@@ -682,7 +680,18 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
async def async_trigger_reconnect(self) -> None:
"""Trigger device reconnect."""
await self.hass.async_add_executor_job(self.connection.reconnect)
try:
await self.hass.async_add_executor_job(
self.connection.call_action,
f"{self.device_conn_type}1",
"ForceTermination",
)
except FritzConnectionException as ex:
# ignore UPnPError:
# errorCode: 707
# errorDescription: DisconnectInProgress
if "disconnectinprogress" not in str(ex).lower():
raise
async def async_trigger_set_guest_password(
self, password: str | None, length: int
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260624.1"]
"requirements": ["home-assistant-frontend==20260624.2"]
}
@@ -34,7 +34,7 @@ class FullyCameraEntity(FullyKioskEntity, Camera):
"""Initialize the camera."""
FullyKioskEntity.__init__(self, coordinator)
Camera.__init__(self)
self._attr_unique_id = f"{coordinator.data['deviceID']}-camera"
self._attr_unique_id = f"{coordinator.data['deviceID']}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_camera_image(
@@ -28,12 +28,7 @@ OTBR_ADDON_NAME = "OpenThread Border Router"
OTBR_ADDON_MANAGER_DATA = "openthread_border_router"
OTBR_ADDON_SLUG = "core_openthread_border_router"
ZIGBEE_FLASHER_ADDON_NAME = "Silicon Labs Flasher"
ZIGBEE_FLASHER_ADDON_MANAGER_DATA = "silabs_flasher"
ZIGBEE_FLASHER_ADDON_SLUG = "core_silabs_flasher"
SILABS_MULTIPROTOCOL_ADDON_SLUG = "core_silabs_multiprotocol"
SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher"
Z2M_EMBER_DOCS_URL = "https://www.zigbee2mqtt.io/guide/adapters/emberznet.html"
@@ -36,9 +36,6 @@ from .const import (
OTBR_ADDON_SLUG,
Z2M_ADDON_NAME,
Z2M_ADDON_SLUG_REGEX,
ZIGBEE_FLASHER_ADDON_MANAGER_DATA,
ZIGBEE_FLASHER_ADDON_NAME,
ZIGBEE_FLASHER_ADDON_SLUG,
)
from .helpers import async_firmware_update_context
@@ -88,7 +85,7 @@ class WaitingAddonManager(AddonManager):
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state == AddonState.NOT_INSTALLED:
if info is not None and info.state is AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
@@ -128,18 +125,6 @@ def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
)
@singleton(ZIGBEE_FLASHER_ADDON_MANAGER_DATA)
@callback
def get_zigbee_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
"""Get the flasher add-on manager."""
return WaitingAddonManager(
hass,
_LOGGER,
ZIGBEE_FLASHER_ADDON_NAME,
ZIGBEE_FLASHER_ADDON_SLUG,
)
@callback
def get_z2m_addon_manager(hass: HomeAssistant, slug: str) -> WaitingAddonManager:
"""Get the Z2M add-on manager."""
+1 -1
View File
@@ -36,7 +36,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity):
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update"
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
@@ -0,0 +1 @@
"""Virtual integration: IoTorero."""
@@ -0,0 +1,6 @@
{
"domain": "iotorero",
"name": "IoTorero",
"integration_type": "virtual",
"supported_by": "esphome"
}
@@ -108,7 +108,7 @@ class KaiterraAirQuality(AirQualityEntity):
@override
def unique_id(self):
"""Return the sensor's unique id."""
return f"{self._device_id}_air_quality"
return f"{self._device_id}_air_quality" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -114,7 +114,7 @@ async def _get_coordinator(
for entry_id in device.config_entries:
entry = call.hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN:
if entry.state != ConfigEntryState.LOADED:
if entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(f"{entry.title} is not loaded")
return entry.runtime_data
@@ -237,7 +237,7 @@ class DemoWeather(WeatherEntity):
) -> None:
"""Initialize the Demo weather."""
self._attr_name = f"Test Weather {name}"
self._attr_unique_id = f"test-weather-{name.lower()}"
self._attr_unique_id = f"test-weather-{name.lower()}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._condition = condition
self._native_temperature = temperature
self._native_temperature_unit = temperature_unit
+1 -1
View File
@@ -33,7 +33,7 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity):
def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.data.serial_number}-update"
self._attr_unique_id = f"{coordinator.data.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -69,7 +69,7 @@ class LiebherrPresentationLight(LiebherrEntity, LightEntity):
) -> None:
"""Initialize the presentation light entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.device_id}_presentation_light"
self._attr_unique_id = f"{coordinator.device_id}_presentation_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
def _light_control(self) -> PresentationLightControl | None:
@@ -42,7 +42,7 @@ class LutronCasetaScene(Scene):
identifiers={(DOMAIN, data.bridge_device["serial"])},
)
self._attr_name = scene["name"]
self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}"
self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_activate(self, **kwargs: Any) -> None:
@@ -9,6 +9,7 @@ from homeassistant.helpers.trigger import (
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
make_entity_transition_trigger,
)
@@ -60,7 +61,11 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
return self.is_muted(from_state) != self.is_muted(to_state)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state."""
if not self._has_volume_attributes(state):
return False
+1 -1
View File
@@ -179,7 +179,7 @@ class TemperatureSensor(BaseSensorEntity):
"""Initialize TemperatureSensor entity."""
super().__init__(coordinator)
self._temp_sensor = nasweb_temp_sensor
self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._temp_sensor.webio_serial)}
)
+1 -1
View File
@@ -96,7 +96,7 @@ class RelaySwitch(SwitchEntity, BaseCoordinatorEntity):
self._attr_translation_key = OUTPUT_TRANSLATION_KEY
self._attr_translation_placeholders = {"index": f"{nasweb_output.index:2d}"}
self._attr_unique_id = (
f"{DOMAIN}.{self._output.webio_serial}.relay_switch.{self._output.index}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
f"{DOMAIN}.{self._output.webio_serial}.relay_switch.{self._output.index}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._output.webio_serial)},
+1 -1
View File
@@ -151,7 +151,7 @@ class NestCameraBaseEntity(Camera, ABC):
self._attr_model = nest_device_info.device_model
self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
# The API "name" field is a unique device identifier.
self._attr_unique_id = f"{self._device.name}-camera"
self._attr_unique_id = f"{self._device.name}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_added_to_hass(self) -> None:
+2 -2
View File
@@ -74,7 +74,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity):
def __init__(self, netatmo_device: NetatmoDevice) -> None:
"""Initialize a Netatmo Presence camera light."""
super().__init__(netatmo_device)
self._attr_unique_id = f"{self.device.entity_id}-light"
self._attr_unique_id = f"{self.device.entity_id}-light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._signal_name = f"{HOME}-{self.home.entity_id}"
self._publishers.extend(
@@ -154,7 +154,7 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity):
def __init__(self, netatmo_device: NetatmoDevice) -> None:
"""Initialize a Netatmo light."""
super().__init__(netatmo_device)
self._attr_unique_id = f"{self.device.entity_id}-light"
self._attr_unique_id = f"{self.device.entity_id}-light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
if self.device.brightness is not None:
self._attr_color_mode = ColorMode.BRIGHTNESS
+1 -1
View File
@@ -69,7 +69,7 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
configuration_url=CONF_URL_ENERGY,
)
self._attr_unique_id = f"{self.home.entity_id}-schedule-select"
self._attr_unique_id = f"{self.home.entity_id}-schedule-select" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
schedule = self.home.get_selected_schedule()
assert schedule
+1 -1
View File
@@ -43,7 +43,7 @@ class NetgearUpdateEntity(
) -> None:
"""Initialize a Netgear device."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.router.serial_number}-update"
self._attr_unique_id = f"{coordinator.router.serial_number}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -51,7 +51,7 @@ class OpenhomeUpdateEntity(UpdateEntity):
def __init__(self, device):
"""Initialize a Linn DS update entity."""
self._device = device
self._attr_unique_id = f"{device.uuid()}-update"
self._attr_unique_id = f"{device.uuid()}-update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, device.uuid()),
@@ -217,7 +217,7 @@ class ConversationFlowHandler(ConfigSubentryFlow):
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage conversation agent configuration."""
if self._get_entry().state != ConfigEntryState.LOADED:
if self._get_entry().state is not ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
if user_input is not None:
+1 -1
View File
@@ -80,7 +80,7 @@ class PlexSensor(SensorEntity):
def __init__(self, hass, plex_server):
"""Initialize the sensor."""
self._attr_unique_id = f"sensor-{plex_server.machine_identifier}"
self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._server = plex_server
self.async_refresh_sensor = Debouncer(
+1 -1
View File
@@ -102,7 +102,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}-climate"
self._attr_unique_id = f"{device_id}-climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._api = coordinator.api
gateway_id: str = self._api.gateway_id
@@ -74,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) ->
translation_key="connect_failed",
) from err
if client_status != RequestStatus.SUCCESS:
if client_status is not RequestStatus.SUCCESS:
raise ConfigEntryNotReady(
translation_domain=entry.domain,
translation_key="client_init_failed",
@@ -62,7 +62,7 @@ class PooldoseCoordinator(DataUpdateCoordinator[StructuredValuesDict]):
translation_key="update_connect_failed",
) from err
if status != RequestStatus.SUCCESS:
if status is not RequestStatus.SUCCESS:
raise UpdateFailed(
translation_domain=self.config_entry.domain,
translation_key="api_status_error",
@@ -1132,7 +1132,7 @@ class PrometheusMetrics:
PERCENTAGE: "percent",
}
default = unit.replace("/", "_per_")
# Unit conversion for CONCENTRATION_MICROGRAMS_PER_CUBIC_METER "μg/m³"
# Unit conversion for UnitOfDensity.MICROGRAMS_PER_CUBIC_METER "μg/m³"
# "μ" == "\u03bc" but the API uses "\u00b5"
default = default.replace("\u03bc", "\u00b5")
default = default.lower()
+1 -1
View File
@@ -65,7 +65,7 @@ class RachioCalendarEntity(
self._attr_translation_placeholders = {
"base": coordinator.base_station[KEY_SERIAL_NUMBER]
}
self._attr_unique_id = f"{coordinator.base_station[KEY_ID]}-calendar"
self._attr_unique_id = f"{coordinator.base_station[KEY_ID]}-calendar" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._previous_event: dict[str, Any] | None = None
@property
+1 -1
View File
@@ -40,7 +40,7 @@ class LeilSaunaLight(LeilSaunaEntity, LightEntity):
"""Initialize the light entity."""
super().__init__(coordinator)
# Override unique_id to differentiate from climate entity
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -45,7 +45,7 @@ class SleepIQLightEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], LightEn
self.light = light
super().__init__(coordinator, bed)
self._attr_name = f"SleepNumber {bed.name} Light {light.outlet_id}"
self._attr_unique_id = f"{bed.id}-light-{light.outlet_id}"
self._attr_unique_id = f"{bed.id}-light-{light.outlet_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_turn_on(self, **kwargs: Any) -> None:
+1 -1
View File
@@ -37,7 +37,7 @@ class SmInfraredEntity(SmEntity, InfraredEmitterEntity):
def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize the SLZB-Ultima infrared."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter"
self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_send_command(self, command: InfraredCommand) -> None:
@@ -12,7 +12,7 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["pysmlight==0.5.2", "bleak-smlight==1.1.0"],
"zeroconf": [
{
@@ -54,11 +54,11 @@ rules:
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
@@ -1,6 +1,7 @@
"""Config flow for Steam integration."""
from collections.abc import Iterator, Mapping
import logging
from typing import Any, override
import steam.api
@@ -17,9 +18,12 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, PLACEHOLDERS
from .coordinator import SteamConfigEntry
_LOGGER = logging.getLogger(__name__)
# To avoid too long request URIs, the amount of ids to request is limited
MAX_IDS_TO_REQUEST = 275
@@ -75,8 +79,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
except Exception:
_LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
@@ -129,8 +133,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
except Exception:
_LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
if not errors:
@@ -166,7 +170,6 @@ class SteamOptionsFlowHandler(OptionsFlowWithReload):
) -> ConfigFlowResult:
"""Manage Steam options."""
if user_input is not None:
await self.hass.config_entries.async_unload(self.config_entry.entry_id)
for _id in self.options[CONF_ACCOUNTS]:
if _id not in user_input[CONF_ACCOUNTS] and (
entity_id := er.async_get(self.hass).async_get_entity_id(
@@ -1,6 +1,5 @@
"""Steam constants."""
import logging
from typing import Final
CONF_ACCOUNT = "account"
@@ -10,7 +9,6 @@ DATA_KEY_COORDINATOR = "coordinator"
DEFAULT_NAME = "Steam"
DOMAIN: Final = "steam_online"
LOGGER = logging.getLogger(__package__)
PLACEHOLDERS = {
"api_key_url": "https://steamcommunity.com/dev/apikey",
@@ -1,10 +1,11 @@
"""Data update coordinator for the Steam integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import override
import steam.api
from steam.api import _interface_method as INTMethod
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
@@ -12,65 +13,116 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_ACCOUNTS, DOMAIN, LOGGER
from .const import CONF_ACCOUNTS, DOMAIN
type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
class SteamDataUpdateCoordinator(
DataUpdateCoordinator[dict[str, dict[str, str | int]]]
):
@dataclass(kw_only=True, frozen=True)
class PlayerData:
"""Steam player data."""
steamid: str
communityvisibilitystate: int
profilestate: int
personaname: str
commentpermission: int | None = None
profileurl: str
avatar: str
avatarmedium: str
avatarfull: str
avatarhash: str
lastlogoff: int
personastate: int
realname: str | None = None
primaryclanid: str | None = None
timecreated: int | None = None
personastateflags: int
loccountrycode: str | None = None
locstatecode: str | None = None
loccityid: int | None = None
gameextrainfo: str | None = None
gameid: str | None = None
level: int | None = None
class SteamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PlayerData]]):
"""Data update coordinator for the Steam integration."""
config_entry: SteamConfigEntry
user_interface: steam.api.interface
player_interface: steam.api.interface
def __init__(self, hass: HomeAssistant, config_entry: SteamConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
logger=LOGGER,
logger=_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=30),
)
self.game_icons: dict[int, str] = {}
self.player_interface: INTMethod = None
self.user_interface: INTMethod = None
steam.api.key.set(self.config_entry.data[CONF_API_KEY])
self.game_icons: dict[str, str] = {}
def _update(self) -> dict[str, dict[str, str | int]]:
@override
async def _async_setup(self) -> None:
"""Set up the coordinator."""
steam.api.key.set(self.config_entry.data[CONF_API_KEY])
self.user_interface = steam.api.interface("ISteamUser")
self.player_interface = steam.api.interface("IPlayerService")
def _update(self) -> dict[str, PlayerData]:
"""Fetch data from API endpoint."""
accounts = self.config_entry.options[CONF_ACCOUNTS]
_ids = list(accounts)
if not self.user_interface or not self.player_interface:
self.user_interface = steam.api.interface("ISteamUser")
self.player_interface = steam.api.interface("IPlayerService")
if not self.game_icons:
for _id in _ids:
res = self.player_interface.GetOwnedGames(
steamid=_id, include_appinfo=1
)["response"]
self.game_icons = self.game_icons | {
game["appid"]: game["img_icon_url"] for game in res.get("games", [])
}
response = self.user_interface.GetPlayerSummaries(steamids=_ids)
players = {
player["steamid"]: player
player["steamid"]: PlayerData(
**player,
level=self.player_interface.GetSteamLevel(steamid=player["steamid"])[
"response"
].get("player_level"),
)
for player in response["response"]["players"]["player"]
if player["steamid"] in _ids
}
for value in players.values():
data = self.player_interface.GetSteamLevel(steamid=value["steamid"])
value["level"] = data["response"].get("player_level")
for player in players.values():
if player.gameid and player.gameid not in self.game_icons:
games = self.player_interface.GetOwnedGames(
steamid=player.steamid,
include_appinfo=1,
include_played_free_games=True,
)["response"].get("games", [])
self.game_icons.update(
{str(game["appid"]): game["img_icon_url"] for game in games}
)
return players
@override
async def _async_update_data(self) -> dict[str, dict[str, str | int]]:
async def _async_update_data(self) -> dict[str, PlayerData]:
"""Send request to the executor."""
try:
return await self.hass.async_add_executor_job(self._update)
except steam.api.HTTPTimeoutError as ex:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from ex
except steam.api.HTTPError as ex:
if "401" in str(ex):
raise ConfigEntryAuthFailed from ex
raise UpdateFailed(ex) from ex
_LOGGER.debug("Full exception:", exc_info=True)
if "401" in str(ex) or "403" in str(ex):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_exception",
) from ex
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="request_exception",
) from ex
+16 -18
View File
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any, cast, override
from typing import Any, override
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
@@ -20,7 +20,7 @@ from .const import (
STEAM_MAIN_IMAGE_FILE,
STEAM_STATUSES,
)
from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator
from .coordinator import PlayerData, SteamConfigEntry, SteamDataUpdateCoordinator
from .entity import SteamEntity
PARALLEL_UPDATES = 1
@@ -36,18 +36,18 @@ class SteamSensor(StrEnum):
class SteamSensorEntityDescription(SensorEntityDescription):
"""Steam sensor description."""
value_fn: Callable[[dict[str, Any]], StateType]
name_fn: Callable[[dict[str, Any]], str]
entity_picture_fn: Callable[[dict[str, Any]], str] | None = None
value_fn: Callable[[PlayerData], StateType]
name_fn: Callable[[PlayerData], str]
entity_picture_fn: Callable[[PlayerData], str] | None = None
SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = (
SteamSensorEntityDescription(
key=SteamSensor.ACCOUNT,
translation_key=SteamSensor.ACCOUNT,
value_fn=lambda x: STEAM_STATUSES[x["personastate"]],
name_fn=lambda x: x["personaname"],
entity_picture_fn=lambda x: x["avatarfull"],
value_fn=lambda x: STEAM_STATUSES[x.personastate],
name_fn=lambda x: x.personaname,
entity_picture_fn=lambda x: x.avatarfull,
),
)
@@ -106,29 +106,27 @@ class SteamSensorEntity(SteamEntity, SensorEntity):
player = self.coordinator.data[self._steamid]
attrs: dict[str, str | int | datetime] = {}
if game := player.get("gameextrainfo"):
if game := player.gameextrainfo:
attrs["game"] = game
if game_id := player.get("gameid"):
if game_id := player.gameid:
attrs["game_id"] = game_id
game_url = f"{STEAM_API_URL}{player['gameid']}/"
game_url = f"{STEAM_API_URL}{player.gameid}/"
attrs["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}"
attrs["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}"
if info := self._get_game_icon(player):
attrs["game_icon"] = f"{STEAM_ICON_URL}{game_id}/{info}.jpg"
if last_online := cast(int | None, player.get("lastlogoff")):
if last_online := player.lastlogoff:
attrs["last_online"] = dt_util.as_local(
dt_util.utc_from_timestamp(last_online)
)
if level := self.coordinator.data[self._steamid]["level"]:
if level := self.coordinator.data[self._steamid].level:
attrs["level"] = level
return attrs
def _get_game_icon(self, player: dict) -> str | None:
def _get_game_icon(self, player: PlayerData) -> str | None:
"""Get game icon identifier."""
if player.get("gameid") in self.coordinator.game_icons:
return self.coordinator.game_icons[player["gameid"]]
# Reset game icons to have coordinator get id for new game
self.coordinator.game_icons = {}
if player.gameid is not None and player.gameid in self.coordinator.game_icons:
return self.coordinator.game_icons[player.gameid]
return None
@property
@@ -70,6 +70,17 @@
}
}
},
"exceptions": {
"auth_exception": {
"message": "Failed to connect to Steam due to an authentication error"
},
"request_exception": {
"message": "Failed to connect to Steam due to a request error"
},
"timeout_exception": {
"message": "Failed to connect to Steam due to a request timeout"
}
},
"options": {
"error": {
"unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends"
+1 -1
View File
@@ -58,7 +58,7 @@ class SwitchbotAirPurifierLightEntity(SwitchbotEntity, LightEntity, RestoreEntit
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.base_unique_id}_light"
self._attr_unique_id = f"{coordinator.base_unique_id}_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_added_to_hass(self) -> None:
@@ -71,6 +71,6 @@ rules:
stale-devices: todo
# Platinum
async-dependency: todo
async-dependency: done
inject-websession: done
strict-typing: done
+1 -1
View File
@@ -63,7 +63,7 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity):
"""Initialize TOLO Sauna Climate entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_climate"
self._attr_unique_id = f"{entry.entry_id}_climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -32,7 +32,7 @@ class ToloFan(ToloSaunaCoordinatorEntity, FanEntity):
"""Initialize TOLO fan entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_fan"
self._attr_unique_id = f"{entry.entry_id}_fan" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -33,7 +33,7 @@ class ToloLight(ToloSaunaCoordinatorEntity, LightEntity):
"""Initialize TOLO Sauna Light entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_light"
self._attr_unique_id = f"{entry.entry_id}_light" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
@@ -62,7 +62,7 @@ class ToonBinarySensor(ToonEntity, BinarySensorEntity):
self._attr_unique_id = (
# This unique ID is a bit ugly and contains unneeded information.
# It is here for legacy / backward compatible reasons.
f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_binary_sensor_{description.key}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_binary_sensor_{description.key}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
)
@property
+1 -1
View File
@@ -66,7 +66,7 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity):
PRESET_SLEEP,
]
self._attr_unique_id = (
f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_climate" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_climate" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
)
@property
+1 -1
View File
@@ -81,7 +81,7 @@ class ToonSensor(ToonEntity, SensorEntity):
self._attr_unique_id = (
# This unique ID is a bit ugly and contains unneeded information.
# It is here for legacy / backward compatible reasons.
f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_sensor_{description.key}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_sensor_{description.key}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform
)
@property
+1 -1
View File
@@ -67,7 +67,7 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
self._device_control = self._device.light_control
self._device_data = self._device_control.lights[0]
self._attr_unique_id = f"light-{gateway_id}-{self._device_id}"
self._attr_unique_id = f"light-{gateway_id}-{self._device_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._hs_color = None
# Calculate supported color modes
+1 -1
View File
@@ -8,5 +8,5 @@
"integration_type": "entity",
"loggers": ["mutagen"],
"quality_scale": "internal",
"requirements": ["mutagen==1.47.0"]
"requirements": ["mutagen==1.48.0"]
}
@@ -67,7 +67,7 @@ class ProtectSiren(SirenEntity):
"""Initialise the siren entity."""
self.data = data
self._siren_id = siren.id
self._attr_unique_id = f"{siren.mac}_siren"
self._attr_unique_id = f"{siren.mac}_siren" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
nvr = data.api.bootstrap.nvr
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, siren.mac)},
+1 -1
View File
@@ -26,7 +26,7 @@ class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity):
"""Initialize the Vallox date."""
super().__init__(name, coordinator)
self._attr_unique_id = f"{self._device_uuid}-filter_change_date"
self._attr_unique_id = f"{self._device_uuid}-filter_change_date" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -42,7 +42,7 @@ class VelbusSelect(VelbusEntity, SelectEntity):
"""Initialize a select Velbus entity."""
super().__init__(channel)
self._attr_options = self._channel.get_options()
self._attr_unique_id = f"{self._attr_unique_id}-program_select"
self._attr_unique_id = f"{self._attr_unique_id}-program_select" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@api_call
@override
+1 -1
View File
@@ -42,7 +42,7 @@ class VeluxScene(Scene):
"""Init velux scene."""
self.scene = scene
# Renaming scenes in gateway keeps scene_id stable, we can use it as unique_id
self._attr_unique_id = f"{config_entry_id}_scene_{scene.scene_id}"
self._attr_unique_id = f"{config_entry_id}_scene_{scene.scene_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_name = scene.name
# Associate scenes with the gateway device (where they are stored)
@@ -75,7 +75,7 @@ class VictronBaseEntity(Entity):
# 3. Dynamic units come from user-configured MQTT topics (e.g.
# SwitchableOutput Settings/Unit) and have no translation file
# entry, so we must set the unit programmatically.
or self._metric.metric_type == MetricType.DYNAMIC
or self._metric.metric_type is MetricType.DYNAMIC
):
return unit_of_measurement
+1 -1
View File
@@ -41,7 +41,7 @@ class VistapoolLight(VistapoolEntity, LightEntity):
def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None:
"""Initialize the light entity."""
super().__init__(coordinator)
self._attr_unique_id = self.build_unique_id("pool_light")
self._attr_unique_id = self.build_unique_id("pool_light") # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
@@ -93,7 +93,7 @@ class WyomingConversationEntity(
)
self._supported_languages = list(model_languages)
self._attr_unique_id = f"{config_entry.entry_id}-conversation"
self._attr_unique_id = f"{config_entry.entry_id}-conversation" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -53,7 +53,7 @@ class WyomingSttProvider(stt.SpeechToTextEntity):
self._supported_languages = list(model_languages)
self._attr_name = asr_service.name
self._attr_unique_id = f"{config_entry.entry_id}-stt"
self._attr_unique_id = f"{config_entry.entry_id}-stt" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@property
@override
+1 -1
View File
@@ -86,7 +86,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity):
self._attr_default_language = self._attr_supported_languages[0]
self._attr_name = self._tts_service.name
self._attr_unique_id = f"{config_entry.entry_id}-tts"
self._attr_unique_id = f"{config_entry.entry_id}-tts" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@callback
@override
@@ -55,7 +55,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity):
for ww in wake_service.models
]
self._attr_name = wake_service.name
self._attr_unique_id = f"{config_entry.entry_id}-wake_word"
self._attr_unique_id = f"{config_entry.entry_id}-wake_word" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def get_supported_wake_words(self) -> list[wake_word.WakeWord]:
+1 -1
View File
@@ -67,7 +67,7 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity):
) -> None:
"""Init YoLink Thermostat."""
super().__init__(config_entry, coordinator)
self._attr_unique_id = f"{coordinator.device.device_id}_climate"
self._attr_unique_id = f"{coordinator.device.device_id}_climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
self._attr_fan_modes = [FAN_ON, FAN_AUTO]
self._attr_min_temp = -10
+1 -1
View File
@@ -41,7 +41,7 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity):
) -> None:
"""Init YoLink Lock."""
super().__init__(config_entry, coordinator)
self._attr_unique_id = f"{coordinator.device.device_id}_lock_state"
self._attr_unique_id = f"{coordinator.device.device_id}_lock_state" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@callback
@override
+21 -4
View File
@@ -36,6 +36,7 @@ from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
TriggerActionRunner,
TriggerConfig,
@@ -211,7 +212,11 @@ class EnteredZoneTrigger(ZoneTriggerBase):
return not self._in_target_zone(from_state)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the entity is now in the selected zone."""
return self._in_target_zone(state)
@@ -225,7 +230,11 @@ class LeftZoneTrigger(ZoneTriggerBase):
return self._in_target_zone(from_state)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the entity is no longer in the selected zone."""
return not self._in_target_zone(state)
@@ -279,7 +288,11 @@ class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions to an occupied state."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the zone is occupied."""
return self._is_occupied(state)
@@ -293,7 +306,11 @@ class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions from occupied to unoccupied."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the zone is empty (count == 0)."""
return self._occupancy_count(state) == 0
+1 -1
View File
@@ -196,7 +196,7 @@ class ZWaveFirmwareUpdateEntity(ZWaveNodeBaseEntity, UpdateEntity):
# Entity class attributes
self._attr_name = "Firmware"
self._attr_unique_id = f"{self._base_unique_id}.firmware_update"
self._attr_unique_id = f"{self._base_unique_id}.firmware_update" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
self._attr_installed_version = node.firmware_version
@property
+17 -10
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Final
from .generated.entity_platforms import EntityPlatforms
from .helpers.deprecation import (
DeprecatedConstant,
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
@@ -798,21 +799,27 @@ class UnitOfRatio(StrEnum):
# Concentration units
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = UnitOfDensity.GRAMS_PER_CUBIC_METER.value
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = (
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER.value
_DEPRECATED_CONCENTRATION_GRAMS_PER_CUBIC_METER = DeprecatedConstantEnum(
UnitOfDensity.GRAMS_PER_CUBIC_METER, "2027.8"
)
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = (
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER.value
_DEPRECATED_CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER = DeprecatedConstantEnum(
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER, "2027.8"
)
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = (
UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT.value
_DEPRECATED_CONCENTRATION_MICROGRAMS_PER_CUBIC_METER = DeprecatedConstantEnum(
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER, "2027.8"
)
_DEPRECATED_CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT = DeprecatedConstantEnum(
UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT, "2027.8"
)
_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant(
"p/m³", "p/m³", "2027.7"
"p/m³", "p/m³", "2027.8"
)
_DEPRECATED_CONCENTRATION_PARTS_PER_MILLION = DeprecatedConstantEnum(
UnitOfRatio.PARTS_PER_MILLION, "2027.8"
)
_DEPRECATED_CONCENTRATION_PARTS_PER_BILLION = DeprecatedConstantEnum(
UnitOfRatio.PARTS_PER_BILLION, "2027.8"
)
CONCENTRATION_PARTS_PER_MILLION: Final = UnitOfRatio.PARTS_PER_MILLION.value
CONCENTRATION_PARTS_PER_BILLION: Final = UnitOfRatio.PARTS_PER_BILLION.value
PERCENTAGE: Final = UnitOfRatio.PERCENTAGE.value
+5 -5
View File
@@ -490,11 +490,11 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
)
if flow.flow_id not in self._progress:
# The flow was removed during the step, raise UnknownFlow
# unless the result is an abort. Uses `!=` (not `is not`) because
# this runs before the legacy-string normalization below, and
# out-of-tree flow handlers may still return raw "abort".
if result["type"] != FlowResultType.ABORT: # type: ignore[ha-enum-identity-compare,unused-ignore]
# The flow was removed during the step, raise UnknownFlow unless
# the result is an abort. Compares against the string value
# because this runs before the legacy-string normalization
# below, and out-of-tree flow handlers may still return raw "abort".
if result["type"] != FlowResultType.ABORT.value:
raise UnknownFlow
return result
@@ -3318,6 +3318,11 @@
"config_flow": true,
"iot_class": "local_polling"
},
"iotorero": {
"name": "IoTorero",
"integration_type": "virtual",
"supported_by": "esphome"
},
"iotty": {
"name": "iotty",
"integration_type": "device",
+179 -25
View File
@@ -374,6 +374,10 @@ ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
def _report_not_triggered_noop(reason: str, /, **data: Any) -> None:
"""Swallow a not-triggered report; used when diagnostics are not wanted."""
class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
@@ -431,7 +435,11 @@ class EntityTriggerBase(Trigger):
"""
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the state is a target state for the trigger.
Called only after `state.state` has been filtered against
@@ -439,6 +447,12 @@ class EntityTriggerBase(Trigger):
check. Default: any non-excluded state is a target. Override
to restrict (specific to_states, value within a threshold,
etc.).
When the state cannot fire the trigger, subclasses may use
`report_not_triggered` to record an interesting reason - e.g. a
non-numeric value or an unsupported unit - in the automation trace.
Callers that don't collect diagnostics (e.g. `count_matches`) pass
`_report_not_triggered_noop`.
"""
return True
@@ -481,7 +495,7 @@ class EntityTriggerBase(Trigger):
if state is None or not self._should_include(state):
continue
included += 1
if self.is_valid_state(state):
if self.is_valid_state(state, _report_not_triggered_noop):
matches += 1
return matches, included
@@ -509,7 +523,7 @@ class EntityTriggerBase(Trigger):
if (
to_state is None
or to_state.state in self._excluded_states
or not self.is_valid_state(to_state)
or not self.is_valid_state(to_state, _report_not_triggered_noop)
):
pending_timers.pop(entity_id)()
return
@@ -593,11 +607,19 @@ class EntityTriggerBase(Trigger):
if not from_state or not to_state:
return
# The trigger should never fire if the new state is excluded
# or not a target state.
if to_state.state in self._excluded_states or not self.is_valid_state(
to_state
):
if to_state.state in self._excluded_states:
return
@callback
def report_not_triggered(reason: str, /, **data: Any) -> None:
"""Report why this evaluated change did not fire the trigger."""
if did_not_trigger is None:
return
did_not_trigger(
NotTriggeredInfo(reason=reason, data=data), event.context
)
if not self.is_valid_state(to_state, report_not_triggered):
return
# The trigger should never fire if the origin state is excluded
@@ -708,7 +730,11 @@ class EntityTargetStateTriggerBase(EntityTriggerBase):
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state."""
return self._get_tracked_value(state) in self._to_states
@@ -729,7 +755,11 @@ class EntityTransitionTriggerBase(EntityTriggerBase):
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected states."""
return self._get_tracked_value(state) in self._to_states
@@ -748,7 +778,11 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the new state is different from the origin state."""
return bool(self._get_tracked_value(state) != self._from_state)
@@ -804,7 +838,11 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
return True
return unit == self._valid_unit
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
def _get_threshold_value(
self,
threshold: ThresholdConfig | None,
report_not_triggered: NotTriggeredReasonReporter,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
@@ -813,14 +851,29 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
# Entity not found
report_not_triggered(
"threshold_entity_not_found",
entity_id=threshold.entity,
)
return None
if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
# Entity unit does not match the expected unit
report_not_triggered(
"threshold_unit_not_supported",
entity_id=threshold.entity,
unit=unit,
)
return None
try:
return float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
report_not_triggered(
"threshold_value_not_numeric",
entity_id=threshold.entity,
value=state.state,
)
return None
@override
@@ -841,11 +894,46 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
# Entity state is not a valid number
return None
def _report_tracked_value_problem(
self, state: State, report_not_triggered: NotTriggeredReasonReporter
) -> None:
"""Report why `_get_tracked_value` rejected this state.
Called only when the tracked value is invalid. It mirrors the failure
modes of `_get_tracked_value` - which integrations override, so the
reason is derived here rather than reported inline: a state-sourced
value with an unsupported unit, otherwise a value that is not a number.
"""
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
report_not_triggered(
"entity_unit_not_supported",
entity_id=state.entity_id,
unit=unit,
)
return
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
report_not_triggered(
"entity_value_not_numeric",
entity_id=state.entity_id,
value=raw_value,
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state or state attribute matches the expected one."""
# Handle missing or None value case first to avoid expensive exceptions
if (current_value := self._get_tracked_value(state)) is None:
self._report_tracked_value_problem(state, report_not_triggered)
return False
if self._threshold_type == NumericThresholdType.ANY:
@@ -854,20 +942,32 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
return True
if self._threshold_type == NumericThresholdType.ABOVE:
if (limit := self._get_threshold_value(self.threshold)) is None:
if (
limit := self._get_threshold_value(self.threshold, report_not_triggered)
) is None:
# Entity not found or invalid number, don't trigger
return False
return current_value > limit
if self._threshold_type == NumericThresholdType.BELOW:
if (limit := self._get_threshold_value(self.threshold)) is None:
if (
limit := self._get_threshold_value(self.threshold, report_not_triggered)
) is None:
# Entity not found or invalid number, don't trigger
return False
return current_value < limit
# Mode is BETWEEN or OUTSIDE
lower_limit = self._get_threshold_value(self.lower_threshold)
upper_limit = self._get_threshold_value(self.upper_threshold)
if lower_limit is None or upper_limit is None:
# Mode is BETWEEN or OUTSIDE. Evaluate the lower limit first so at most
# one not-triggered reason is reported per change.
lower_limit = self._get_threshold_value(
self.lower_threshold, report_not_triggered
)
if lower_limit is None:
# Entity not found or invalid number, don't trigger
return False
upper_limit = self._get_threshold_value(
self.upper_threshold, report_not_triggered
)
if upper_limit is None:
# Entity not found or invalid number, don't trigger
return False
between = lower_limit <= current_value <= upper_limit
@@ -887,7 +987,41 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@override
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
def _report_tracked_value_problem(
self, state: State, report_not_triggered: NotTriggeredReasonReporter
) -> None:
"""Report why `_get_tracked_value` rejected this state.
Mirrors the with-unit failure modes: a value that is not a number,
otherwise a unit that cannot be converted to the base unit.
"""
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
try:
float(raw_value)
except TypeError, ValueError:
report_not_triggered(
"entity_value_not_numeric",
entity_id=state.entity_id,
value=raw_value,
)
return
report_not_triggered(
"entity_unit_not_supported",
entity_id=state.entity_id,
unit=self._get_entity_unit(state),
)
@override
def _get_threshold_value(
self,
threshold: ThresholdConfig | None,
report_not_triggered: NotTriggeredReasonReporter,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
@@ -900,19 +1034,32 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
# Entity not found
report_not_triggered(
"threshold_entity_not_found",
entity_id=threshold.entity,
)
return None
try:
value = float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
report_not_triggered(
"threshold_value_not_numeric",
entity_id=threshold.entity,
value=state.state,
)
return None
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
try:
return self._unit_converter.convert(
value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit
)
return self._unit_converter.convert(value, unit, self._base_unit)
except HomeAssistantError:
# Unit conversion failed (i.e. incompatible units), treat as invalid number
report_not_triggered(
"threshold_unit_not_supported",
entity_id=threshold.entity,
unit=unit,
)
return None
@override
@@ -1009,7 +1156,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the tracked value crossed into the threshold range."""
return not self.is_valid_state(from_state)
return not self.is_valid_state(from_state, _report_not_triggered_noop)
def _make_numerical_state_crossed_threshold_with_unit_schema(
@@ -1289,6 +1436,13 @@ class TriggerNotTriggeredReporter(Protocol):
"""Report that the trigger did not fire."""
class NotTriggeredReasonReporter(Protocol):
"""Reports why an evaluated change did not fire an entity trigger."""
def __call__(self, reason: str, /, **data: Any) -> None:
"""Report, with diagnostic data, why the change did not fire."""
class TriggerNotTriggeredAction(Protocol):
"""Protocol type for the did_not_trigger consumer callback.
+2 -2
View File
@@ -39,13 +39,13 @@ habluetooth==6.25.1
hass-nabucasa==2.2.0
hassil==3.8.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260624.1
home-assistant-frontend==20260624.2
home-assistant-intents==2026.6.24
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
lru-dict==1.4.1
mutagen==1.47.0
mutagen==1.48.0
openai==2.21.0
orjson==3.11.9
packaging>=23.1
Generated
+1 -1
View File
@@ -5,7 +5,7 @@
[mypy]
python_version = 3.14
platform = linux
plugins = pydantic.mypy
plugins = pydantic.mypy, mypy_plugins/enum_identity_compare.py
show_error_codes = true
follow_imports = normal
native_parser = true
+1
View File
@@ -0,0 +1 @@
"""Home Assistant mypy plugins."""
+184
View File
@@ -0,0 +1,184 @@
"""Mypy plugin: flag ``==``/``!=`` between two operands of the same enum class.
Scope is intentionally narrow: only **plain ``enum.Enum`` subclasses** are
flagged by default, because Python's ``Enum.__eq__`` is identity-based —
``a == b`` and ``a is b`` produce the same result there.
Any enum with a base outside the ``Enum`` hierarchy is **skipped**, because
such a mixin typically gives it a value-based ``__eq__``: a primitive
(``StrEnum``/``IntEnum`` or the legacy ``class X(str, Enum)`` /
``class X(int, Enum)`` / ``class X(float, Enum)`` form), a ``@dataclass``, or a
``NamedTuple``. Their ``==`` compares by value and accepts raw operands:
callers routinely pass ``"on"`` where a ``HVACMode`` parameter is annotated,
and ``==`` silently makes that work while ``is`` silently breaks it. Switching
those sites to ``is`` is a runtime-behavior change, not a refactor.
The check is deliberately conservative it is decided from the MRO shape, not
from where ``__eq__`` is defined (mypy does not model the ``__eq__`` a
``@dataclass`` synthesizes). So an enum whose only non-``Enum`` base is a plain
helper class that does not override ``__eq__`` is still skipped even though it
is really identity-based. That under-flags such enums rather than risk an
unsafe ``==````is`` rewrite the safe direction.
The ``_FRAMEWORK_GUARANTEED_ENUMS`` set carves back in
``StrEnum``/``IntEnum`` classes where the HA framework itself controls
every callsite and guarantees the value is the enum instance currently
just ``homeassistant.data_entry_flow.FlowResultType``.
``enum.Flag``/``enum.IntFlag`` are always exempt bitwise ``==`` is
idiomatic there.
"""
from collections.abc import Callable
from mypy.errorcodes import ErrorCode
from mypy.nodes import TypeInfo
from mypy.plugin import MethodContext, Plugin
from mypy.types import Instance, LiteralType, Type, UnionType, get_proper_type
ENUM_IDENTITY = ErrorCode(
"home-assistant-enum-identity-compare",
"Use `is`/`is not` to compare two operands of the same enum class.",
"Home Assistant",
)
_PLAIN_ENUM_BASE = "enum.Enum"
_FLAG_BASES = frozenset({"enum.Flag", "enum.IntFlag"})
def _is_value_mixin(base: TypeInfo) -> bool:
"""True if ``base`` gives an enum value-based ``__eq__``.
The plugin should fire only when ``==`` is identity-based, which holds iff
the enum has no mixin outside the ``Enum`` hierarchy. Any non-``Enum`` base
a primitive (``str``/``int``/``float``/), a ``@dataclass``, or a
``NamedTuple`` provides value comparison, so ``is`` is not equivalent to
``==``. Decided structurally from the MRO (independent of how ``__eq__`` is
synthesized): a base is value-mixing unless it is ``object`` or is itself
part of the ``Enum`` hierarchy (e.g. an intermediate ``class Base(Enum)``,
which keeps the enum identity-based and therefore still flaggable).
"""
if base.fullname == "builtins.object":
return False
return not any(b.fullname == _PLAIN_ENUM_BASE for b in base.mro)
# StrEnum/IntEnum classes where every callsite assigning the value is
# framework-controlled, so the runtime value is guaranteed to be the
# enum instance (never a raw string/int). Audited additions only.
_FRAMEWORK_GUARANTEED_ENUMS = frozenset(
{
"homeassistant.data_entry_flow.FlowResultType",
}
)
def _enum_class(t: Type | None) -> TypeInfo | None:
"""Return the enum TypeInfo if t resolves to a tracked enum class.
Handles three shapes:
- ``Instance``: the direct case, e.g. ``source: SourceCodes``.
- ``LiteralType``: a single literal enum member, e.g. ``Literal[E.A]``.
Peeled to its enum-class ``fallback``.
- ``UnionType``: if all variants resolve to the same enum class, that
class is passed on.
Returns ``None`` for:
- ``Flag``/``IntFlag`` (bitwise ``==`` is idiomatic)
- value-based enums not in ``_FRAMEWORK_GUARANTEED_ENUMS``
- Anything else (``Any``, ``None``, mixed unions, etc.)
"""
if t is None:
return None
pt = get_proper_type(t)
if isinstance(pt, UnionType):
common: TypeInfo | None = None
for variant in pt.items:
v_info = _enum_class(variant)
if v_info is None:
return None
if common is None:
common = v_info
elif common.fullname != v_info.fullname:
return None
return common
if isinstance(pt, LiteralType):
pt = pt.fallback
if not isinstance(pt, Instance):
return None
info = pt.type
has_enum_base = False
has_value_based_base = False
for base in info.mro:
fn = base.fullname
if fn in _FLAG_BASES:
return None
if fn == _PLAIN_ENUM_BASE:
has_enum_base = True
continue
if _is_value_mixin(base):
has_value_based_base = True
if not has_enum_base:
return None
if has_value_based_base and info.fullname not in _FRAMEWORK_GUARANTEED_ENUMS:
# Value-based enum without explicit trust — `is` may diverge from
# `==` when callers pass the underlying primitive value.
return None
return info
def _emit(ctx: MethodContext, op: str, enum_cls: TypeInfo) -> Type:
"""Emit the warning and return the default return type."""
replacement = "is" if op == "==" else "is not"
ctx.api.fail(
f"Use `{replacement}` instead of `{op}` to compare "
f"`{enum_cls.name}` enum instances",
ctx.context,
code=ENUM_IDENTITY,
)
return ctx.default_return_type
def _make_hook(op: str) -> Callable[[MethodContext], Type]:
"""Return a method-hook callback for ``__eq__`` (``==``) or ``__ne__``."""
def hook(ctx: MethodContext) -> Type:
left_enum = _enum_class(ctx.type)
if left_enum is None:
return ctx.default_return_type
right_type = ctx.arg_types[0][0] if ctx.arg_types and ctx.arg_types[0] else None
right_enum = _enum_class(right_type)
if right_enum is None:
return ctx.default_return_type
if left_enum.fullname != right_enum.fullname:
return ctx.default_return_type
return _emit(ctx, op, left_enum)
return hook
_EQ_HOOK = _make_hook("==")
_NE_HOOK = _make_hook("!=")
class HassEnumIdentityPlugin(Plugin):
"""Mypy plugin entry point."""
def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | None:
"""Return a hook for ``__eq__``/``__ne__`` calls, else ``None``.
``a == b`` desugars to ``a.__eq__(b)``; ``a != b`` to ``__ne__``.
Mypy reports the method's fullname, which we use to tell which
operator triggered the call.
"""
if fullname.endswith(".__eq__"):
return _EQ_HOOK
if fullname.endswith(".__ne__"):
return _NE_HOOK
return None
def plugin(version: str) -> type[Plugin]:
"""Mypy plugin entry point."""
return HassEnumIdentityPlugin
+40 -12
View File
@@ -111,6 +111,7 @@ Every check has a code following the
| `W7423` | [`home-assistant-missing-entity-unique-id`](#w7423-home-assistant-missing-entity-unique-id) | Entity class does not statically guarantee a non-None unique id |
| `W7424` | [`home-assistant-entity-unique-id-static`](#w7424-home-assistant-entity-unique-id-static) | Entity class sets `_attr_unique_id` to a static string at class level |
| `W7425` | [`home-assistant-entity-unique-id-redundant-domain`](#w7425-home-assistant-entity-unique-id-redundant-domain) | Entity unique ID references the `DOMAIN` constant or includes the integration's domain as a string-literal delimited segment |
| `W7427` | [`home-assistant-entity-unique-id-redundant-platform`](#w7427-home-assistant-entity-unique-id-redundant-platform) | Entity unique ID includes the entity platform name (e.g. `sensor`, `light`) as a delimited string-literal segment |
| `C7412` | [`home-assistant-entity-description-redundant-default`](#c7412-home-assistant-entity-description-redundant-default) | Setting an EntityDescription field to its default value is redundant |
| `C7413` | [`home-assistant-duplicate-const`](#c7413-home-assistant-duplicate-const) | Constant duplicates one in `homeassistant.const` with the same value |
| `E7405` | [`home-assistant-action-swallowed-exception`](#e7405-home-assistant-action-swallowed-exception) | Action handler must not swallow exceptions |
@@ -564,24 +565,26 @@ serial, MAC, etc.) or declaring the integration as
Hosts format-related checks on the value an entity uses for its unique
ID (`_attr_unique_id` assignments and `unique_id` property/method
returns). Unlike the gated `entity-unique-id` quality-scale checks,
these checks are **not** gated on `quality_scale.yaml` claims, and they
fire on every class inheriting from `Entity` anywhere inside an
integration (including shared bases in `entity.py` and mixins/abstract
bases subclassed by other classes in the same module). Once an
integration ships with malformed unique_ids, the IDs cannot be changed
without an entity-registry migration, so the antipatterns must be
caught before they ship.
returns). Migrating unique_ids after an integration has shipped risks
disrupting existing users, so the antipatterns must be caught before
they ship. Unlike the gated `entity-unique-id` quality-scale checks,
these checks are **not** gated on `quality_scale.yaml` claims. Both
checks inspect every class inheriting from `Entity` in their
respective scopes (including shared bases and mixins/abstract bases
subclassed by other classes in the same module); see the per-rule
sections below for the module scope.
### `W7425`: `home-assistant-entity-unique-id-redundant-domain`
The entity registry already keys uniqueness on `(domain, platform,
unique_id)` where `platform` is the integration's name (as declared
by the `"domain"` field in `manifest.json`). Any prefix in the
unique_id that repeats the integration's name duplicates information
already present in the registry key.
by the `"domain"` field in `manifest.json`). Any occurrence of the
integration's name in the unique_id duplicates information already
present in the registry key.
The rule fires when the value used for the entity's unique id either:
The rule fires in every integration module (entity-platform modules,
`entity.py`, `__init__.py`, ...) when the value used for the entity's
unique id either:
- references the `DOMAIN` name at any depth (e.g.
`f"{DOMAIN}_{entry.entry_id}"`), or
@@ -600,6 +603,31 @@ Three locations are scanned: class-body `_attr_unique_id` assignments,
Aliased imports (`from .const import DOMAIN as MY_DOMAIN`) are not
scanned.
### `W7427`: `home-assistant-entity-unique-id-redundant-platform`
In `(domain, platform, unique_id)` the `domain` field is the entity
platform (e.g. `sensor`, `light`, `binary_sensor` — derived from the
module the entity lives in), so embedding that name as a delimited
segment of the unique id duplicates information already in the
registry key.
The rule fires when the value used for the entity's unique id
contains the current module's platform name as a delimited segment of
any string literal. The same boundary rules as `W7425` apply: a
segment is considered delimited when bordered by a non-alphanumeric
character (`_`, `-`, `.`, `:`, space, ...) or a string boundary, so
unrelated substrings like `"highlight-..."` or `"light2"` don't match
`light`.
Scope is narrower than `W7425`: only files whose integration
sub-module path keys off a known entity platform name are checked.
Both single-file platform modules (`sensor.py`, `light.py`, ...) and
platform packages (`sensor/__init__.py`, `sensor/helpers.py`, ...)
are in scope. `entity.py`, `__init__.py` at the integration root, and
other helper sub-modules are out of scope because the platform
context is ambiguous there. The three in-class scan locations are
the same as for `W7425`.
## `home_assistant_entity_description_defaults` checker
@@ -2,21 +2,23 @@
Hosts format-related checks on the value an entity uses for its unique
ID (``_attr_unique_id`` assignments and ``unique_id`` property/method
returns). Once an integration ships with malformed unique_ids, the IDs
cannot be changed without an entity-registry migration, so these checks
are **not** gated on ``quality_scale.yaml`` claims, and they fire on
every class inheriting from ``Entity`` anywhere inside an integration
(including shared bases in ``entity.py`` and mixins/abstract bases
subclassed by other classes in the same module).
returns). Migrating unique_ids after an integration has shipped risks
disrupting existing users, so these checks are **not** gated on
``quality_scale.yaml`` claims. Both checks inspect every class
inheriting from ``Entity`` in their respective scopes (including
shared bases and mixins/abstract bases subclassed by other classes in
the same module); see the per-rule sections below for the module
scope.
``W7425`` (``home-assistant-entity-unique-id-redundant-domain``)
----------------------------------------------------------------
The entity registry keys uniqueness on ``(domain, platform, unique_id)``
where ``platform`` is the integration's name (as declared by the
``"domain"`` field in ``manifest.json``). Any prefix in the unique_id
that repeats the integration's name duplicates information already
present in the registry key. The rule fires when the value used for
the entity's unique id either:
``"domain"`` field in ``manifest.json``). Any occurrence of the
integration's name in the unique_id duplicates information already
present in the registry key. The rule fires in every integration
module (entity-platform modules, ``entity.py``, ``__init__.py``, ...)
when the value used for the entity's unique id either:
- references the ``DOMAIN`` name at any depth (e.g.
``f"{DOMAIN}_{entry.entry_id}"``), or
@@ -30,49 +32,135 @@ the entity's unique id either:
longer identifier, so substrings like ``"myhubitat_..."`` or
``"myhub2"`` don't match.
Three locations are scanned: class-body ``_attr_unique_id``
assignments, ``self._attr_unique_id = ...`` assignments inside method
bodies, and ``return`` values inside a ``unique_id`` property/method
override. Aliased imports (``from .const import DOMAIN as MY_DOMAIN``)
are not scanned.
``W7427`` (``home-assistant-entity-unique-id-redundant-platform``)
------------------------------------------------------------------
The ``domain`` field of the registry key (in Home Assistant
user-facing vocabulary: the *platform*, e.g. ``sensor``, ``light``,
``binary_sensor``) is already known from the module the entity lives
in. Repeating that name as a delimited segment of the entity's unique
ID duplicates information already present in the registry key. The
rule fires when the integration sub-module path keys off a known
entity platform name; both single-file platform modules
(``sensor.py``, ``binary_sensor.py``, ``light.py``, ...) and platform
packages (``sensor/__init__.py``, ``sensor/helpers.py``, ...) are in
scope. Shared bases in ``entity.py`` and code in ``__init__.py`` at
the integration root are not in scope because the platform context is
ambiguous there.
Three locations are scanned for both rules: class-body
``_attr_unique_id`` assignments, ``self._attr_unique_id = ...``
assignments inside method bodies, and ``return`` values inside a
``unique_id`` property/method override. Aliased imports
(``from .const import DOMAIN as MY_DOMAIN``) are not scanned.
"""
from collections.abc import Callable, Iterable
import re
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.const import ENTITY_COMPONENTS
from pylint_home_assistant.helpers.entity_class import inherits_from_entity
from pylint_home_assistant.helpers.integration import read_manifest
from pylint_home_assistant.helpers.module_info import is_integration_module
from pylint_home_assistant.helpers.module_info import (
is_integration_module,
parse_module,
)
_ATTR_NAME = "_attr_unique_id"
_PROPERTY_NAME = "unique_id"
_FSTRING_PLACEHOLDER = "a"
def _joined_str_approximation(node: nodes.JoinedStr) -> str:
"""Build the runtime string of *node* with f-string expressions replaced.
Each ``FormattedValue`` is replaced by a single alphanumeric character
so that boundary checks at part boundaries do not claim a delimiter
that may not exist at runtime. Const string parts are concatenated
verbatim.
"""
parts: list[str] = []
for v in node.values:
if isinstance(v, nodes.Const) and isinstance(v.value, str):
parts.append(v.value)
else:
parts.append(_FSTRING_PLACEHOLDER)
return "".join(parts)
def _is_inside_joined_str(node: nodes.NodeNG) -> bool:
"""Return True if *node* has a ``JoinedStr`` ancestor."""
parent = node.parent
while parent is not None:
if isinstance(parent, nodes.JoinedStr):
return True
parent = parent.parent
return False
def _iter_string_literals(value: nodes.NodeNG) -> Iterable[str]:
"""Yield the static string approximations contained in *value*.
For f-strings (``JoinedStr``), expression parts are substituted with
a single alphanumeric placeholder so boundary checks evaluate
against the whole runtime string instead of per-fragment.
``Const`` strings outside any ``JoinedStr`` are yielded verbatim;
those nested inside a ``JoinedStr`` are skipped because they are
already covered by the ``JoinedStr`` approximation.
"""
for node in value.nodes_of_class(nodes.JoinedStr):
yield _joined_str_approximation(node)
for const in value.nodes_of_class(nodes.Const):
if isinstance(const.value, str) and not _is_inside_joined_str(const):
yield const.value
def _value_contains_segment(value: nodes.NodeNG, segment: str) -> bool:
"""Return True if any string literal in *value* contains *segment* delimited.
A segment is considered delimited when bordered by a non-alphanumeric
character (``_``, ``-``, ``.``, ``:``, space, ...) or a string
boundary. Letters and digits are excluded from the boundary set
because both are valid in HA integration domain names, so substrings
like ``"myhubitat_..."`` or ``"myhub2"`` don't match ``myhub``.
f-strings are evaluated against the full runtime string (with
expression parts substituted by a placeholder), so e.g.
``f"{prefix}sensor_{key}"`` does not match ``sensor`` (because the
character before ``sensor`` at runtime is unknown and may not be a
delimiter).
"""
pattern = re.compile(rf"(?:^|[^a-zA-Z0-9]){re.escape(segment)}(?:[^a-zA-Z0-9]|$)")
return any(pattern.search(s) for s in _iter_string_literals(value))
def _value_references_domain(value: nodes.NodeNG | None, domain: str | None) -> bool:
"""Return True if the value expression embeds the integration's domain.
Matches either a ``Name(name="DOMAIN")`` reference at any depth, or
the integration's domain string appearing as a delimited segment
(bordered by a non-alphanumeric character or a string boundary)
inside any string ``Const`` in the value (including f-string
literal parts). Letters and digits are excluded from the boundary
set because both are valid in HA integration domain names.
the integration's domain string appearing as a delimited segment of
any string literal in the value (with f-strings evaluated against
their full runtime approximation, see ``_iter_string_literals``).
"""
if value is None:
return False
if any(n.name == "DOMAIN" for n in value.nodes_of_class(nodes.Name)):
return True
if domain:
pattern = re.compile(
rf"(?:^|[^a-zA-Z0-9]){re.escape(domain)}(?:[^a-zA-Z0-9]|$)"
)
for const in value.nodes_of_class(nodes.Const):
if isinstance(const.value, str) and pattern.search(const.value):
return True
return False
return domain is not None and _value_contains_segment(value, domain)
def _value_references_platform(
value: nodes.NodeNG | None, platform: str | None
) -> bool:
"""Return True if the value embeds the platform name as a delimited segment."""
if value is None or platform is None:
return False
return _value_contains_segment(value, platform)
def _is_self_attr_target(target: nodes.NodeNG) -> bool:
@@ -85,10 +173,11 @@ def _is_self_attr_target(target: nodes.NodeNG) -> bool:
return False
def _redundant_domain_value_nodes(
class_node: nodes.ClassDef, domain: str | None
def _redundant_value_nodes(
class_node: nodes.ClassDef,
check: Callable[[nodes.NodeNG], bool],
) -> list[nodes.NodeNG]:
"""Return value nodes that embed the domain in the entity's unique_id.
"""Return value nodes for which *check* returns True.
Scans three locations:
@@ -102,22 +191,22 @@ def _redundant_domain_value_nodes(
for item in class_node.body:
match item:
case nodes.AnnAssign(target=nodes.AssignName(name=name), value=value) if (
name == _ATTR_NAME and _value_references_domain(value, domain)
name == _ATTR_NAME and value is not None and check(value)
):
hits.append(value)
case nodes.Assign(targets=targets, value=value) if any(
isinstance(t, nodes.AssignName) and t.name == _ATTR_NAME
for t in targets
) and _value_references_domain(value, domain):
) and check(value):
hits.append(value)
for method in class_node.body:
if not isinstance(method, nodes.FunctionDef):
if not isinstance(method, nodes.FunctionDef | nodes.AsyncFunctionDef):
continue
if method.name == _PROPERTY_NAME:
hits.extend(
ret.value
for ret in method.nodes_of_class(nodes.Return)
if ret.value is not None and _value_references_domain(ret.value, domain)
if ret.value is not None and check(ret.value)
)
continue
for stmt in method.nodes_of_class((nodes.Assign, nodes.AnnAssign)):
@@ -128,8 +217,10 @@ def _redundant_domain_value_nodes(
target_list = [target]
case _:
continue
if _value_references_domain(value, domain) and any(
_is_self_attr_target(t) for t in target_list
if (
value is not None
and check(value)
and any(_is_self_attr_target(t) for t in target_list)
):
hits.append(value)
return hits
@@ -141,8 +232,23 @@ def _integration_domain(module: nodes.Module) -> str | None:
return manifest.get("domain") if manifest else None
def _module_platform(module_name: str) -> str | None:
"""Return the entity platform for *module_name*, or None.
Returns the platform name (e.g. ``"sensor"``) when *module_name*
points to a known entity-platform sub-module of an integration.
Returns ``None`` for non-platform sub-modules (``entity.py``,
``__init__.py``, ``const.py``, ...) and for modules outside the
integration root.
"""
parsed = parse_module(module_name)
if parsed is None or parsed.module is None:
return None
return parsed.module if parsed.module in ENTITY_COMPONENTS else None
class EntityUniqueIdFormatChecker(BaseChecker):
"""Format-related checks on ``_attr_unique_id`` values."""
"""Format-related checks on entity unique-ID values."""
name = "home_assistant_entity_unique_id_format"
priority = -1
@@ -164,11 +270,29 @@ class EntityUniqueIdFormatChecker(BaseChecker):
"in the entity_id namespace."
),
),
"W7427": (
(
"Entity class `%s` embeds the platform name `%s` in its "
"unique ID; the entity registry namespaces unique IDs per "
"platform, so this segment is redundant"
),
"home-assistant-entity-unique-id-redundant-platform",
(
"Used when an entity's unique ID contains the name of the "
"entity platform (e.g. `sensor`, `light`, `binary_sensor`) "
"as a delimited substring. Entity registry uniqueness is "
"keyed on (domain, platform, unique_id) where `domain` is "
"the entity platform, so embedding the platform in the "
"unique id duplicates information already present in the "
"registry key."
),
),
}
options = ()
_is_integration_module: bool
_integration_domain: str | None
_platform: str | None
def visit_module(self, node: nodes.Module) -> None:
"""Cache per-module state."""
@@ -176,9 +300,12 @@ class EntityUniqueIdFormatChecker(BaseChecker):
self._integration_domain = (
_integration_domain(node) if self._is_integration_module else None
)
self._platform = (
_module_platform(node.name) if self._is_integration_module else None
)
def visit_classdef(self, node: nodes.ClassDef) -> None:
"""Flag entity classes whose unique_id embeds the integration's domain.
"""Flag entity classes whose unique_id embeds the domain or platform.
Every class inheriting from ``Entity`` in any integration module
is inspected. Mixin/abstract bases are not exempted, because the
@@ -189,12 +316,25 @@ class EntityUniqueIdFormatChecker(BaseChecker):
return
if not inherits_from_entity(node):
return
for value_node in _redundant_domain_value_nodes(node, self._integration_domain):
for value_node in _redundant_value_nodes(
node, lambda v: _value_references_domain(v, self._integration_domain)
):
self.add_message(
"home-assistant-entity-unique-id-redundant-domain",
node=value_node,
args=(node.name,),
)
platform = self._platform
if platform is None:
return
for value_node in _redundant_value_nodes(
node, lambda v: _value_references_platform(v, platform)
):
self.add_message(
"home-assistant-entity-unique-id-redundant-platform",
node=value_node,
args=(node.name, platform),
)
def register(linter: PyLinter) -> None:
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260624.1"
FRONTEND_VERSION: Final[str] = "20260624.2"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+1 -1
View File
@@ -33,7 +33,7 @@ ifaddr==0.2.0
infrared-protocols==6.3.0
Jinja2==3.1.6
lru-dict==1.4.1
mutagen==1.47.0
mutagen==1.48.0
orjson==3.11.9
packaging>=23.1
Pillow==12.2.0
+3 -3
View File
@@ -251,7 +251,7 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
aioecowitt==2025.9.2
aioecowitt==2026.6.0
# homeassistant.components.co2signal
aioelectricitymaps==1.1.1
@@ -1269,7 +1269,7 @@ hole==0.9.2
holidays==0.99
# homeassistant.components.frontend
home-assistant-frontend==20260624.1
home-assistant-frontend==20260624.2
# homeassistant.components.conversation
home-assistant-intents==2026.6.24
@@ -1619,7 +1619,7 @@ mullvad-api==1.0.0
music-assistant-client==1.3.6
# homeassistant.components.tts
mutagen==1.47.0
mutagen==1.48.0
# homeassistant.components.mutesync
mutesync==0.0.1
+1
View File
@@ -34,6 +34,7 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
"plugins": ", ".join( # noqa: FLY002
[
"pydantic.mypy",
"mypy_plugins/enum_identity_compare.py",
]
),
"show_error_codes": "true",

Some files were not shown because too many files have changed in this diff Show More