mirror of
https://github.com/home-assistant/core.git
synced 2026-05-07 00:56:50 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5169b6554a | |||
| 2d9cf87e44 | |||
| b5f04ef502 | |||
| d073d4fe4a | |||
| 2e6d8e3aea | |||
| 0d9f5a32f4 | |||
| feea4925cd | |||
| 3be267a94c | |||
| 9da3788c52 | |||
| cb6a38b10f | |||
| 03e22e1cb2 |
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "sensereo",
|
||||
"name": "Sensereo",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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={}): {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Helper to check the configuration file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user