Compare commits

..

11 Commits

Author SHA1 Message Date
jbouwh 5169b6554a Follow up on code review 2026-05-05 13:06:08 +00:00
Jan Bouwhuis 2d9cf87e44 Merge branch 'dev' into mqtt-subscribe-identifier 2026-05-05 14:37:06 +02:00
jbouwh b5f04ef502 Break up long line 2026-05-03 12:37:01 +00:00
jbouwh d073d4fe4a Cache subscriptions for topic and subscription_id 2026-05-03 12:33:37 +00:00
jbouwh 2e6d8e3aea Set subscription ID for restored subscriptiions 2026-05-02 20:46:45 +00:00
jbouwh 0d9f5a32f4 Fix packet type 2026-05-02 20:28:41 +00:00
jbouwh feea4925cd Improve testcase labels 2026-05-02 20:14:33 +00:00
Jan Bouwhuis 3be267a94c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-02 22:10:21 +02:00
jbouwh 9da3788c52 Expand unsubscribe race tests with wildcard subscriptions and multiple protocols 2026-05-02 13:31:18 +00:00
Jan Bouwhuis cb6a38b10f Merge branch 'dev' into mqtt-subscribe-identifier 2026-05-02 14:29:12 +02:00
jbouwh 03e22e1cb2 Set subscription identifier to allow filtering duplicate payloads with overlapping subscriptions 2026-05-02 11:40:36 +00:00
183 changed files with 2688 additions and 4569 deletions
+1 -1
View File
@@ -323,7 +323,7 @@ jobs:
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish to ${{ matrix.registry }}
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
with:
extra-args: --all-files zizmor
Generated
+2 -2
View File
@@ -1495,8 +1495,8 @@ CLAUDE.md @home-assistant/core
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.1"]
"requirements": ["serialx==1.4.1"]
}
@@ -899,13 +899,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
+23 -3
View File
@@ -1,16 +1,36 @@
"""Provides triggers for buttons."""
from homeassistant.core import HomeAssistant
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class ButtonPressedTrigger(StatelessEntityTriggerBase):
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+5 -23
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,33 +59,12 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -109,7 +88,10 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": ClimateTargetHumidityCondition,
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
}
+10 -38
View File
@@ -8,15 +8,14 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -56,13 +55,6 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
@@ -83,32 +75,6 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -117,8 +83,14 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
}
+21 -4
View File
@@ -6,22 +6,39 @@ from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
class DoorbellRangTrigger(StatelessEntityTriggerBase):
class DoorbellRangTrigger(EntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the entity is available and the event type is ring."""
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
TRIGGERS: dict[str, type[Trigger]] = {
"rang": DoorbellRangTrigger,
@@ -13,9 +13,6 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -34,15 +31,9 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
@@ -52,15 +43,10 @@ async def async_get_config_entry_diagnostics(
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
@@ -137,6 +137,10 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not self.show_advanced_options:
return await self.async_step_auth()
if user_input:
self._mode = user_input[CONF_MODE]
return await self.async_step_auth()
+1 -1
View File
@@ -16,5 +16,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.15"]
"requirements": ["elkm1-lib==2.2.13"]
}
+1 -3
View File
@@ -199,9 +199,7 @@ class ElkSetting(ElkSensor):
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = (
None if self._element.value is None else str(self._element.value)
)
self._attr_native_value = self._element.value
@property
def extra_state_attributes(self) -> dict[str, Any]:
+17 -6
View File
@@ -2,13 +2,13 @@
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
StatelessEntityTriggerBase,
EntityTriggerBase,
Trigger,
TriggerConfig,
)
@@ -28,7 +28,7 @@ EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EventReceivedTrigger(StatelessEntityTriggerBase):
class EventReceivedTrigger(EntityTriggerBase):
"""Trigger for event entity when it receives a matching event."""
_domain_specs = {DOMAIN: DomainSpec()}
@@ -39,10 +39,21 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
super().__init__(hass, config)
self._event_types = set(self._options[CONF_EVENT_TYPE])
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the event type matches one of the configured types."""
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
"""Check if the event type is valid and matches one of the configured types."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
@@ -1,5 +1,7 @@
"""DataUpdateCoordinator for Fluss+ integration."""
from __future__ import annotations
import asyncio
from typing import Any
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.3"]
"requirements": ["home-assistant-frontend==20260429.2"]
}
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.8.1"]
"requirements": ["gardena-bluetooth==2.4.0"]
}
+8 -13
View File
@@ -12,7 +12,6 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -42,18 +41,14 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
+5 -11
View File
@@ -44,20 +44,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
except HiveReauthRequired as err:
raise ConfigEntryAuthFailed from err
hub_data = devices["parent"][0]
connections: set[tuple[str, str]] = set()
if mac := hub_data.get("macAddress"):
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, hub_data["device_id"])},
connections=connections,
name=hub_data["hiveName"],
model=hub_data["deviceData"]["model"],
sw_version=hub_data["deviceData"]["version"],
manufacturer=hub_data["deviceData"]["manufacturer"],
identifiers={(DOMAIN, devices["parent"][0]["device_id"])},
name=devices["parent"][0]["hiveName"],
model=devices["parent"][0]["deviceData"]["model"],
sw_version=devices["parent"][0]["deviceData"]["version"],
manufacturer=devices["parent"][0]["deviceData"]["manufacturer"],
)
await hass.config_entries.async_forward_entry_setups(
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.96", "babel==2.15.0"]
"requirements": ["holidays==0.95", "babel==2.15.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.10.0"]
"requirements": ["homematicip==2.9.0"]
}
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityNumericalConditionBase,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
@@ -46,20 +46,6 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
return False
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
@@ -93,7 +79,10 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": IsTargetHumidityCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
}
+3 -26
View File
@@ -14,9 +14,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -31,31 +31,8 @@ HUMIDITY_DOMAIN_SPECS = {
),
}
class HumidityCondition(EntityNumericalConditionBase):
"""Condition for humidity value across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
Mirrors the humidity trigger: for climate / humidifier / weather
(attribute-based), the entity is filtered when the source attribute
is absent; sensor entities (state-value-based) fall through to the
base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
CONDITIONS: dict[str, type[Condition]] = {
"is_value": HumidityCondition,
"is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
}
+9 -43
View File
@@ -13,13 +13,12 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
@@ -37,46 +36,13 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
class _HumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for humidity triggers providing entity filtering."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
For domains whose tracked value comes from an attribute
(climate / humidifier / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a humidity as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
}
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
}
+19 -10
View File
@@ -76,12 +76,14 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
# The default for new entries is to not include text and headers
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
)
CONFIG_SCHEMA_ADVANCED = {
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
OPTIONS_SCHEMA = vol.Schema(
{
@@ -91,15 +93,18 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Optional(
CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS
): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
)
OPTIONS_SCHEMA_ADVANCED = {
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
async def validate_input(
hass: HomeAssistant, user_input: dict[str, Any]
@@ -146,6 +151,8 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
schema = CONFIG_SCHEMA
if self.show_advanced_options:
schema = schema.extend(CONFIG_SCHEMA_ADVANCED)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=schema)
@@ -243,6 +250,8 @@ class ImapOptionsFlow(OptionsFlow):
return self.async_create_entry(data={})
schema = OPTIONS_SCHEMA
if self.show_advanced_options:
schema = schema.extend(OPTIONS_SCHEMA_ADVANCED)
schema = self.add_suggested_values_to_schema(schema, entry_data)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
@@ -77,9 +77,10 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
existing_intents = hass.data[DOMAIN]
for intent_type, conf in existing_intents.items():
intent.async_remove(hass, intent_type)
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_unload()
await conf[CONF_ACTION].async_stop()
conf[CONF_ACTION].async_unload()
intent.async_remove(hass, intent_type)
if not new_config or DOMAIN not in new_config:
hass.data[DOMAIN] = {}
+2 -8
View File
@@ -251,10 +251,8 @@ class MatterFan(MatterEntity, FanEntity):
return
self._feature_map = feature_map
self._attr_supported_features = FanEntityFeature(0)
# Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed
# does not leave a stale speed_count / percentage_step.
self._attr_speed_count = 100
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_speed_count = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
)
@@ -304,12 +302,8 @@ class MatterFan(MatterEntity, FanEntity):
if feature_map & FanControlFeature.kAirflowDirection:
self._attr_supported_features |= FanEntityFeature.DIRECTION
# PercentSetting is always a mandatory attribute of the FanControl cluster,
# so percentage-based speed control is always available.
self._attr_supported_features |= (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
@@ -1,108 +1,11 @@
"""Provides conditions for media players."""
from datetime import datetime
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityConditionBase,
EntityNumericalConditionBase,
make_entity_state_condition,
)
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED
from .const import DOMAIN, MediaPlayerState
class _MediaPlayerMutedConditionBase(EntityConditionBase):
"""Base class for media player is_muted/is_unmuted conditions."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _state_valid_since(self, state: State) -> datetime:
"""Anchor `for:` durations to `last_updated` for the muted attribute.
Needed because the domain spec does not reflect that the condition
reads from the muted and volume attributes.
"""
return state.last_updated
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Skip entities without volume attributes from the all/count check."""
return super()._should_include(state) and self._has_volume_attributes(state)
def _is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the entity state matches the targeted muted state."""
if not self._has_volume_attributes(entity_state):
return False
return self._is_muted(entity_state) is self._target_muted
class MediaPlayerIsMutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is muted."""
_target_muted = True
class MediaPlayerIsUnmutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is not muted."""
_target_muted = False
class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase):
"""Condition for media player volume level with 0.0-1.0 to percentage conversion."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL)}
_valid_unit = "%"
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the volume value converted from 0.0-1.0 to percentage (0-100)."""
raw = super()._get_tracked_value(entity_state)
if raw is None:
return None
try:
return float(raw) * 100.0
except TypeError, ValueError:
return None
def _should_include(self, state: State) -> bool:
"""Skip media players that do not expose a volume_level attribute."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_muted": MediaPlayerIsMutedCondition,
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
@@ -114,10 +17,18 @@ CONDITIONS: dict[str, type[Condition]] = {
MediaPlayerState.PLAYING,
},
),
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED),
"is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING),
"is_unmuted": MediaPlayerIsUnmutedCondition,
"is_volume": MediaPlayerIsVolumeCondition,
}
@@ -1,51 +1,22 @@
.condition_common: &condition_common
target: &condition_media_player_target
target:
entity:
domain: media_player
fields:
behavior: &condition_behavior
behavior:
required: true
default: any
selector:
automation_behavior:
mode: condition
for: &condition_for
for:
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_muted: *condition_common
is_off: *condition_common
is_on: *condition_common
is_not_playing: *condition_common
is_paused: *condition_common
is_playing: *condition_common
is_unmuted: *condition_common
is_volume:
target: *condition_media_player_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: is
number: *volume_threshold_number
@@ -1,8 +1,5 @@
{
"conditions": {
"is_muted": {
"condition": "mdi:volume-mute"
},
"is_not_playing": {
"condition": "mdi:stop"
},
@@ -17,12 +14,6 @@
},
"is_playing": {
"condition": "mdi:play"
},
"is_unmuted": {
"condition": "mdi:volume-high"
},
"is_volume": {
"condition": "mdi:volume-medium"
}
},
"entity_component": {
@@ -152,12 +143,6 @@
},
"unmuted": {
"trigger": "mdi:volume-high"
},
"volume_changed": {
"trigger": "mdi:volume-medium"
},
"volume_crossed_threshold": {
"trigger": "mdi:volume-medium"
}
}
}
@@ -2,24 +2,10 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold"
"trigger_for_name": "For at least"
},
"conditions": {
"is_muted": {
"description": "Tests if one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is muted"
},
"is_not_playing": {
"description": "Tests if one or more media players are not playing.",
"fields": {
@@ -79,33 +65,6 @@
}
},
"name": "Media player is playing"
},
"is_unmuted": {
"description": "Tests if one or more media players are not muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is not muted"
},
"is_volume": {
"description": "Tests the volume of one or more media players.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::condition_threshold_name%]"
}
},
"name": "Volume"
}
},
"device_automation": {
@@ -561,30 +520,6 @@
}
},
"name": "Media player unmuted"
},
"volume_changed": {
"description": "Triggers after the volume of one or more media players changes.",
"fields": {
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume changed"
},
"volume_crossed_threshold": {
"description": "Triggers after the volume of one or more media players crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume crossed threshold"
}
}
}
@@ -4,9 +4,6 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
Trigger,
make_entity_transition_trigger,
@@ -15,10 +12,6 @@ from homeassistant.helpers.trigger import (
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState
from .const import DOMAIN
VOLUME_DOMAIN_SPECS = {
DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
}
class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
"""Base class for media player muted/unmuted triggers."""
@@ -40,7 +33,27 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
excluded from the check - otherwise an "all" check would never
pass when there are media players without volume support.
"""
return super()._should_include(state) and self._has_volume_attributes(state)
return state.state not in self._excluded_states and self._has_volume_attributes(
state
)
def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all mutable entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)
def count_matches(self, entity_ids: set[str]) -> int:
"""Count matching mutable entities."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)
def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
@@ -78,48 +91,9 @@ class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase):
_target_muted = False
class VolumeTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for volume triggers."""
_domain_specs = VOLUME_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, state: State) -> float | None:
"""Get tracked volume as a percentage."""
value = super()._get_tracked_value(state)
if value is None:
return None
# Convert 0.0-1.0 range to percentage (0-100)
return value * 100.0
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without a volume level cannot have their volume tracked,
so they are excluded - otherwise an "all" check would never pass
when there are media players without volume support.
"""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin):
"""Trigger for media player volume changes."""
class VolumeCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin
):
"""Trigger for media player volume crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": VolumeChangedTrigger,
"volume_crossed_threshold": VolumeCrossedThresholdTrigger,
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -1,34 +1,20 @@
.trigger_common: &trigger_common
target: &trigger_media_player_target
target:
entity:
domain: media_player
fields:
behavior: &trigger_behavior
behavior:
required: true
default: any
selector:
automation_behavior:
mode: trigger
for: &trigger_for
for:
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
muted: *trigger_common
unmuted: *trigger_common
paused_playing: *trigger_common
@@ -36,27 +22,3 @@ started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
volume_changed:
target: *trigger_media_player_target
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: changed
number: *volume_threshold_number
volume_crossed_threshold:
target: *trigger_media_player_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: crossed
number: *volume_threshold_number
@@ -1,5 +1,7 @@
"""Mitsubishi Comfort integration for Home Assistant."""
from __future__ import annotations
import asyncio
import logging
@@ -1,5 +1,7 @@
"""Climate entity for Mitsubishi Comfort integration."""
from __future__ import annotations
from typing import Any
from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection
@@ -1,5 +1,7 @@
"""Config flow for Mitsubishi Comfort integration."""
from __future__ import annotations
import logging
from typing import Any
@@ -1,5 +1,7 @@
"""DataUpdateCoordinator for Mitsubishi Comfort devices."""
from __future__ import annotations
import logging
from mitsubishi_comfort import IndoorUnit, KumoStation
@@ -1,5 +1,7 @@
"""Base entity for Mitsubishi Comfort integration."""
from __future__ import annotations
from mitsubishi_comfort import IndoorUnit, KumoStation
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -82,7 +82,6 @@ ATTR_SENSOR_UOM = "unit_of_measurement"
SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
SIGNAL_RECORD_NOTIFICATION = f"{DOMAIN}_record_notification"
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
+1 -23
View File
@@ -21,13 +21,9 @@ from homeassistant.components.notify import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -50,7 +46,6 @@ from .const import (
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
SIGNAL_RECORD_NOTIFICATION,
)
from .helpers import device_info
from .push_notification import PushChannel
@@ -116,21 +111,6 @@ class MobileAppNotifyEntity(NotifyEntity):
translation_placeholders={"device_name": self._config_entry.title},
)
@callback
def _async_handle_notification(self, webhook_id: str) -> None:
"""Handle notifications triggered externally."""
if webhook_id == self._config_entry.data[ATTR_WEBHOOK_ID]:
self._async_record_notification()
async def async_added_to_hass(self) -> None:
"""Register callback."""
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_RECORD_NOTIFICATION, self._async_handle_notification
)
)
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
@@ -215,7 +195,6 @@ class MobileAppNotificationService(BaseNotificationService):
data,
partial(self._async_send_remote_message_target, entry),
)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
continue
# Test if local push only.
@@ -224,7 +203,6 @@ class MobileAppNotificationService(BaseNotificationService):
continue
await self._async_send_remote_message_target(entry, data)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
if failed_targets:
raise HomeAssistantError(
+114 -23
View File
@@ -16,8 +16,6 @@ from typing import TYPE_CHECKING, Any
from uuid import uuid4
import certifi
import paho.mqtt.client as mqtt
from paho.mqtt.matcher import MQTTMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -49,7 +47,6 @@ from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.util.collection import chunked_or_all
from homeassistant.util.logging import catch_log_exception, log_exception
from .async_client import AsyncMQTTClient
from .const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
@@ -89,6 +86,13 @@ from .models import (
)
from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabled
if TYPE_CHECKING:
# Only import for paho-mqtt type checking here, imports are done locally
# because integrations should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt
from .async_client import AsyncMQTTClient
_LOGGER = logging.getLogger(__name__)
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
@@ -109,7 +113,6 @@ TIMEOUT_ACK = 10
SUBSCRIBE_TIMEOUT = 10
RECONNECT_INTERVAL_SECONDS = 10
MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1
MAX_SUBSCRIBES_PER_CALL = 500
MAX_UNSUBSCRIBES_PER_CALL = 500
@@ -296,8 +299,9 @@ class Subscription:
is_simple_match: bool
complex_matcher: Callable[[str], bool] | None
job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None]
qos: int = 0
encoding: str | None = "utf-8"
qos: int
encoding: str | None
subscription_id: int
class MqttClientSetup:
@@ -319,6 +323,12 @@ class MqttClientSetup:
The setup of the MQTT client should be run in an executor job,
because it accesses files, so it does IO.
"""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
from paho.mqtt import client as mqtt # noqa: PLC0415
from .async_client import AsyncMQTTClient # noqa: PLC0415
config = self._config
clean_session: bool | None = None
# If no protocol setting is set in the config entry data
@@ -445,6 +455,7 @@ class MQTT:
self._max_qos: defaultdict[str, int] = defaultdict(int) # topic, max qos
self._pending_subscriptions: dict[str, int] = {} # topic, qos
self._registered_subscriptions: dict[str, int] = {} # topic, subscription_id
self._unsubscribe_debouncer = EnsureJobAfterCooldown(
UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes
)
@@ -551,6 +562,7 @@ class MQTT:
"""Start the misc periodic."""
assert self._misc_timer is None, "Misc periodic already started"
_LOGGER.debug("%s: Starting client misc loop", self.config_entry.title)
import paho.mqtt.client as mqtt # noqa: PLC0415
# Inner function to avoid having to check late import
# each time the function is called.
@@ -694,6 +706,7 @@ class MQTT:
async def async_connect(self, client_available: asyncio.Future[bool]) -> None:
"""Connect to the host. Does not process messages yet."""
import paho.mqtt.client as mqtt # noqa: PLC0415
result: int | None = None
self._available_future = client_available
@@ -751,6 +764,7 @@ class MQTT:
async def _reconnect_loop(self) -> None:
"""Reconnect to the MQTT server."""
import paho.mqtt.client as mqtt # noqa: PLC0415
while True:
if not self.connected:
@@ -799,6 +813,9 @@ class MQTT:
) -> None:
"""Restore tracked subscriptions after reload."""
for subscription in subscriptions:
self._registered_subscriptions[subscription.topic] = (
subscription.subscription_id
)
self._async_track_subscription(subscription)
self._matching_subscriptions.cache_clear()
@@ -904,7 +921,19 @@ class MQTT:
is_simple_match = not ("+" in topic or "#" in topic)
matcher = None if is_simple_match else _matcher_for_topic(topic)
subscription = Subscription(topic, is_simple_match, matcher, job, qos, encoding)
if is_simple_match:
subscription_id = 1
elif topic in self._registered_subscriptions:
subscription_id = self._registered_subscriptions[topic]
else:
subscription_id = self._registered_subscriptions[topic] = (
self._mqtt_data.subscription_id_generator.generate()
)
subscription = Subscription(
topic, is_simple_match, matcher, job, qos, encoding, subscription_id
)
self._async_track_subscription(subscription)
self._matching_subscriptions.cache_clear()
@@ -923,15 +952,15 @@ class MQTT:
del self._retained_topics[subscription]
# Only unsubscribe if currently connected
if self.connected:
self._async_unsubscribe(subscription.topic)
self._async_unsubscribe(subscription.topic, subscription.subscription_id)
@callback
def _async_unsubscribe(self, topic: str) -> None:
def _async_unsubscribe(self, topic: str, subscription_id: int) -> None:
"""Unsubscribe from a topic."""
if self.is_active_subscription(topic):
if self._max_qos[topic] == 0:
return
subs = self._matching_subscriptions(topic)
subs = self._matching_subscriptions(topic, (subscription_id,))
self._max_qos[topic] = max(sub.qos for sub in subs)
# Other subscriptions on topic remaining - don't unsubscribe.
return
@@ -957,33 +986,62 @@ class MQTT:
#
# Since we do not know if a published value is retained we need to
# (re)subscribe, to ensure retained messages are replayed
import paho.mqtt.client as mqtt # noqa: PLC0415
if not self._pending_subscriptions:
return
# Split out the wildcard subscriptions, we subscribe to them one by one
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
pending_subscriptions: dict[str, int] = self._pending_subscriptions
pending_wildcard_subscriptions = {
subscription.topic: pending_subscriptions.pop(subscription.topic)
for subscription in self._wildcard_subscriptions
if subscription.topic in pending_subscriptions
}
subscribe_chain = chunked_or_all(
pending_subscriptions.items(), MAX_SUBSCRIBES_PER_CALL
)
if self.is_mqttv5 and pending_subscriptions:
bulk_properties = mqtt.Properties(packetType=mqtt.PacketTypes.SUBSCRIBE) # type: ignore[no-untyped-call]
bulk_properties.SubscriptionIdentifier = 1
else:
bulk_properties = None
self._pending_subscriptions = {}
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
for topic, qos in pending_wildcard_subscriptions.items():
if self.is_mqttv5:
properties = mqtt.Properties(packetType=mqtt.PacketTypes.SUBSCRIBE) # type: ignore[no-untyped-call]
properties.SubscriptionIdentifier = self._registered_subscriptions[
topic
]
else:
properties = None
for chunk in chain(
chunked_or_all(
pending_wildcard_subscriptions.items(), MAX_WILDCARD_SUBSCRIBES_PER_CALL
),
chunked_or_all(pending_subscriptions.items(), MAX_SUBSCRIBES_PER_CALL),
):
result, mid = self._mqttc.subscribe(topic, qos, properties=properties)
if debug_enabled:
_LOGGER.debug(
"Subscribing with mid: %s to topic %s "
"with qos: %s and properties: %s",
mid,
topic,
qos,
properties,
)
self._last_subscribe = time.monotonic()
await self._async_wait_for_mid_or_raise(mid, result)
async_dispatcher_send(
self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, [(topic, qos)]
)
for chunk in subscribe_chain:
chunk_list = list(chunk)
if not chunk_list:
continue
result, mid = self._mqttc.subscribe(chunk_list)
result, mid = self._mqttc.subscribe(chunk_list, properties=bulk_properties)
if debug_enabled:
_LOGGER.debug(
@@ -1014,6 +1072,10 @@ class MQTT:
await self._async_wait_for_mid_or_raise(mid, result)
# Flush subscription identifiers if they are available
for topic in topics:
self._registered_subscriptions.pop(topic, None)
async def _async_resubscribe_and_publish_birth_message(
self, birth_message: PublishMessage
) -> None:
@@ -1112,16 +1174,27 @@ class MQTT:
)
@lru_cache(None) # pylint: disable=method-cache-max-size-none
def _matching_subscriptions(self, topic: str) -> list[Subscription]:
def _matching_subscriptions(
self, topic: str, identifiers: tuple[int, ...] | None
) -> list[Subscription]:
subscriptions: list[Subscription] = []
if topic in self._simple_subscriptions:
subscriptions.extend(self._simple_subscriptions[topic])
simple_subscriptions_for_topic = self._simple_subscriptions[topic]
if identifiers is None:
subscriptions.extend(simple_subscriptions_for_topic)
else:
subscriptions.extend(
subscription
for subscription in simple_subscriptions_for_topic
if subscription.subscription_id in identifiers
)
subscriptions.extend(
subscription
for subscription in self._wildcard_subscriptions
# mypy doesn't know that complex_matcher is always set when
# is_simple_match is False
if subscription.complex_matcher(topic) # type: ignore[misc]
and (identifiers is None or subscription.subscription_id in identifiers)
)
return subscriptions
@@ -1129,6 +1202,17 @@ class MQTT:
def _async_mqtt_on_message(
self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage
) -> None:
identifiers: tuple[int,] | None = None
if self.is_mqttv5:
# It is possible we have multiple messages if there
# are overlapping wildcard subscriptions.
# So we assigned all wildcard subscriptions with a
# unique SubscriptionIdentifier. Simple subscriptions are assigned
# with SubscriptionIdentifier 1.
if msg.properties is not None and hasattr(
msg.properties, "SubscriptionIdentifier"
):
identifiers = tuple(msg.properties.SubscriptionIdentifier)
try:
# msg.topic is a property that decodes the topic to a string
# every time it is accessed. Save the result to avoid
@@ -1145,16 +1229,16 @@ class MQTT:
)
return
_LOGGER.debug(
"Received%s message on %s (qos=%s): %s",
"Received%s message on %s (qos=%s) IDs=%s: %s",
" retained" if msg.retain else "",
topic,
msg.qos,
identifiers,
msg.payload[0:8192],
)
subscriptions = self._matching_subscriptions(topic)
msg_cache_by_subscription_topic: dict[str, ReceiveMessage] = {}
for subscription in subscriptions:
for subscription in self._matching_subscriptions(topic, identifiers):
if msg.retain:
retained_topics = self._retained_topics[subscription]
# Skip if the subscription already received a retained message
@@ -1252,6 +1336,9 @@ class MQTT:
@callback
def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
"""Handle a callback exception."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
_LOGGER.warning(
"Error returned from MQTT server: %s",
@@ -1296,6 +1383,8 @@ class MQTT:
) -> None:
"""Wait for ACK from broker or raise on error."""
if result_code != 0:
import paho.mqtt.client as mqtt # noqa: PLC0415
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mqtt_broker_error",
@@ -1342,6 +1431,8 @@ class MQTT:
def _matcher_for_topic(subscription: str) -> Callable[[str], bool]:
from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415
matcher = MQTTMatcher() # type: ignore[no-untyped-call]
matcher[subscription] = True
+9 -3
View File
@@ -22,7 +22,6 @@ from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
)
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import paho.mqtt.client as mqtt
import voluptuous as vol
import yaml
@@ -5372,9 +5371,12 @@ async def async_get_broker_settings( # noqa: C901
description={"suggested_value": current_pass},
)
] = PASSWORD_SELECTOR
# show advanced options checkbox if no defaults
# of the advanced options are overridden
# show advanced options checkbox if requested and
# advanced options are enabled
# or when the defaults of advanced options are overridden
if not advanced_broker_options:
if not flow.show_advanced_options:
return False
fields[
vol.Optional(
ADVANCED_OPTIONS,
@@ -5480,6 +5482,10 @@ def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
+19 -2
View File
@@ -9,8 +9,6 @@ from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any, TypedDict
from paho.mqtt.client import MQTTMessage
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
@@ -26,6 +24,8 @@ from homeassistant.helpers.typing import (
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from paho.mqtt.client import MQTTMessage
from .client import MQTT, Subscription
from .debug_info import TimestampedPublishMessage
from .device_trigger import Trigger
@@ -42,6 +42,22 @@ class PayloadSentinel(StrEnum):
DEFAULT = "default"
class SubscriptionID:
"""ID generator for wildcard subscriptions."""
_id: int = 1
def generate(self) -> int:
"""Generate a new subscription ID.
ID 0 is reserved.
ID 1 is used for non wildcard topics.
Generator starts at ID 2.
"""
self._id = self._id + 1
return self._id
_LOGGER = logging.getLogger(__name__)
ATTR_THIS = "this"
@@ -421,6 +437,7 @@ class MqttData:
state_write_requests: EntityTopicState = field(default_factory=EntityTopicState)
subscriptions_to_restore: set[Subscription] = field(default_factory=set)
tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict)
subscription_id_generator: SubscriptionID = field(default_factory=SubscriptionID)
@dataclass(slots=True)
+3 -2
View File
@@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -56,7 +55,9 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity):
},
]
)
self._attr_unique_id = f"{self.device.entity_id}-{device_type_to_str(self.device_type)}-preferred_position"
self._attr_unique_id = (
f"{self.device.entity_id}-{self.device_type}-preferred_position"
)
@callback
def async_update_callback(self) -> None:
+1 -4
View File
@@ -42,7 +42,6 @@ from .const import (
)
from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -103,9 +102,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
Camera.__init__(self)
super().__init__(netatmo_device)
self._attr_unique_id = (
f"{netatmo_device.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{netatmo_device.device.entity_id}-{self.device_type}"
self._light_state = None
self._publishers.extend(
+1 -4
View File
@@ -54,7 +54,6 @@ from .const import (
)
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom
from .entity import NetatmoRoomEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -220,9 +219,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
if self.device_type is NA_THERM:
self._attr_hvac_modes.append(HVACMode.OFF)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
async def async_added_to_hass(self) -> None:
"""Entity created."""
+1 -4
View File
@@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -71,9 +70,7 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity):
},
]
)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
+1 -4
View File
@@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -63,9 +62,7 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity):
]
)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
@@ -3,16 +3,6 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from pyatmo.modules.device_types import DeviceType as NetatmoDeviceType
def device_type_to_str(device_type: NetatmoDeviceType) -> str:
"""Convert a device type to a string.
Used to generate backwards compatible unique ids.
"""
return f"{type(device_type).__name__}.{device_type}"
@dataclass
class NetatmoArea:
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"requirements": ["pyatmo==9.4.0"]
"requirements": ["pyatmo==9.2.3"]
}
+1 -4
View File
@@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -59,9 +58,7 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity):
},
]
)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
self._attr_is_on = self.device.on
@callback
@@ -72,6 +72,7 @@ UNSUPPORTED_MODELS: list[str] = [
]
UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [
"gpt-5-nano",
"gpt-3.5",
"gpt-4-turbo",
"gpt-4.1-nano",
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any
from openai import OpenAIError
from propcache.api import cached_property
@@ -164,15 +164,14 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
client = self.entry.runtime_data
response_format = options[ATTR_PREFERRED_FORMAT]
if response_format in ("ogg", "oga"):
codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus"
elif response_format == "raw":
response_format = codec = "pcm"
elif response_format not in self._supported_formats:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
codec = response_format
else:
codec = response_format
if response_format not in self._supported_formats:
# common aliases
if response_format == "ogg":
response_format = "opus"
elif response_format == "raw":
response_format = "pcm"
else:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
try:
async with client.audio.speech.with_streaming_response.create(
@@ -181,7 +180,7 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
input=message,
instructions=str(options.get(CONF_PROMPT)),
speed=options.get(CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED),
response_format=codec,
response_format=response_format,
) as response:
response_data = bytearray()
async for chunk in response.iter_bytes():
@@ -1,5 +1,7 @@
"""Services for the Overkiz integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.cover import (
@@ -1,7 +1,7 @@
{
"domain": "roomba",
"name": "iRobot Roomba and Braava",
"codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"],
"codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Orhideous"],
"config_flow": true,
"dhcp": [
{
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["satel_integra"],
"quality_scale": "bronze",
"requirements": ["satel-integra==1.3.1"]
"requirements": ["satel-integra==1.3.0"]
}
+23 -3
View File
@@ -1,16 +1,36 @@
"""Provides triggers for scenes."""
from homeassistant.core import HomeAssistant
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class SceneActivatedTrigger(StatelessEntityTriggerBase):
class SceneActivatedTrigger(EntityTriggerBase):
"""Trigger for scene entity activations."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the scene is activated
# it would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+6 -6
View File
@@ -766,13 +766,13 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Stop script and remove service when it will be removed from HA."""
self.hass.services.async_remove(DOMAIN, self._attr_unique_id)
await self.script.async_stop()
if not self.registry_entry or self.registry_entry.entity_id == self.entity_id:
# Entity ID not changed, unload the script as it will not be reused.
self.script.async_unload()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script as it will be reused.
await self.script.async_stop()
return
await self.script.async_unload()
# remove service
self.hass.services.async_remove(DOMAIN, self._attr_unique_id)
@websocket_api.websocket_command({"type": "script/config", "entity_id": str})
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["serialx==1.7.1"]
"requirements": ["serialx==1.4.1"]
}
@@ -56,7 +56,6 @@ PLATFORMS_BY_TYPE = {
SupportedModels.HYGROMETER.value: [Platform.SENSOR],
SupportedModels.HYGROMETER_CO2.value: [
Platform.BUTTON,
Platform.NUMBER,
Platform.SENSOR,
Platform.SELECT,
],
@@ -1,77 +0,0 @@
"""Number platform for SwitchBot devices."""
from datetime import timedelta
import logging
import switchbot
from switchbot import SwitchbotOperationError
from switchbot.devices.meter_pro import MAX_TIME_OFFSET
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity, exception_handler
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(days=7)
_LOGGER = logging.getLogger(__name__)
_SECONDS_IN_MINUTE = 60
_MAX_TIME_OFFSET_MINUTES = MAX_TIME_OFFSET // _SECONDS_IN_MINUTE
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot number platform."""
coordinator = entry.runtime_data
if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2):
async_add_entities(
[SwitchBotMeterProCO2DisplayTimeOffsetNumber(coordinator)], True
)
class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity):
"""Number entity to set the time offset for Meter Pro CO2 devices."""
_device: switchbot.SwitchbotMeterProCO2
_attr_device_class = NumberDeviceClass.DURATION
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "display_time_offset"
_attr_native_min_value = -_MAX_TIME_OFFSET_MINUTES
_attr_native_max_value = _MAX_TIME_OFFSET_MINUTES
_attr_native_step = 1.0
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_should_poll = True
_attr_entity_registry_enabled_default = False
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.base_unique_id}_display_time_offset"
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the time offset."""
_LOGGER.debug("Setting time offset to %s minutes for %s", value, self._address)
offset_minutes = round(value)
offset_seconds = offset_minutes * _SECONDS_IN_MINUTE
await self._device.set_time_offset(offset_seconds)
self._attr_native_value = offset_minutes
self.async_write_ha_state()
async def async_update(self) -> None:
"""Fetch the latest time offset from the device."""
try:
offset_seconds = await self._device.get_time_offset()
except SwitchbotOperationError:
_LOGGER.debug(
"Failed to update time offset for %s", self._address, exc_info=True
)
return
self._attr_native_value = round(offset_seconds / _SECONDS_IN_MINUTE)
@@ -272,11 +272,6 @@
}
}
},
"number": {
"display_time_offset": {
"name": "Display time offset"
}
},
"select": {
"time_format": {
"name": "Time format",
@@ -46,21 +46,6 @@ class TemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = TEMPERATURE_DOMAIN_SPECS
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the temperature attribute.
Mirrors the temperature trigger: for climate / water_heater /
weather (attribute-based), the entity is filtered when the source
attribute is absent; sensor entities (state-value-based) fall
through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if entity_state.domain == SENSOR_DOMAIN:
@@ -46,23 +46,6 @@ class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
_domain_specs = TEMPERATURE_DOMAIN_SPECS
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the temperature attribute.
For domains whose tracked value comes from an attribute
(climate / water_heater / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a temperature as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if state.domain == SENSOR_DOMAIN:
@@ -1,8 +1,8 @@
"""Data update coordinator for trigger based template entities."""
from collections.abc import Callable
from collections.abc import Callable, Mapping
import logging
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import (
@@ -37,7 +37,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator"
)
self.config = config
self._cond_func: condition.ConditionsChecker | None = None
self._cond_func: Callable[[Mapping[str, Any] | None], bool] | None = None
self._unsub_start: Callable[[], None] | None = None
self._unsub_trigger: Callable[[], None] | None = None
self._script: Script | None = None
@@ -69,9 +69,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
self._unsub_trigger()
self._unsub_trigger = None
if self._script is not None:
await self._script.async_unload()
if self._cond_func is not None:
self._cond_func.async_unload()
await self._script.async_stop()
async def async_setup(self, hass_config: ConfigType) -> None:
"""Set up the trigger and create entities."""
@@ -160,7 +158,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
def _check_condition(self, run_variables: TemplateVarsType) -> bool:
if not self._cond_func:
return True
condition_result = self._cond_func.async_check(variables=run_variables)
condition_result = self._cond_func(run_variables)
if condition_result is False:
_LOGGER.debug(
"Conditions not met, aborting template trigger update. Condition summary: %s",
+3 -9
View File
@@ -169,15 +169,9 @@ class AbstractTemplateEntity(Entity):
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up scripts when removing from Home Assistant."""
if not self.registry_entry or self.registry_entry.entity_id == self.entity_id:
# Entity ID not changed, unload scripts as they will not be reused.
for action_script in self._action_scripts.values():
await action_script.async_unload()
else:
# Entity ID changed, just stop scripts
for action_script in self._action_scripts.values():
await action_script.async_stop()
"""Stop scripts when removing from Home Assistant."""
for action_script in self._action_scripts.values():
await action_script.async_stop()
async def async_run_script(
self,
+33 -16
View File
@@ -25,12 +25,19 @@ from homeassistant.components.weather import (
ATTR_CONDITION_WINDY_VARIANT,
DOMAIN as WEATHER_DOMAIN,
ENTITY_ID_FORMAT,
PLATFORM_SCHEMA as WEATHER_PLATFORM_SCHEMA,
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import (
CONF_NAME,
CONF_TEMPERATURE_UNIT,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
@@ -223,6 +230,18 @@ WEATHER_MODERN_YAML_SCHEMA = WEATHER_COMMON_MODERN_SCHEMA.extend(
make_template_entity_common_modern_schema(WEATHER_DOMAIN, DEFAULT_NAME).schema
)
PLATFORM_SCHEMA = (
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(WEATHER_COMMON_LEGACY_SCHEMA.schema)
.extend(WEATHER_PLATFORM_SCHEMA.schema)
)
WEATHER_CONFIG_ENTRY_SCHEMA = WEATHER_COMMON_MODERN_SCHEMA.extend(
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
)
@@ -238,23 +257,21 @@ async def async_setup_platform(
# Rewrite the configuration options to modern keys.
if discovery_info is None:
_LOGGER.warning(
"Template weather entities can only be configured under template:"
)
return
# Legacy
config = rewrite_legacy_to_modern_config(hass, config, LEGACY_FIELDS)
else:
# Modern and Trigger
entity_configs: list[ConfigType] = discovery_info["entities"]
modified_entity_configs = []
for entity_config in entity_configs:
entity_config = rewrite_legacy_to_modern_config(
hass, entity_config, LEGACY_FIELDS
)
# Modern and Trigger
entity_configs: list[ConfigType] = discovery_info["entities"]
modified_entity_configs = []
for entity_config in entity_configs:
entity_config = rewrite_legacy_to_modern_config(
hass, entity_config, LEGACY_FIELDS
)
modified_entity_configs.append(entity_config)
modified_entity_configs.append(entity_config)
if modified_entity_configs:
discovery_info["entities"] = modified_entity_configs
if modified_entity_configs:
discovery_info["entities"] = modified_entity_configs
await async_setup_template_platform(
hass,
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.4"]
"requirements": ["pyTibber==0.37.3"]
}
@@ -45,9 +45,6 @@
},
"started": {
"trigger": "mdi:timer-play"
},
"time_remaining": {
"trigger": "mdi:timer-alert-outline"
}
}
}
@@ -183,15 +183,6 @@
}
},
"name": "Timer started"
},
"time_remaining": {
"description": "Triggers when one or more timers reach a specific remaining time.",
"fields": {
"remaining": {
"name": "Time remaining"
}
},
"name": "Timer time remaining"
}
}
}
+4 -155
View File
@@ -1,160 +1,10 @@
"""Provides triggers for timers."""
from datetime import datetime, timedelta
from typing import cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, filter_by_domain_specs
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
Trigger,
TriggerActionRunner,
TriggerConfig,
make_entity_target_state_trigger,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE
CONF_REMAINING = "remaining"
TIME_REMAINING_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_REMAINING): cv.positive_time_period_dict,
},
}
)
class TimeRemainingTrigger(Trigger):
"""Trigger when a timer has a specific amount of time remaining."""
_domain_specs: dict[str, DomainSpec] = {DOMAIN: DomainSpec()}
_schema = TIME_REMAINING_TRIGGER_SCHEMA
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, cls._schema(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the time remaining trigger."""
super().__init__(hass, config)
assert config.target is not None
self._target = config.target
options = config.options or {}
self._remaining: timedelta = options[CONF_REMAINING]
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities to timer domain."""
return filter_by_domain_specs(self._hass, self._domain_specs, entities)
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
scheduled: dict[str, CALLBACK_TYPE] = {}
@callback
def schedule_for_state(
entity_id: str,
to_state: State | None,
context: Context | None,
) -> None:
"""Schedule a fire for an active timer state, if applicable."""
if to_state is None:
return
if to_state.state != STATUS_ACTIVE:
return
finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT)
if finishes_at_str is None:
return
finishes_at = dt_util.parse_datetime(finishes_at_str)
if finishes_at is None:
return
fire_at = finishes_at - self._remaining
if fire_at <= dt_util.utcnow():
return
@callback
def fire_trigger(now: datetime) -> None:
"""Fire the trigger."""
scheduled.pop(entity_id, None)
run_action(
{
ATTR_ENTITY_ID: entity_id,
"to_state": to_state,
"remaining": self._remaining,
},
f"time remaining of {entity_id}",
context,
)
scheduled[entity_id] = async_track_point_in_utc_time(
self._hass, fire_trigger, fire_at
)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and schedule trigger."""
event = target_state_change_data.state_change_event
entity_id: str = event.data["entity_id"]
to_state = event.data["new_state"]
# Cancel any previously scheduled callback for this entity
if entity_id in scheduled:
scheduled.pop(entity_id)()
schedule_for_state(entity_id, to_state, event.context)
@callback
def on_entities_update(added: set[str], removed: set[str]) -> None:
"""Handle changes to the tracked entity set."""
for entity_id in removed:
if entity_id in scheduled:
scheduled.pop(entity_id)()
for entity_id in added:
state = self._hass.states.get(entity_id)
schedule_for_state(entity_id, state, state.context if state else None)
unsub = async_track_target_selector_state_change_event(
self._hass,
self._target,
state_change_listener,
self.entity_filter,
on_entities_update,
)
@callback
def async_remove() -> None:
"""Remove state listeners."""
unsub()
for cancel in scheduled.values():
cancel()
scheduled.clear()
return async_remove
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import ATTR_LAST_TRANSITION, DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"cancelled": make_entity_target_state_trigger(
@@ -172,7 +22,6 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started"
),
"time_remaining": TimeRemainingTrigger,
}
@@ -20,13 +20,3 @@ finished: *trigger_common
paused: *trigger_common
restarted: *trigger_common
started: *trigger_common
time_remaining:
target:
entity:
domain: timer
fields:
remaining:
required: true
selector:
duration:
+1 -2
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET
from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -25,7 +25,6 @@ from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus
ITEM_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS, default={}): {},
}
)
+8 -2
View File
@@ -28,7 +28,6 @@ from .const import (
TUYA_DISCOVERY_NEW,
TUYA_HA_SIGNAL_UPDATE_ENTITY,
)
from .util import get_device_info
type TuyaConfigEntry = ConfigEntry[DeviceListener]
@@ -146,7 +145,14 @@ class DeviceListener(SharingDeviceListener):
device_registry.async_get_or_create(
config_entry_id=self._entry.entry_id,
**get_device_info(device, initial=True),
identifiers={(DOMAIN, device.id)},
manufacturer="Tuya",
name=device.name,
# Note: the model is overridden via entity.device_info property
# when the entity is created. If no entities are generated, it will
# stay as unsupported
model=f"{device.product_name} (unsupported)",
model_id=device.product_id,
)
def remove_device(self, device_id: str) -> None:
+13 -3
View File
@@ -5,11 +5,11 @@ from typing import Any
from tuya_device_handlers.device_wrapper import DeviceWrapper
from tuya_sharing import CustomerDevice, Manager
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription
from .const import LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY
from .util import get_device_info
from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY
class TuyaEntity(Entity):
@@ -25,7 +25,6 @@ class TuyaEntity(Entity):
description: EntityDescription,
) -> None:
"""Init TuyaEntity."""
self._attr_device_info = get_device_info(device)
self._attr_unique_id = f"tuya.{device.id}{description.key}"
self.entity_description = description
# TuyaEntity initialize mq can subscribe
@@ -33,6 +32,17 @@ class TuyaEntity(Entity):
self.device = device
self.device_manager = device_manager
@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
return DeviceInfo(
identifiers={(DOMAIN, self.device.id)},
manufacturer="Tuya",
name=self.device.name,
model=self.device.product_name,
model_id=self.device.product_id,
)
@property
def available(self) -> bool:
"""Return if the device is available."""
+1 -1
View File
@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.19",
"tuya-device-handlers==0.0.18",
"tuya-device-sharing-sdk==0.2.8"
]
}
+21 -20
View File
@@ -3,11 +3,31 @@
from tuya_sharing import CustomerDevice
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN, DPCode
def get_dpcode(
device: CustomerDevice, dpcodes: str | tuple[str, ...] | None
) -> str | None:
"""Get the first matching DPCode from the device or return None."""
if dpcodes is None:
return None
if not isinstance(dpcodes, tuple):
dpcodes = (dpcodes,)
for dpcode in dpcodes:
if (
dpcode in device.function
or dpcode in device.status
or dpcode in device.status_range
):
return dpcode
return None
class ActionDPCodeNotFoundError(ServiceValidationError):
"""Custom exception for action DP code not found errors."""
@@ -32,22 +52,3 @@ class ActionDPCodeNotFoundError(ServiceValidationError):
"available": str(sorted(device.function.keys())),
},
)
def get_device_info(device: CustomerDevice, *, initial: bool = False) -> DeviceInfo:
"""Get device info."""
model = device.product_name
if initial:
# Note: the model is overridden via entity.device_info property
# when the entity is created. If no entities are generated, it will
# stay as unsupported
model = f"{device.product_name} (unsupported)"
return DeviceInfo(
identifiers={(DOMAIN, device.id)},
manufacturer="Tuya",
name=device.name,
model=model,
model_id=device.product_id,
)
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.1"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.4.1"]
}
-1
View File
@@ -10,7 +10,6 @@ from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
-8
View File
@@ -1,13 +1,5 @@
{
"entity": {
"light": {
"light_led": {
"default": "mdi:led-on"
},
"logo_led": {
"default": "mdi:led-on"
}
},
"sensor": {
"battery_power": {
"default": "mdi:home-battery"
-126
View File
@@ -1,126 +0,0 @@
"""Light platform for V2C EVSE LEDs."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pytrydan import Trydan, TrydanData
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
LightEntity,
LightEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
from .entity import V2CBaseEntity
LED_ON_VALUE = 100
LED_OFF_VALUE = 0
BRIGHTNESS_SCALE = (LED_OFF_VALUE, LED_ON_VALUE)
@dataclass(frozen=True, kw_only=True)
class V2CLightEntityDescription(LightEntityDescription):
"""Describes V2C EVSE light entity."""
supports_brightness: bool = False
value_fn: Callable[[TrydanData], int | None]
update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]]
TRYDAN_LIGHTS = (
V2CLightEntityDescription(
key="light_led",
translation_key="light_led",
entity_registry_enabled_default=False,
value_fn=lambda evse_data: evse_data.light_led,
update_fn=lambda evse, value: evse.light_led(value),
),
V2CLightEntityDescription(
key="logo_led",
translation_key="logo_led",
supports_brightness=True,
value_fn=lambda evse_data: evse_data.logo_led,
update_fn=lambda evse, value: evse.logo_led(value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: V2CConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up V2C Trydan light platform."""
coordinator = config_entry.runtime_data
data = coordinator.data
assert data is not None
async_add_entities(
V2CLightEntity(
coordinator,
description,
config_entry.entry_id,
)
for description in TRYDAN_LIGHTS
if description.value_fn(data) is not None
)
class V2CLightEntity(V2CBaseEntity, LightEntity):
"""Representation of V2C EVSE LED light entity."""
entity_description: V2CLightEntityDescription
def __init__(
self,
coordinator: V2CUpdateCoordinator,
description: V2CLightEntityDescription,
entry_id: str,
) -> None:
"""Initialize the V2C light entity."""
super().__init__(coordinator, description)
self._attr_unique_id = f"{entry_id}_{description.key}"
self._attr_color_mode = (
ColorMode.BRIGHTNESS if description.supports_brightness else ColorMode.ONOFF
)
self._attr_supported_color_modes = {self._attr_color_mode}
@property
def brightness(self) -> int | None:
"""Return the light brightness."""
if not self.entity_description.supports_brightness:
return None
value = self.entity_description.value_fn(self.data)
if value is None:
return None
return value_to_brightness(BRIGHTNESS_SCALE, value)
@property
def is_on(self) -> bool | None:
"""Return true if the light is on."""
value = self.entity_description.value_fn(self.data)
if value is None:
return None
return value > 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the LED."""
value = LED_ON_VALUE
if self.entity_description.supports_brightness:
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
value = round(brightness_to_value(BRIGHTNESS_SCALE, brightness))
if brightness:
value = max(value, 1)
await self.entity_description.update_fn(self.coordinator.evse, value)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the LED."""
await self.entity_description.update_fn(self.coordinator.evse, LED_OFF_VALUE)
await self.coordinator.async_request_refresh()
@@ -30,14 +30,6 @@
"name": "Ready"
}
},
"light": {
"light_led": {
"name": "Light LED"
},
"logo_led": {
"name": "Logo LED"
}
},
"number": {
"intensity": {
"name": "Intensity"
@@ -127,11 +127,11 @@ class WolSwitch(SwitchEntity):
"""Clean up script when removing from Home Assistant."""
if self._off_script is None:
return
await self._off_script.async_stop()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script as it will be reused.
await self._off_script.async_stop()
return
await self._off_script.async_unload()
self._off_script.async_unload()
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off if an off action is present."""
@@ -74,13 +74,6 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip water heater entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a water heater entity from its state."""
# Water heater entities convert temperatures to the system unit via show_temp
@@ -60,13 +60,6 @@ class _WaterHeaterTargetTemperatureTriggerMixin(
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip water heater entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a water heater entity from its state."""
# Water heater entities convert temperatures to the system unit via show_temp
@@ -1075,7 +1075,7 @@ async def handle_execute_script(
)
return
finally:
await script_obj.async_unload()
script_obj.async_unload()
connection.send_result(
msg["id"],
{
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.96"]
"requirements": ["holidays==0.95"]
}
+3 -2
View File
@@ -13,8 +13,9 @@ MODE_MAP = {
SmartMode.DYNAMIC: "dynamic",
SmartMode.SELF_USE: "self_use",
SmartMode.PERFORMANCE: "fast_discharge",
SmartMode.CHARGED: "fast_charge",
SmartMode.FEED: "connected_solar_panels",
SmartMode.CHARGED: "charged",
SmartMode.DEFAULT: "idle",
SmartMode.FEED: "fast_charge",
}
HA_TO_MODE = {v: k for k, v in MODE_MAP.items()}
@@ -66,10 +66,11 @@
"battery_mode": {
"name": "Mode",
"state": {
"connected_solar_panels": "Connected solar panels",
"charged": "Charged",
"dynamic": "Dynamic",
"fast_charge": "Fast charge",
"fast_discharge": "Fast discharge",
"idle": "[%key:common::state::idle%]",
"self_use": "Self-use"
}
}
-12
View File
@@ -6146,12 +6146,6 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"sensereo": {
"name": "Sensereo",
"iot_standards": [
"matter"
]
},
"sensibo": {
"name": "Sensibo",
"integration_type": "hub",
@@ -8248,12 +8242,6 @@
"zwave"
]
},
"zunzunbee": {
"name": "Zunzunbee",
"iot_standards": [
"zigbee"
]
},
"zwave_js": {
"name": "Z-Wave",
"integration_type": "hub",
+2
View File
@@ -1,5 +1,7 @@
"""Helper to check the configuration file."""
from __future__ import annotations
from collections import OrderedDict
import logging
import os
+24 -35
View File
@@ -437,9 +437,6 @@ class EntityConditionBase(Condition):
"""Base class for entity conditions."""
_domain_specs: Mapping[str, DomainSpec]
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
@@ -487,32 +484,34 @@ class EntityConditionBase(Condition):
"""
return True
def _state_valid_since(self, _state: State) -> datetime:
"""Return the datetime that anchors `for:` durations for `state`.
Override in subclasses whose `is_valid_state` reads
attributes directly without going through `value_source`.
"""
if self._domain_specs[_state.domain].value_source is None:
return _state.last_changed
return _state.last_updated
def _update_valid_since(self, entity_id: str, _state: State | None) -> None:
"""Update _valid_since tracking for an entity based on its current state.
If the entity is in a valid state and not already tracked, records
when the condition became true (via `_state_valid_since`). If the
entity is not in a valid state, removes it from tracking.
If the entity is in a valid state and not already tracked, records when
the condition became true. If the entity is not in a valid state, removes
it from tracking.
For state-based conditions (value_source is None), last_changed
accurately reflects when the state changed to the current value.
For attribute-based conditions, last_changed only tracks main state
changes, so we use last_updated which is bumped on any update
(state or attributes). This is conservative the tracked attribute
may have held its value longer but it's the best we can do
to avoid false positives.
"""
if (
_state is not None
and self._should_include(_state)
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self.is_valid_state(_state)
):
# Only record the time if not already tracked, to avoid
# resetting the duration on unrelated state/attribute updates.
if entity_id not in self._valid_since:
self._valid_since[entity_id] = self._state_valid_since(_state)
domain_spec = self._domain_specs[_state.domain]
if domain_spec.value_source is None:
self._valid_since[entity_id] = _state.last_changed
else:
self._valid_since[entity_id] = _state.last_updated
else:
self._valid_since.pop(entity_id, None)
@@ -560,15 +559,12 @@ class EntityConditionBase(Condition):
cb()
self._on_unload.clear()
def _should_include(self, _state: State) -> bool:
"""Check if an entity should participate in any/all checks.
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the condition relies on.
"""
return _state.state not in self._excluded_states
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked value from a state based on the DomainSpec."""
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
@abc.abstractmethod
def is_valid_state(self, entity_state: State) -> bool:
@@ -626,7 +622,7 @@ class EntityConditionBase(Condition):
_state
for entity_id in filtered_entity_ids
if (_state := self._hass.states.get(entity_id))
and self._should_include(_state)
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
]
return self._matcher(entity_states)
@@ -645,13 +641,6 @@ class EntityStateConditionBase(EntityConditionBase):
spec.value_source is not None for spec in self._domain_specs.values()
)
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked value from a state based on the DomainSpec."""
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected state(s)."""
return self._get_tracked_value(entity_state) in self._states
+15 -28
View File
@@ -1519,7 +1519,7 @@ class Script:
if self._unloaded:
return
try:
self._async_unload()
self.async_unload()
except Exception:
_LOGGER.exception("Error while unloading script")
@@ -1787,16 +1787,11 @@ class Script:
started_action: Callable[..., Any] | None = None,
) -> ScriptRunResult | None:
"""Run script."""
# Prevent running an unloaded script
if self._unloaded:
raise RuntimeError(
f"Cannot run script '{self.name}' after it has been unloaded"
)
if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data:
self._log("Home Assistant is shutting down, starting script blocked")
return None
# The fences above rely on there being no await between these checks
# and the _runs.append below, so that setting either flag is
# sufficient to block new runs from being added.
if context is None:
self._log(
@@ -1804,6 +1799,11 @@ class Script:
)
context = Context()
# Prevent spawning new script runs when Home Assistant is shutting down
if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data:
self._log("Home Assistant is shutting down, starting script blocked")
return None
# Prevent spawning new script runs if not allowed by script mode
if self.is_running:
if self.script_mode == SCRIPT_MODE_SINGLE:
@@ -1913,20 +1913,7 @@ class Script:
return
await asyncio.shield(create_eager_task(self._async_stop(aws, update_state)))
async def async_unload(self) -> None:
"""Unload the script, stopping any in-flight runs first.
Blocks new runs immediately, stops any in-flight runs, then cleans
up all resources.
"""
if self._unloaded:
return
# Set the flag before stopping so async_run rejects new runs.
self._unloaded = True
await self.async_stop()
self._async_unload()
def _async_unload(self) -> None:
def async_unload(self) -> None:
"""Unload the script, cleaning up all resources.
Unloads cached conditions, and recursively unloads sub-scripts.
@@ -1948,31 +1935,31 @@ class Script:
self._condition_cache.clear()
for sub_script in self._repeat_script.values():
sub_script._async_unload() # noqa: SLF001
sub_script.async_unload()
self._repeat_script.clear()
# Conditions in _choose_data and _if_data are the same objects as in
# _condition_cache, so they're already unloaded above. Only unload scripts.
for choose_data in self._choose_data.values():
for _conditions, sub_script in choose_data["choices"]:
sub_script._async_unload() # noqa: SLF001
sub_script.async_unload()
if choose_data["default"] is not None:
choose_data["default"]._async_unload() # noqa: SLF001
choose_data["default"].async_unload()
self._choose_data.clear()
for if_data in self._if_data.values():
if_data["if_then"]._async_unload() # noqa: SLF001
if_data["if_then"].async_unload()
if if_data["if_else"] is not None:
if_data["if_else"]._async_unload() # noqa: SLF001
if_data["if_else"].async_unload()
self._if_data.clear()
for scripts in self._parallel_scripts.values():
for sub_script in scripts:
sub_script._async_unload() # noqa: SLF001
sub_script.async_unload()
self._parallel_scripts.clear()
for sub_script in self._sequence_scripts.values():
sub_script._async_unload() # noqa: SLF001
sub_script.async_unload()
self._sequence_scripts.clear()
async def _async_get_condition(self, config: ConfigType) -> ConditionChecker:
+26 -70
View File
@@ -397,36 +397,23 @@ class EntityTriggerBase(Trigger):
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the trigger relies on (e.g. a missing volume_level attribute).
"""
return state.state not in self._excluded_states
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
"""Return (matches, included) for the entity set.
`matches` is the number of entities that pass `_should_include` AND
`is_valid_state`. `included` is the number that pass
`_should_include` (i.e. are visible to the all/count check at all).
Callers can use the pair to distinguish vacuous truth
(`included == 0`) from a genuine all-match
(`matches == included > 0`).
"""
matches = 0
included = 0
for entity_id in entity_ids:
state = self._hass.states.get(entity_id)
if state is None or not self._should_include(state):
continue
included += 1
if self.is_valid_state(state):
matches += 1
return matches, included
def count_matches(self, entity_ids: set[str]) -> int:
"""Count the number of entity states that match."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
@override
async def async_attach_runner(
@@ -458,20 +445,14 @@ class EntityTriggerBase(Trigger):
For behavior first/last, checks the combined state.
"""
if behavior == BEHAVIOR_LAST:
matches, included = self.count_matches(
return self.check_all_match(
target_state_change_data.targeted_entity_ids
)
# Require at least one included entity to avoid keeping
# the timer alive when every targeted entity has been
# filtered out since it started — a vacuous all-match
# (`included == 0`) would otherwise let the action fire
# after `for:` even though no entity still matches.
return included > 0 and matches == included
if behavior == BEHAVIOR_FIRST:
matches, _included = self.count_matches(
target_state_change_data.targeted_entity_ids
return (
self.count_matches(target_state_change_data.targeted_entity_ids)
>= 1
)
return matches >= 1
# Behavior any: check the individual entity's state
if not to_state:
return False
@@ -489,19 +470,18 @@ class EntityTriggerBase(Trigger):
return
if behavior == BEHAVIOR_LAST:
matches, included = self.count_matches(
if not self.check_all_match(
target_state_change_data.targeted_entity_ids
)
if matches != included:
):
return
elif behavior == BEHAVIOR_FIRST:
# Note: It's enough to test for exactly 1 match here because if there
# were previously 2 matches the transition would not be valid and we
# would have returned already.
matches, _ = self.count_matches(
target_state_change_data.targeted_entity_ids
)
if matches != 1:
if (
self.count_matches(target_state_change_data.targeted_entity_ids)
!= 1
):
return
@callback
@@ -626,30 +606,6 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
)
class StatelessEntityTriggerBase(EntityTriggerBase):
"""Trigger for entities that don't carry meaningful state.
Used for stateless entities (buttons, scenes, doorbells, events)
whose `state.state` is just a timestamp of the last activation.
"""
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is available and the state has changed.
STATE_UNKNOWN is allowed as the origin state so the first
activation fires.
"""
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check that the entity has been activated at least once."""
return state.state not in self._excluded_states
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): vol.All(
+3 -3
View File
@@ -39,8 +39,8 @@ habluetooth==6.1.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260429.3
home-assistant-intents==2026.5.5
home-assistant-frontend==20260429.2
home-assistant-intents==2026.3.24
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
@@ -63,7 +63,7 @@ PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
serialx==1.7.1
serialx==1.4.1
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
@@ -1,5 +1,7 @@
"""Plugin for logger invocations."""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter

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