mirror of
https://github.com/home-assistant/core.git
synced 2026-06-30 18:45:58 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a5eb3af6b | |||
| 9424427a55 | |||
| 94c5700f22 | |||
| 6b7d2b34f9 | |||
| 939dd7abce | |||
| 1a330ca23e | |||
| 5a1cc024dd | |||
| 16a6824799 | |||
| 8cb101ae85 | |||
| 7950469e31 | |||
| 9b08858bf0 | |||
| b0355f8a9e | |||
| 1c1d95652a | |||
| d1275c1128 | |||
| 9c34477559 | |||
| 8fc2c24ea0 |
@@ -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: >-
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
)
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Home Assistant mypy plugins."""
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user