mirror of
https://github.com/home-assistant/core.git
synced 2025-11-12 12:30:31 +00:00
Compare commits
1 Commits
claude/tri
...
sql_adjust
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c0df09dc9 |
@@ -143,28 +143,5 @@
|
||||
"name": "Trigger"
|
||||
}
|
||||
},
|
||||
"title": "Alarm control panel",
|
||||
"triggers": {
|
||||
"armed": {
|
||||
"description": "Triggers when an alarm is armed.",
|
||||
"description_configured": "Triggers when an alarm is armed",
|
||||
"fields": {
|
||||
"mode": {
|
||||
"description": "The arm modes to trigger on. If empty, triggers on all arm modes.",
|
||||
"name": "Arm modes"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed"
|
||||
},
|
||||
"disarmed": {
|
||||
"description": "Triggers when an alarm is disarmed.",
|
||||
"description_configured": "Triggers when an alarm is disarmed",
|
||||
"name": "When an alarm is disarmed"
|
||||
},
|
||||
"triggered": {
|
||||
"description": "Triggers when an alarm is triggered.",
|
||||
"description_configured": "Triggers when an alarm is triggered",
|
||||
"name": "When an alarm is triggered"
|
||||
}
|
||||
}
|
||||
"title": "Alarm control panel"
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""Provides triggers for alarm control panels."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, AlarmControlPanelState
|
||||
|
||||
CONF_MODE = "mode"
|
||||
|
||||
ARMED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional(CONF_MODE, default=[]): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.In(
|
||||
[
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
]
|
||||
)
|
||||
],
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
DISARMED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
TRIGGERED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AlarmArmedTrigger(Trigger):
|
||||
"""Trigger for when an alarm control panel is armed."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, ARMED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the alarm armed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
mode_filter = self._options[CONF_MODE]
|
||||
|
||||
# All armed states
|
||||
armed_states = {
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
}
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if the new state is an armed state
|
||||
if to_state.state not in armed_states:
|
||||
return
|
||||
|
||||
# If mode filter is specified, check if the mode matches
|
||||
if mode_filter and to_state.state not in mode_filter:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"alarm armed on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class AlarmDisarmedTrigger(Trigger):
|
||||
"""Trigger for when an alarm control panel is disarmed."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, DISARMED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the alarm disarmed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if the new state is disarmed
|
||||
if to_state.state != AlarmControlPanelState.DISARMED:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"alarm disarmed on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class AlarmTriggeredTrigger(Trigger):
|
||||
"""Trigger for when an alarm control panel is triggered."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, TRIGGERED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the alarm triggered trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if the new state is triggered
|
||||
if to_state.state != AlarmControlPanelState.TRIGGERED:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"alarm triggered on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"armed": AlarmArmedTrigger,
|
||||
"disarmed": AlarmDisarmedTrigger,
|
||||
"triggered": AlarmTriggeredTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for alarm control panels."""
|
||||
return TRIGGERS
|
||||
@@ -1,30 +0,0 @@
|
||||
armed:
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
fields:
|
||||
mode:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- value: armed_home
|
||||
label: Home
|
||||
- value: armed_away
|
||||
label: Away
|
||||
- value: armed_night
|
||||
label: Night
|
||||
- value: armed_vacation
|
||||
label: Vacation
|
||||
- value: armed_custom_bypass
|
||||
label: Custom bypass
|
||||
disarmed:
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
triggered:
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
@@ -98,27 +98,5 @@
|
||||
"name": "Start conversation"
|
||||
}
|
||||
},
|
||||
"title": "Assist satellite",
|
||||
"triggers": {
|
||||
"listening": {
|
||||
"description": "Triggers when a satellite starts listening for a command.",
|
||||
"description_configured": "Triggers when a satellite starts listening for a command",
|
||||
"name": "When a satellite starts listening"
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers when a satellite starts processing a command.",
|
||||
"description_configured": "Triggers when a satellite starts processing a command",
|
||||
"name": "When a satellite starts processing"
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers when a satellite starts responding to a command.",
|
||||
"description_configured": "Triggers when a satellite starts responding to a command",
|
||||
"name": "When a satellite starts responding"
|
||||
},
|
||||
"idle": {
|
||||
"description": "Triggers when a satellite goes back to idle.",
|
||||
"description_configured": "Triggers when a satellite goes back to idle",
|
||||
"name": "When a satellite goes back to idle"
|
||||
}
|
||||
}
|
||||
"title": "Assist satellite"
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Provides triggers for assist satellites."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_TARGET,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import process_state_match
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
STATE_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StateTriggerBase(Trigger):
|
||||
"""Trigger for assist satellite state changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig, state: str) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
self._state = state
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
match_config_state = process_state_match(self._state)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if the new state matches the trigger state
|
||||
if not match_config_state(to_state.state):
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"{entity_id} {self._state}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ListeningTrigger(StateTriggerBase):
|
||||
"""Trigger for when a satellite starts listening."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the listening trigger."""
|
||||
super().__init__(hass, config, "listening")
|
||||
|
||||
|
||||
class ProcessingTrigger(StateTriggerBase):
|
||||
"""Trigger for when a satellite starts processing."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the processing trigger."""
|
||||
super().__init__(hass, config, "processing")
|
||||
|
||||
|
||||
class RespondingTrigger(StateTriggerBase):
|
||||
"""Trigger for when a satellite starts responding."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the responding trigger."""
|
||||
super().__init__(hass, config, "responding")
|
||||
|
||||
|
||||
class IdleTrigger(StateTriggerBase):
|
||||
"""Trigger for when a satellite goes back to idle."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the idle trigger."""
|
||||
super().__init__(hass, config, "idle")
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"listening": ListeningTrigger,
|
||||
"processing": ProcessingTrigger,
|
||||
"responding": RespondingTrigger,
|
||||
"idle": IdleTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for assist satellites."""
|
||||
return TRIGGERS
|
||||
@@ -1,19 +0,0 @@
|
||||
listening:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
|
||||
processing:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
|
||||
responding:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
|
||||
idle:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
@@ -285,93 +285,5 @@
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
"title": "Climate",
|
||||
"triggers": {
|
||||
"cooling": {
|
||||
"description": "Triggers when a climate starts cooling.",
|
||||
"name": "When a climate starts cooling"
|
||||
},
|
||||
"current_humidity_changed": {
|
||||
"description": "Triggers when the current humidity of a climate changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when the current humidity goes above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when the current humidity goes below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "When current humidity changes"
|
||||
},
|
||||
"current_temperature_changed": {
|
||||
"description": "Triggers when the current temperature of a climate changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when the current temperature goes above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when the current temperature goes below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "When current temperature changes"
|
||||
},
|
||||
"drying": {
|
||||
"description": "Triggers when a climate starts drying.",
|
||||
"name": "When a climate starts drying"
|
||||
},
|
||||
"heating": {
|
||||
"description": "Triggers when a climate starts heating.",
|
||||
"name": "When a climate starts heating"
|
||||
},
|
||||
"mode_changed": {
|
||||
"description": "Triggers when the HVAC mode of a climate changes.",
|
||||
"fields": {
|
||||
"hvac_mode": {
|
||||
"description": "The HVAC modes to trigger on. If empty, triggers on all mode changes.",
|
||||
"name": "HVAC modes"
|
||||
}
|
||||
},
|
||||
"name": "When HVAC mode changes"
|
||||
},
|
||||
"target_humidity_changed": {
|
||||
"description": "Triggers when the target humidity of a climate changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when the target humidity goes above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when the target humidity goes below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "When target humidity changes"
|
||||
},
|
||||
"target_temperature_changed": {
|
||||
"description": "Triggers when the target temperature of a climate changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when the target temperature goes above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when the target temperature goes below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "When target temperature changes"
|
||||
},
|
||||
"turns_off": {
|
||||
"description": "Triggers when a climate turns off.",
|
||||
"name": "When a climate turns off"
|
||||
},
|
||||
"turns_on": {
|
||||
"description": "Triggers when a climate turns on.",
|
||||
"name": "When a climate turns on"
|
||||
}
|
||||
}
|
||||
"title": "Climate"
|
||||
}
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
"""Provides triggers for climate."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_ACTION,
|
||||
ATTR_HVAC_MODE,
|
||||
DOMAIN,
|
||||
HVAC_MODES,
|
||||
HVACMode,
|
||||
)
|
||||
|
||||
CLIMATE_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
MODE_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional(ATTR_HVAC_MODE, default=[]): vol.All(
|
||||
cv.ensure_list, [vol.In(HVAC_MODES)]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
THRESHOLD_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional(CONF_ABOVE): vol.Coerce(float),
|
||||
vol.Optional(CONF_BELOW): vol.Coerce(float),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ClimateTurnsOnTrigger(Trigger):
|
||||
"""Trigger for when a climate turns on."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate turns on trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if climate turned on (from off to any other mode)
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.state == HVACMode.OFF
|
||||
and to_state.state != HVACMode.OFF
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} turned on",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateTurnsOffTrigger(Trigger):
|
||||
"""Trigger for when a climate turns off."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate turns off trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if climate turned off (from any mode to off)
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.state != HVACMode.OFF
|
||||
and to_state.state == HVACMode.OFF
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} turned off",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateModeChangedTrigger(Trigger):
|
||||
"""Trigger for when a climate mode changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, MODE_CHANGED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate mode changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
hvac_modes_filter = self._options[ATTR_HVAC_MODE]
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if hvac_mode changed
|
||||
if from_state is not None and from_state.state != to_state.state:
|
||||
# If hvac_modes filter is specified, check if the new mode matches
|
||||
if hvac_modes_filter and to_state.state not in hvac_modes_filter:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} mode changed to {to_state.state}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateCoolingTrigger(Trigger):
|
||||
"""Trigger for when a climate starts cooling."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate cooling trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if climate started cooling
|
||||
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
|
||||
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
|
||||
|
||||
if from_action != "cooling" and to_action == "cooling":
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} started cooling",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateHeatingTrigger(Trigger):
|
||||
"""Trigger for when a climate starts heating."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate heating trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if climate started heating
|
||||
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
|
||||
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
|
||||
|
||||
if from_action != "heating" and to_action == "heating":
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} started heating",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateDryingTrigger(Trigger):
|
||||
"""Trigger for when a climate starts drying."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate drying trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if climate started drying
|
||||
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
|
||||
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
|
||||
|
||||
if from_action != "drying" and to_action == "drying":
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} started drying",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateTargetTemperatureChangedTrigger(Trigger):
|
||||
"""Trigger for when a climate target temperature changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate target temperature changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
above = self._options.get(CONF_ABOVE)
|
||||
below = self._options.get(CONF_BELOW)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if target temperature changed
|
||||
from_temp = (
|
||||
from_state.attributes.get(ATTR_TEMPERATURE) if from_state else None
|
||||
)
|
||||
to_temp = to_state.attributes.get(ATTR_TEMPERATURE)
|
||||
|
||||
if to_temp is None or from_temp == to_temp:
|
||||
return
|
||||
|
||||
# Apply threshold filters if specified
|
||||
if above is not None and to_temp <= above:
|
||||
return
|
||||
if below is not None and to_temp >= below:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} target temperature changed to {to_temp}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateCurrentTemperatureChangedTrigger(Trigger):
|
||||
"""Trigger for when a climate current temperature changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate current temperature changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
above = self._options.get(CONF_ABOVE)
|
||||
below = self._options.get(CONF_BELOW)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if current temperature changed
|
||||
from_temp = (
|
||||
from_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
||||
if from_state
|
||||
else None
|
||||
)
|
||||
to_temp = to_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
||||
|
||||
if to_temp is None or from_temp == to_temp:
|
||||
return
|
||||
|
||||
# Apply threshold filters if specified
|
||||
if above is not None and to_temp <= above:
|
||||
return
|
||||
if below is not None and to_temp >= below:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} current temperature changed to {to_temp}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateTargetHumidityChangedTrigger(Trigger):
|
||||
"""Trigger for when a climate target humidity changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate target humidity changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
above = self._options.get(CONF_ABOVE)
|
||||
below = self._options.get(CONF_BELOW)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if target humidity changed
|
||||
from_humidity = (
|
||||
from_state.attributes.get(ATTR_HUMIDITY) if from_state else None
|
||||
)
|
||||
to_humidity = to_state.attributes.get(ATTR_HUMIDITY)
|
||||
|
||||
if to_humidity is None or from_humidity == to_humidity:
|
||||
return
|
||||
|
||||
# Apply threshold filters if specified
|
||||
if above is not None and to_humidity <= above:
|
||||
return
|
||||
if below is not None and to_humidity >= below:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} target humidity changed to {to_humidity}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class ClimateCurrentHumidityChangedTrigger(Trigger):
|
||||
"""Trigger for when a climate current humidity changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the climate current humidity changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
above = self._options.get(CONF_ABOVE)
|
||||
below = self._options.get(CONF_BELOW)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Check if current humidity changed
|
||||
from_humidity = (
|
||||
from_state.attributes.get(ATTR_CURRENT_HUMIDITY)
|
||||
if from_state
|
||||
else None
|
||||
)
|
||||
to_humidity = to_state.attributes.get(ATTR_CURRENT_HUMIDITY)
|
||||
|
||||
if to_humidity is None or from_humidity == to_humidity:
|
||||
return
|
||||
|
||||
# Apply threshold filters if specified
|
||||
if above is not None and to_humidity <= above:
|
||||
return
|
||||
if below is not None and to_humidity >= below:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"climate {entity_id} current humidity changed to {to_humidity}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"turns_on": ClimateTurnsOnTrigger,
|
||||
"turns_off": ClimateTurnsOffTrigger,
|
||||
"mode_changed": ClimateModeChangedTrigger,
|
||||
"cooling": ClimateCoolingTrigger,
|
||||
"heating": ClimateHeatingTrigger,
|
||||
"drying": ClimateDryingTrigger,
|
||||
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
|
||||
"current_temperature_changed": ClimateCurrentTemperatureChangedTrigger,
|
||||
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
|
||||
"current_humidity_changed": ClimateCurrentHumidityChangedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for climate."""
|
||||
return TRIGGERS
|
||||
@@ -1,128 +0,0 @@
|
||||
turns_on:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
turns_off:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
mode_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
fields:
|
||||
hvac_mode:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
mode: dropdown
|
||||
options:
|
||||
- label: "Off"
|
||||
value: "off"
|
||||
- label: "Heat"
|
||||
value: "heat"
|
||||
- label: "Cool"
|
||||
value: "cool"
|
||||
- label: "Heat/Cool"
|
||||
value: "heat_cool"
|
||||
- label: "Auto"
|
||||
value: "auto"
|
||||
- label: "Dry"
|
||||
value: "dry"
|
||||
- label: "Fan only"
|
||||
value: "fan_only"
|
||||
|
||||
cooling:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
heating:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
drying:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
target_temperature_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
fields:
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
step: 0.1
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
step: 0.1
|
||||
|
||||
current_temperature_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
fields:
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
step: 0.1
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
step: 0.1
|
||||
|
||||
target_humidity_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
fields:
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 0
|
||||
max: 100
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 0
|
||||
max: 100
|
||||
|
||||
current_humidity_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
fields:
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 0
|
||||
max: 100
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 0
|
||||
max: 100
|
||||
@@ -136,75 +136,5 @@
|
||||
"name": "Toggle tilt"
|
||||
}
|
||||
},
|
||||
"title": "Cover",
|
||||
"triggers": {
|
||||
"opens": {
|
||||
"description": "Triggers when a cover opens.",
|
||||
"description_configured": "Triggers when a cover opens",
|
||||
"fields": {
|
||||
"fully_opened": {
|
||||
"description": "Only trigger when the cover is fully opened (position at 100%).",
|
||||
"name": "Fully opened"
|
||||
},
|
||||
"device_class": {
|
||||
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
|
||||
"name": "Device classes"
|
||||
}
|
||||
},
|
||||
"name": "When a cover opens"
|
||||
},
|
||||
"closes": {
|
||||
"description": "Triggers when a cover closes.",
|
||||
"description_configured": "Triggers when a cover closes",
|
||||
"fields": {
|
||||
"fully_closed": {
|
||||
"description": "Only trigger when the cover is fully closed (position at 0%).",
|
||||
"name": "Fully closed"
|
||||
},
|
||||
"device_class": {
|
||||
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
|
||||
"name": "Device classes"
|
||||
}
|
||||
},
|
||||
"name": "When a cover closes"
|
||||
},
|
||||
"stops": {
|
||||
"description": "Triggers when a cover stops moving.",
|
||||
"description_configured": "Triggers when a cover stops moving",
|
||||
"fields": {
|
||||
"device_class": {
|
||||
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
|
||||
"name": "Device classes"
|
||||
}
|
||||
},
|
||||
"name": "When a cover stops moving"
|
||||
},
|
||||
"position_changed": {
|
||||
"description": "Triggers when the position of a cover changes.",
|
||||
"description_configured": "Triggers when the position of a cover changes",
|
||||
"fields": {
|
||||
"lower": {
|
||||
"description": "The minimum position value to trigger on. Only triggers when position is at or above this value.",
|
||||
"name": "Lower limit"
|
||||
},
|
||||
"upper": {
|
||||
"description": "The maximum position value to trigger on. Only triggers when position is at or below this value.",
|
||||
"name": "Upper limit"
|
||||
},
|
||||
"above": {
|
||||
"description": "Only trigger when position is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when position is below this value.",
|
||||
"name": "Below"
|
||||
},
|
||||
"device_class": {
|
||||
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
|
||||
"name": "Device classes"
|
||||
}
|
||||
},
|
||||
"name": "When the position of a cover changes"
|
||||
}
|
||||
}
|
||||
"title": "Cover"
|
||||
}
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
|
||||
from .const import DOMAIN
|
||||
|
||||
CONF_LOWER = "lower"
|
||||
CONF_UPPER = "upper"
|
||||
CONF_ABOVE = "above"
|
||||
CONF_BELOW = "below"
|
||||
CONF_FULLY_OPENED = "fully_opened"
|
||||
CONF_FULLY_CLOSED = "fully_closed"
|
||||
|
||||
OPENS_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional(CONF_FULLY_OPENED, default=False): cv.boolean,
|
||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
CLOSES_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional(CONF_FULLY_CLOSED, default=False): cv.boolean,
|
||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
STOPS_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
POSITION_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Exclusive(CONF_LOWER, "position_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
||||
),
|
||||
vol.Exclusive(CONF_UPPER, "position_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
||||
),
|
||||
vol.Exclusive(CONF_ABOVE, "position_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
||||
),
|
||||
vol.Exclusive(CONF_BELOW, "position_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
||||
),
|
||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CoverOpensTrigger(Trigger):
|
||||
"""Trigger for when a cover opens."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, OPENS_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the cover opens trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
assert config.options is not None
|
||||
self._target = config.target
|
||||
self._options = config.options
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
fully_opened = self._options[CONF_FULLY_OPENED]
|
||||
device_classes_filter = self._options[CONF_DEVICE_CLASS]
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Filter by device class if specified
|
||||
if device_classes_filter:
|
||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
||||
if device_class not in device_classes_filter:
|
||||
return
|
||||
|
||||
# Trigger when cover opens or is opening
|
||||
if to_state.state in (CoverState.OPEN, CoverState.OPENING):
|
||||
# If fully_opened is True, only trigger when position reaches 100
|
||||
if fully_opened:
|
||||
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
if current_position != 100:
|
||||
return
|
||||
|
||||
# Only trigger on state change, not if already in that state
|
||||
if from_state and from_state.state == to_state.state:
|
||||
# For fully_opened, allow triggering when position changes to 100
|
||||
if fully_opened:
|
||||
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
if from_position == to_position:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"cover opened on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class CoverClosesTrigger(Trigger):
|
||||
"""Trigger for when a cover closes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, CLOSES_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the cover closes trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
assert config.options is not None
|
||||
self._target = config.target
|
||||
self._options = config.options
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
fully_closed = self._options[CONF_FULLY_CLOSED]
|
||||
device_classes_filter = self._options[CONF_DEVICE_CLASS]
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Filter by device class if specified
|
||||
if device_classes_filter:
|
||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
||||
if device_class not in device_classes_filter:
|
||||
return
|
||||
|
||||
# Trigger when cover closes or is closing
|
||||
if to_state.state in (CoverState.CLOSED, CoverState.CLOSING):
|
||||
# If fully_closed is True, only trigger when position reaches 0
|
||||
if fully_closed:
|
||||
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
if current_position != 0:
|
||||
return
|
||||
|
||||
# Only trigger on state change, not if already in that state
|
||||
if from_state and from_state.state == to_state.state:
|
||||
# For fully_closed, allow triggering when position changes to 0
|
||||
if fully_closed:
|
||||
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
if from_position == to_position:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"cover closed on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class CoverStopsTrigger(Trigger):
|
||||
"""Trigger for when a cover stops moving."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, STOPS_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the cover stops trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
assert config.options is not None
|
||||
self._target = config.target
|
||||
self._options = config.options
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
device_classes_filter = self._options[CONF_DEVICE_CLASS]
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Filter by device class if specified
|
||||
if device_classes_filter:
|
||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
||||
if device_class not in device_classes_filter:
|
||||
return
|
||||
|
||||
# Trigger when cover stops (from opening/closing to open/closed)
|
||||
if from_state and from_state.state in (
|
||||
CoverState.OPENING,
|
||||
CoverState.CLOSING,
|
||||
):
|
||||
if to_state.state in (CoverState.OPEN, CoverState.CLOSED):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"cover stopped on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class CoverPositionChangedTrigger(Trigger):
|
||||
"""Trigger for when a cover's position changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, POSITION_CHANGED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the cover position changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
self._options = config.options or {}
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
lower_limit = self._options.get(CONF_LOWER)
|
||||
upper_limit = self._options.get(CONF_UPPER)
|
||||
above_limit = self._options.get(CONF_ABOVE)
|
||||
below_limit = self._options.get(CONF_BELOW)
|
||||
device_classes_filter = self._options.get(CONF_DEVICE_CLASS, [])
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Filter by device class if specified
|
||||
if device_classes_filter:
|
||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
||||
if device_class not in device_classes_filter:
|
||||
return
|
||||
|
||||
# Get position values
|
||||
from_position = (
|
||||
from_state.attributes.get(ATTR_CURRENT_POSITION) if from_state else None
|
||||
)
|
||||
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
|
||||
# Only trigger if position value exists and has changed
|
||||
if to_position is None or from_position == to_position:
|
||||
return
|
||||
|
||||
# Apply threshold filters if configured
|
||||
if lower_limit is not None and to_position < lower_limit:
|
||||
return
|
||||
if upper_limit is not None and to_position > upper_limit:
|
||||
return
|
||||
if above_limit is not None and to_position <= above_limit:
|
||||
return
|
||||
if below_limit is not None and to_position >= below_limit:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
"from_position": from_position,
|
||||
"to_position": to_position,
|
||||
},
|
||||
f"position changed on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opens": CoverOpensTrigger,
|
||||
"closes": CoverClosesTrigger,
|
||||
"stops": CoverStopsTrigger,
|
||||
"position_changed": CoverPositionChangedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for covers."""
|
||||
return TRIGGERS
|
||||
@@ -1,101 +0,0 @@
|
||||
opens:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
fully_opened:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
|
||||
closes:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
fully_closed:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
|
||||
stops:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
|
||||
position_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
lower:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
upper:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["apyhiveapi"],
|
||||
"requirements": ["pyhive-integration==1.0.7"]
|
||||
"requirements": ["pyhive-integration==1.0.6"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ from typing import Any
|
||||
from aiohttp import web
|
||||
from hyperion import client
|
||||
from hyperion.const import (
|
||||
KEY_DATA,
|
||||
KEY_IMAGE,
|
||||
KEY_IMAGE_STREAM,
|
||||
KEY_LEDCOLORS,
|
||||
@@ -156,8 +155,7 @@ class HyperionCamera(Camera):
|
||||
"""Update Hyperion components."""
|
||||
if not img:
|
||||
return
|
||||
# Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions
|
||||
img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE)
|
||||
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
|
||||
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
|
||||
return
|
||||
async with self._image_cond:
|
||||
|
||||
@@ -462,40 +462,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Light",
|
||||
"triggers": {
|
||||
"turns_on": {
|
||||
"description": "Triggers when a light turns on.",
|
||||
"description_configured": "Triggers when a light turns on",
|
||||
"name": "When a light turns on"
|
||||
},
|
||||
"turns_off": {
|
||||
"description": "Triggers when a light turns off.",
|
||||
"description_configured": "Triggers when a light turns off",
|
||||
"name": "When a light turns off"
|
||||
},
|
||||
"brightness_changed": {
|
||||
"description": "Triggers when the brightness of a light changes.",
|
||||
"description_configured": "Triggers when the brightness of a light changes",
|
||||
"fields": {
|
||||
"lower": {
|
||||
"description": "The minimum brightness value to trigger on. Only triggers when brightness is at or above this value.",
|
||||
"name": "Lower limit"
|
||||
},
|
||||
"upper": {
|
||||
"description": "The maximum brightness value to trigger on. Only triggers when brightness is at or below this value.",
|
||||
"name": "Upper limit"
|
||||
},
|
||||
"above": {
|
||||
"description": "Only trigger when brightness is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when brightness is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "When the brightness of a light changes"
|
||||
}
|
||||
}
|
||||
"title": "Light"
|
||||
}
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
"""Provides triggers for lights."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_BRIGHTNESS = "brightness"
|
||||
CONF_LOWER = "lower"
|
||||
CONF_UPPER = "upper"
|
||||
CONF_ABOVE = "above"
|
||||
CONF_BELOW = "below"
|
||||
|
||||
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
BRIGHTNESS_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Exclusive(CONF_LOWER, "brightness_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
||||
),
|
||||
vol.Exclusive(CONF_UPPER, "brightness_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
||||
),
|
||||
vol.Exclusive(CONF_ABOVE, "brightness_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
||||
),
|
||||
vol.Exclusive(CONF_BELOW, "brightness_range"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LightTurnsOnTrigger(Trigger):
|
||||
"""Trigger for when a light turns on."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the light turns on trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when light turns on (from off to on)
|
||||
if from_state and from_state.state == STATE_OFF and to_state.state == STATE_ON:
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"light turned on on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class LightTurnsOffTrigger(Trigger):
|
||||
"""Trigger for when a light turns off."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the light turns off trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when light turns off (from on to off)
|
||||
if from_state and from_state.state == STATE_ON and to_state.state == STATE_OFF:
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"light turned off on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class LightBrightnessChangedTrigger(Trigger):
|
||||
"""Trigger for when a light's brightness changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, BRIGHTNESS_CHANGED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the light brightness changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
self._options = config.options or {}
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
lower_limit = self._options.get(CONF_LOWER)
|
||||
upper_limit = self._options.get(CONF_UPPER)
|
||||
above_limit = self._options.get(CONF_ABOVE)
|
||||
below_limit = self._options.get(CONF_BELOW)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Get brightness values
|
||||
from_brightness = (
|
||||
from_state.attributes.get(ATTR_BRIGHTNESS) if from_state else None
|
||||
)
|
||||
to_brightness = to_state.attributes.get(ATTR_BRIGHTNESS)
|
||||
|
||||
# Only trigger if brightness value exists and has changed
|
||||
if to_brightness is None or from_brightness == to_brightness:
|
||||
return
|
||||
|
||||
# Apply threshold filters if configured
|
||||
if lower_limit is not None and to_brightness < lower_limit:
|
||||
return
|
||||
if upper_limit is not None and to_brightness > upper_limit:
|
||||
return
|
||||
if above_limit is not None and to_brightness <= above_limit:
|
||||
return
|
||||
if below_limit is not None and to_brightness >= below_limit:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
"from_brightness": from_brightness,
|
||||
"to_brightness": to_brightness,
|
||||
},
|
||||
f"brightness changed on {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"turns_on": LightTurnsOnTrigger,
|
||||
"turns_off": LightTurnsOffTrigger,
|
||||
"brightness_changed": LightBrightnessChangedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for lights."""
|
||||
return TRIGGERS
|
||||
@@ -1,43 +0,0 @@
|
||||
turns_on:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
|
||||
turns_off:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
|
||||
brightness_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
lower:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 255
|
||||
mode: box
|
||||
upper:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 255
|
||||
mode: box
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 255
|
||||
mode: box
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 255
|
||||
mode: box
|
||||
@@ -367,63 +367,5 @@
|
||||
"name": "Turn up volume"
|
||||
}
|
||||
},
|
||||
"title": "Media player",
|
||||
"triggers": {
|
||||
"turns_on": {
|
||||
"description": "Triggers when a media player turns on.",
|
||||
"description_configured": "Triggers when a media player turns on",
|
||||
"name": "When a media player turns on"
|
||||
},
|
||||
"turns_off": {
|
||||
"description": "Triggers when a media player turns off.",
|
||||
"description_configured": "Triggers when a media player turns off",
|
||||
"name": "When a media player turns off"
|
||||
},
|
||||
"playing": {
|
||||
"description": "Triggers when a media player starts playing.",
|
||||
"description_configured": "Triggers when a media player starts playing",
|
||||
"fields": {
|
||||
"media_content_type": {
|
||||
"description": "The media content types to trigger on. If empty, triggers on all content types.",
|
||||
"name": "Media content types"
|
||||
}
|
||||
},
|
||||
"name": "When a media player starts playing"
|
||||
},
|
||||
"paused": {
|
||||
"description": "Triggers when a media player pauses.",
|
||||
"description_configured": "Triggers when a media player pauses",
|
||||
"name": "When a media player pauses"
|
||||
},
|
||||
"stopped": {
|
||||
"description": "Triggers when a media player stops playing.",
|
||||
"description_configured": "Triggers when a media player stops playing",
|
||||
"name": "When a media player stops playing"
|
||||
},
|
||||
"muted": {
|
||||
"description": "Triggers when a media player gets muted.",
|
||||
"description_configured": "Triggers when a media player gets muted",
|
||||
"name": "When a media player gets muted"
|
||||
},
|
||||
"unmuted": {
|
||||
"description": "Triggers when a media player gets unmuted.",
|
||||
"description_configured": "Triggers when a media player gets unmuted",
|
||||
"name": "When a media player gets unmuted"
|
||||
},
|
||||
"volume_changed": {
|
||||
"description": "Triggers when a media player volume changes.",
|
||||
"description_configured": "Triggers when a media player volume changes",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when volume is above this level (0.0-1.0).",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when volume is below this level (0.0-1.0).",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "When a media player volume changes"
|
||||
}
|
||||
}
|
||||
"title": "Media player"
|
||||
}
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
"""Provides triggers for media players."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
PLAYING_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional(ATTR_MEDIA_CONTENT_TYPE, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
STOPPED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
MUTED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
UNMUTED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
VOLUME_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {
|
||||
vol.Optional("above"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
|
||||
vol.Optional("below"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PAUSED_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerTurnsOnTrigger(Trigger):
|
||||
"""Trigger for when a media player turns on."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player turns on trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when turning on from off state
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.state == STATE_OFF
|
||||
and to_state.state != STATE_OFF
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} turned on",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerTurnsOffTrigger(Trigger):
|
||||
"""Trigger for when a media player turns off."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player turns off trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when turning off
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.state != STATE_OFF
|
||||
and to_state.state == STATE_OFF
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} turned off",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerPlayingTrigger(Trigger):
|
||||
"""Trigger for when a media player starts playing."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, PLAYING_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player playing trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
media_content_types_filter = self._options[ATTR_MEDIA_CONTENT_TYPE]
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when starting to play
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.state != STATE_PLAYING
|
||||
and to_state.state == STATE_PLAYING
|
||||
):
|
||||
# If media_content_type filter is specified, check if it matches
|
||||
if media_content_types_filter:
|
||||
media_content_type = to_state.attributes.get(ATTR_MEDIA_CONTENT_TYPE)
|
||||
if media_content_type not in media_content_types_filter:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} started playing",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerPausedTrigger(Trigger):
|
||||
"""Trigger for when a media player pauses."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, PAUSED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player paused trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when pausing
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.state != STATE_PAUSED
|
||||
and to_state.state == STATE_PAUSED
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} paused",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerStoppedTrigger(Trigger):
|
||||
"""Trigger for when a media player stops playing."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, STOPPED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player stopped trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when stopping (to idle or off from playing/paused states)
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.state in (STATE_PLAYING, STATE_PAUSED)
|
||||
and to_state.state in (STATE_IDLE, STATE_OFF)
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} stopped",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerMutedTrigger(Trigger):
|
||||
"""Trigger for when a media player gets muted."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, MUTED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player muted trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when muting
|
||||
if (
|
||||
from_state is not None
|
||||
and not from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
||||
and to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} muted",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerUnmutedTrigger(Trigger):
|
||||
"""Trigger for when a media player gets unmuted."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, UNMUTED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player unmuted trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Trigger when unmuting
|
||||
if (
|
||||
from_state is not None
|
||||
and from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
||||
and not to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
||||
):
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} unmuted",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerVolumeChangedTrigger(Trigger):
|
||||
"""Trigger for when a media player volume changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, VOLUME_CHANGED_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the media player volume changed trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
above_threshold = self._options.get("above")
|
||||
below_threshold = self._options.get("below")
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Ignore unavailable states
|
||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
||||
return
|
||||
|
||||
# Get volume levels
|
||||
old_volume = (
|
||||
from_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
|
||||
if from_state is not None
|
||||
else None
|
||||
)
|
||||
new_volume = to_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
|
||||
|
||||
# Volume must have changed
|
||||
if old_volume == new_volume or new_volume is None:
|
||||
return
|
||||
|
||||
# Check thresholds if specified
|
||||
if above_threshold is not None and new_volume <= above_threshold:
|
||||
return
|
||||
|
||||
if below_threshold is not None and new_volume >= below_threshold:
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"media player {entity_id} volume changed",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"turns_on": MediaPlayerTurnsOnTrigger,
|
||||
"turns_off": MediaPlayerTurnsOffTrigger,
|
||||
"playing": MediaPlayerPlayingTrigger,
|
||||
"paused": MediaPlayerPausedTrigger,
|
||||
"stopped": MediaPlayerStoppedTrigger,
|
||||
"muted": MediaPlayerMutedTrigger,
|
||||
"unmuted": MediaPlayerUnmutedTrigger,
|
||||
"volume_changed": MediaPlayerVolumeChangedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for media players."""
|
||||
return TRIGGERS
|
||||
@@ -1,65 +0,0 @@
|
||||
turns_on:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
|
||||
turns_off:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
|
||||
playing:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
fields:
|
||||
media_content_type:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
custom_value: true
|
||||
options: []
|
||||
|
||||
paused:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
|
||||
stopped:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
|
||||
muted:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
|
||||
unmuted:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
|
||||
volume_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
fields:
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0.0
|
||||
max: 1.0
|
||||
step: 0.01
|
||||
mode: slider
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0.0
|
||||
max: 1.0
|
||||
step: 0.01
|
||||
mode: slider
|
||||
@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
|
||||
from music_assistant_models.api import ServerInfoMessage
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
@@ -21,14 +21,21 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
DEFAULT_TITLE = "Music Assistant"
|
||||
DEFAULT_URL = "http://mass.local:8095"
|
||||
DEFAULT_TITLE = "Music Assistant"
|
||||
|
||||
|
||||
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_URL): str})
|
||||
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
|
||||
"""Return a schema for the manual step."""
|
||||
default_url = user_input.get(CONF_URL, DEFAULT_URL)
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL, default=default_url): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
|
||||
async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
|
||||
"""Validate the user input allows us to connect."""
|
||||
async with MusicAssistantClient(
|
||||
url, aiohttp_client.async_get_clientsession(hass)
|
||||
@@ -45,17 +52,25 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up flow instance."""
|
||||
self.url: str | None = None
|
||||
self.server_info: ServerInfoMessage | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a manual configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
server_info = await _get_server_info(self.hass, user_input[CONF_URL])
|
||||
self.server_info = await get_server_info(
|
||||
self.hass, user_input[CONF_URL]
|
||||
)
|
||||
await self.async_set_unique_id(
|
||||
self.server_info.server_id, raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_URL: user_input[CONF_URL]},
|
||||
reload_on_update=True,
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidServerVersion:
|
||||
@@ -64,49 +79,68 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
server_info.server_id, raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_URL: user_input[CONF_URL]}
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={CONF_URL: user_input[CONF_URL]},
|
||||
data={
|
||||
CONF_URL: user_input[CONF_URL],
|
||||
},
|
||||
)
|
||||
|
||||
suggested_values = user_input
|
||||
if suggested_values is None:
|
||||
suggested_values = {CONF_URL: DEFAULT_URL}
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=get_manual_schema(user_input), errors=errors
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_SCHEMA, suggested_values
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=get_manual_schema({}))
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a zeroconf discovery for a Music Assistant server."""
|
||||
"""Handle a discovered Mass server.
|
||||
|
||||
This flow is triggered by the Zeroconf component. It will check if the
|
||||
host is already configured and delegate to the import step if not.
|
||||
"""
|
||||
# abort if discovery info is not what we expect
|
||||
if "server_id" not in discovery_info.properties:
|
||||
return self.async_abort(reason="missing_server_id")
|
||||
|
||||
self.server_info = ServerInfoMessage.from_dict(discovery_info.properties)
|
||||
await self.async_set_unique_id(self.server_info.server_id)
|
||||
|
||||
# Check if we already have a config entry for this server_id
|
||||
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
|
||||
DOMAIN, self.server_info.server_id
|
||||
)
|
||||
|
||||
if existing_entry:
|
||||
# If the entry was ignored or disabled, don't make any changes
|
||||
if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test connectivity to the current URL first
|
||||
current_url = existing_entry.data[CONF_URL]
|
||||
try:
|
||||
await get_server_info(self.hass, current_url)
|
||||
# Current URL is working, no need to update
|
||||
return self.async_abort(reason="already_configured")
|
||||
except CannotConnect:
|
||||
# Current URL is not working, update to the discovered URL
|
||||
# and continue to discovery confirm
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry,
|
||||
data={**existing_entry.data, CONF_URL: self.server_info.base_url},
|
||||
)
|
||||
# Schedule reload since URL changed
|
||||
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
|
||||
else:
|
||||
# No existing entry, proceed with normal flow
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Test connectivity to the discovered URL
|
||||
try:
|
||||
server_info = ServerInfoMessage.from_dict(discovery_info.properties)
|
||||
except LookupError:
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
|
||||
self.url = server_info.base_url
|
||||
|
||||
await self.async_set_unique_id(server_info.server_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
|
||||
|
||||
try:
|
||||
await _get_server_info(self.hass, self.url)
|
||||
await get_server_info(self.hass, self.server_info.base_url)
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
@@ -114,16 +148,16 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user-confirmation of discovered server."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.url is not None
|
||||
|
||||
assert self.server_info is not None
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={CONF_URL: self.url},
|
||||
data={
|
||||
CONF_URL: self.server_info.base_url,
|
||||
},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"url": self.url},
|
||||
description_placeholders={"url": self.server_info.base_url},
|
||||
)
|
||||
|
||||
@@ -20,11 +20,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -74,19 +73,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Netatmo from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
|
||||
# Set unique id if non was set (migration)
|
||||
if not entry.unique_id:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
|
||||
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
|
||||
@@ -143,11 +143,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"public_weather": {
|
||||
|
||||
@@ -12,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, httpx_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
httpx_client,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -29,21 +28,19 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
|
||||
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up SENZ from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
|
||||
senz_api = SENZAPI(auth)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
|
||||
class SENZClimate(CoordinatorEntity, ClimateEntity):
|
||||
"""Representation of a SENZ climate entity."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Diagnostics platform for Senz integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TO_REDACT = [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
raw_data = (
|
||||
[device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()],
|
||||
)
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"thermostats": raw_data,
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"""nVent RAYCHEM SENZ sensor platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiosenz import Thermostat
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import SENZDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class SenzSensorDescription(SensorEntityDescription):
|
||||
"""Describes SENZ sensor entity."""
|
||||
|
||||
value_fn: Callable[[Thermostat], str | int | float | None]
|
||||
|
||||
|
||||
SENSORS: tuple[SenzSensorDescription, ...] = (
|
||||
SenzSensorDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda data: data.current_temperatue,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the SENZ sensor entities from a config entry."""
|
||||
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
SENZSensor(thermostat, coordinator, description)
|
||||
for description in SENSORS
|
||||
for thermostat in coordinator.data.values()
|
||||
)
|
||||
|
||||
|
||||
class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of a SENZ sensor entity."""
|
||||
|
||||
entity_description: SenzSensorDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thermostat: Thermostat,
|
||||
coordinator: SENZDataUpdateCoordinator,
|
||||
description: SenzSensorDescription,
|
||||
) -> None:
|
||||
"""Init SENZ sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._thermostat = thermostat
|
||||
self._attr_unique_id = f"{thermostat.serial_number}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, thermostat.serial_number)},
|
||||
manufacturer="nVent Raychem",
|
||||
model="SENZ WIFI",
|
||||
name=thermostat.name,
|
||||
serial_number=thermostat.serial_number,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the thermostat is available."""
|
||||
return super().available and self._thermostat.online
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | float | int | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self._thermostat)
|
||||
@@ -25,10 +25,5 @@
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,11 @@ from homeassistant.core import (
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.trigger_template_entity import ValueTemplate
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
from .const import CONF_QUERY, DOMAIN
|
||||
from .util import (
|
||||
async_create_sessionmaker,
|
||||
check_and_render_sql_query,
|
||||
convert_value,
|
||||
generate_lambda_stmt,
|
||||
redact_credentials,
|
||||
@@ -39,9 +37,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SERVICE_QUERY = "query"
|
||||
SERVICE_QUERY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_QUERY): vol.All(
|
||||
cv.template, ValueTemplate.from_template, validate_sql_select
|
||||
),
|
||||
vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
|
||||
vol.Optional(CONF_DB_URL): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -76,9 +72,8 @@ async def _async_query_service(
|
||||
def _execute_and_convert_query() -> list[JsonValueType]:
|
||||
"""Execute the query and return the results with converted types."""
|
||||
sess: Session = sessmaker()
|
||||
rendered_query = check_and_render_sql_query(call.hass, query_str)
|
||||
try:
|
||||
result: Result = sess.execute(generate_lambda_stmt(rendered_query))
|
||||
result: Result = sess.execute(generate_lambda_stmt(query_str))
|
||||
except SQLAlchemyError as err:
|
||||
_LOGGER.debug(
|
||||
"Error executing query %s: %s",
|
||||
|
||||
@@ -18,7 +18,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.recorder import SupportedDialect, get_instance
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.template import Template
|
||||
@@ -46,11 +46,15 @@ def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str:
|
||||
return get_instance(hass).db_url
|
||||
|
||||
|
||||
def validate_sql_select(value: Template) -> Template:
|
||||
def validate_sql_select(value: Template | str) -> Template | str:
|
||||
"""Validate that value is a SQL SELECT query."""
|
||||
hass: HomeAssistant
|
||||
if isinstance(value, str):
|
||||
hass = async_get_hass()
|
||||
else:
|
||||
hass = value.hass # type: ignore[assignment]
|
||||
try:
|
||||
assert value.hass
|
||||
check_and_render_sql_query(value.hass, value)
|
||||
check_and_render_sql_query(hass, value)
|
||||
except (TemplateError, InvalidSqlQuery) as err:
|
||||
raise vol.Invalid(str(err)) from err
|
||||
return value
|
||||
|
||||
@@ -75,7 +75,6 @@ PLATFORMS_BY_TYPE = {
|
||||
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
|
||||
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
|
||||
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
@@ -103,10 +102,6 @@ PLATFORMS_BY_TYPE = {
|
||||
SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
|
||||
SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR],
|
||||
SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
|
||||
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: [
|
||||
Platform.CLIMATE,
|
||||
Platform.SENSOR,
|
||||
],
|
||||
}
|
||||
CLASS_BY_DEVICE = {
|
||||
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
|
||||
@@ -124,7 +119,6 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
|
||||
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
|
||||
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
@@ -142,7 +136,6 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch,
|
||||
SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM,
|
||||
SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener,
|
||||
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: switchbot.SwitchbotSmartThermostatRadiator,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Support for Switchbot Climate devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import switchbot
|
||||
from switchbot import (
|
||||
ClimateAction as SwitchBotClimateAction,
|
||||
ClimateMode as SwitchBotClimateMode,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import SwitchbotConfigEntry
|
||||
from .entity import SwitchbotEntity, exception_handler
|
||||
|
||||
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE = {
|
||||
SwitchBotClimateMode.HEAT: HVACMode.HEAT,
|
||||
SwitchBotClimateMode.OFF: HVACMode.OFF,
|
||||
}
|
||||
|
||||
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE = {
|
||||
HVACMode.HEAT: SwitchBotClimateMode.HEAT,
|
||||
HVACMode.OFF: SwitchBotClimateMode.OFF,
|
||||
}
|
||||
|
||||
SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION = {
|
||||
SwitchBotClimateAction.HEATING: HVACAction.HEATING,
|
||||
SwitchBotClimateAction.IDLE: HVACAction.IDLE,
|
||||
SwitchBotClimateAction.OFF: HVACAction.OFF,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SwitchbotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Switchbot climate based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([SwitchBotClimateEntity(coordinator)])
|
||||
|
||||
|
||||
class SwitchBotClimateEntity(SwitchbotEntity, ClimateEntity):
|
||||
"""Representation of a Switchbot Climate device."""
|
||||
|
||||
_device: switchbot.SwitchbotDevice
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_target_temperature_step = 0.5
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "climate"
|
||||
_attr_name = None
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature."""
|
||||
return self._device.min_temperature
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature."""
|
||||
return self._device.max_temperature
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return the list of available preset modes."""
|
||||
return self._device.preset_modes
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return self._device.preset_mode
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
return SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE.get(
|
||||
self._device.hvac_mode, HVACMode.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available HVAC modes."""
|
||||
return [
|
||||
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE[mode]
|
||||
for mode in self._device.hvac_modes
|
||||
]
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current HVAC action."""
|
||||
return SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION.get(
|
||||
self._device.hvac_action, HVACAction.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._device.current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._device.target_temperature
|
||||
|
||||
@exception_handler
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new HVAC mode."""
|
||||
return await self._device.set_hvac_mode(
|
||||
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE[hvac_mode]
|
||||
)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
return await self._device.set_preset_mode(preset_mode)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
return await self._device.set_target_temperature(temperature)
|
||||
@@ -58,8 +58,6 @@ class SupportedModels(StrEnum):
|
||||
K11_PLUS_VACUUM = "k11+_vacuum"
|
||||
GARAGE_DOOR_OPENER = "garage_door_opener"
|
||||
CLIMATE_PANEL = "climate_panel"
|
||||
SMART_THERMOSTAT_RADIATOR = "smart_thermostat_radiator"
|
||||
S20_VACUUM = "s20_vacuum"
|
||||
|
||||
|
||||
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -80,7 +78,6 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN,
|
||||
SwitchbotModel.K20_VACUUM: SupportedModels.K20_VACUUM,
|
||||
SwitchbotModel.S10_VACUUM: SupportedModels.S10_VACUUM,
|
||||
SwitchbotModel.S20_VACUUM: SupportedModels.S20_VACUUM,
|
||||
SwitchbotModel.K10_VACUUM: SupportedModels.K10_VACUUM,
|
||||
SwitchbotModel.K10_PRO_VACUUM: SupportedModels.K10_PRO_VACUUM,
|
||||
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
|
||||
@@ -98,7 +95,6 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER,
|
||||
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: SupportedModels.SMART_THERMOSTAT_RADIATOR,
|
||||
}
|
||||
|
||||
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -136,7 +132,6 @@ ENCRYPTED_MODELS = {
|
||||
SwitchbotModel.PLUG_MINI_EU,
|
||||
SwitchbotModel.RELAY_SWITCH_2PM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
|
||||
}
|
||||
|
||||
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
@@ -158,7 +153,6 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
|
||||
SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator,
|
||||
}
|
||||
|
||||
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"climate": {
|
||||
"climate": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"manual": "mdi:hand-back-right",
|
||||
"off": "mdi:hvac-off",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_purifier": {
|
||||
"default": "mdi:air-purifier",
|
||||
|
||||
@@ -100,19 +100,6 @@
|
||||
"name": "Unlocked alarm"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"climate": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"manual": "[%key:common::state::manual%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cover": {
|
||||
"cover": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -23,7 +23,6 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -62,11 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
except ValueError as e:
|
||||
# Remove invalid implementation from config entry then raise AuthFailed
|
||||
hass.config_entries.async_update_entry(
|
||||
|
||||
@@ -609,9 +609,6 @@
|
||||
"no_cable": {
|
||||
"message": "Charge cable will lock automatically when connected"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "{endpoint} data request failed: {message}"
|
||||
}
|
||||
|
||||
@@ -11,10 +11,8 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -88,13 +86,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Toon from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
coordinator = ToonDataUpdateCoordinator(hass, entry, session)
|
||||
|
||||
@@ -32,11 +32,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"update": {
|
||||
"description": "Updates all entities with fresh data from Toon.",
|
||||
|
||||
@@ -181,14 +181,15 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
|
||||
HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF
|
||||
)
|
||||
|
||||
if self._thermostat_module.mode not in STATE_TO_ACTION:
|
||||
# Report a warning on the first non-default unknown mode
|
||||
if self._attr_hvac_action is not HVACAction.OFF:
|
||||
_LOGGER.warning(
|
||||
"Unknown thermostat state, defaulting to OFF: %s",
|
||||
self._thermostat_module.mode,
|
||||
)
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
if (
|
||||
self._thermostat_module.mode not in STATE_TO_ACTION
|
||||
and self._attr_hvac_action is not HVACAction.OFF
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Unknown thermostat state, defaulting to OFF: %s",
|
||||
self._thermostat_module.mode,
|
||||
)
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
return True
|
||||
|
||||
self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode]
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
from .const import (
|
||||
@@ -24,7 +23,7 @@ from .const import (
|
||||
SERVICE_START_TORRENT,
|
||||
SERVICE_STOP_TORRENT,
|
||||
)
|
||||
from .coordinator import TransmissionDataUpdateCoordinator
|
||||
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,52 +67,45 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All(
|
||||
|
||||
|
||||
def _get_coordinator_from_service_data(
|
||||
call: ServiceCall,
|
||||
hass: HomeAssistant, entry_id: str
|
||||
) -> TransmissionDataUpdateCoordinator:
|
||||
"""Return coordinator for entry id."""
|
||||
config_entry_id: str = call.data[CONF_ENTRY_ID]
|
||||
if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": DOMAIN},
|
||||
)
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": entry.title},
|
||||
)
|
||||
return cast(TransmissionDataUpdateCoordinator, entry.runtime_data)
|
||||
entry: TransmissionConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
entry_id
|
||||
)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
raise HomeAssistantError(f"Config entry {entry_id} is not found or not loaded")
|
||||
return entry.runtime_data
|
||||
|
||||
|
||||
async def _async_add_torrent(service: ServiceCall) -> None:
|
||||
"""Add new torrent to download."""
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
torrent: str = service.data[ATTR_TORRENT]
|
||||
download_path: str | None = service.data.get(ATTR_DOWNLOAD_PATH)
|
||||
|
||||
if not (
|
||||
torrent.startswith(("http", "ftp:", "magnet:"))
|
||||
or service.hass.config.is_allowed_path(torrent)
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="could_not_add_torrent",
|
||||
)
|
||||
|
||||
if download_path:
|
||||
await service.hass.async_add_executor_job(
|
||||
partial(coordinator.api.add_torrent, torrent, download_dir=download_path)
|
||||
)
|
||||
if torrent.startswith(
|
||||
("http", "ftp:", "magnet:")
|
||||
) or service.hass.config.is_allowed_path(torrent):
|
||||
if download_path:
|
||||
await service.hass.async_add_executor_job(
|
||||
partial(
|
||||
coordinator.api.add_torrent, torrent, download_dir=download_path
|
||||
)
|
||||
)
|
||||
else:
|
||||
await service.hass.async_add_executor_job(
|
||||
coordinator.api.add_torrent, torrent
|
||||
)
|
||||
await coordinator.async_request_refresh()
|
||||
else:
|
||||
await service.hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
|
||||
await coordinator.async_request_refresh()
|
||||
_LOGGER.warning("Could not add torrent: unsupported type or no permission")
|
||||
|
||||
|
||||
async def _async_start_torrent(service: ServiceCall) -> None:
|
||||
"""Start torrent."""
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
await service.hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id)
|
||||
await coordinator.async_request_refresh()
|
||||
@@ -121,7 +113,8 @@ async def _async_start_torrent(service: ServiceCall) -> None:
|
||||
|
||||
async def _async_stop_torrent(service: ServiceCall) -> None:
|
||||
"""Stop torrent."""
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
await service.hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id)
|
||||
await coordinator.async_request_refresh()
|
||||
@@ -129,7 +122,8 @@ async def _async_stop_torrent(service: ServiceCall) -> None:
|
||||
|
||||
async def _async_remove_torrent(service: ServiceCall) -> None:
|
||||
"""Remove torrent."""
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
delete_data = service.data[ATTR_DELETE_DATA]
|
||||
await service.hass.async_add_executor_job(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
add_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
@@ -19,7 +18,6 @@ add_torrent:
|
||||
remove_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
@@ -29,7 +27,6 @@ remove_torrent:
|
||||
selector:
|
||||
text:
|
||||
delete_data:
|
||||
required: true
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
@@ -37,12 +34,10 @@ remove_torrent:
|
||||
start_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
id:
|
||||
required: true
|
||||
example: 123
|
||||
selector:
|
||||
text:
|
||||
@@ -50,7 +45,6 @@ start_torrent:
|
||||
stop_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
|
||||
@@ -87,17 +87,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"could_not_add_torrent": {
|
||||
"message": "Could not add torrent: unsupported type or no permission."
|
||||
},
|
||||
"integration_not_found": {
|
||||
"message": "Integration \"{target}\" not found in registry."
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -6,14 +6,13 @@ from abc import ABC, abstractmethod
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import struct
|
||||
from typing import Any, Literal, Self, overload
|
||||
|
||||
from tuya_sharing import CustomerDevice
|
||||
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import DPCode, DPType
|
||||
from .util import parse_dptype, remap_value
|
||||
from .util import remap_value
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -135,8 +134,6 @@ _TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
DPType.BOOLEAN: TypeInformation,
|
||||
DPType.ENUM: EnumTypeData,
|
||||
DPType.INTEGER: IntegerTypeData,
|
||||
DPType.JSON: TypeInformation,
|
||||
DPType.RAW: TypeInformation,
|
||||
}
|
||||
|
||||
|
||||
@@ -147,9 +144,6 @@ class DPCodeWrapper(ABC):
|
||||
access read conversion routines.
|
||||
"""
|
||||
|
||||
native_unit: str | None = None
|
||||
suggested_unit: str | None = None
|
||||
|
||||
def __init__(self, dpcode: str) -> None:
|
||||
"""Init DPCodeWrapper."""
|
||||
self.dpcode = dpcode
|
||||
@@ -216,20 +210,6 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
return None
|
||||
|
||||
|
||||
class DPCodeBase64Wrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Wrapper to extract information from a RAW/binary value."""
|
||||
|
||||
DPTYPE = DPType.RAW
|
||||
|
||||
def read_bytes(self, device: CustomerDevice) -> bytes | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None or (
|
||||
len(decoded := base64.b64decode(raw_value)) == 0
|
||||
):
|
||||
return None
|
||||
return decoded
|
||||
|
||||
|
||||
class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Simple wrapper for boolean values.
|
||||
|
||||
@@ -255,18 +235,6 @@ class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
raise ValueError(f"Invalid boolean value `{value}`")
|
||||
|
||||
|
||||
class DPCodeJsonWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Wrapper to extract information from a JSON value."""
|
||||
|
||||
DPTYPE = DPType.JSON
|
||||
|
||||
def read_json(self, device: CustomerDevice) -> Any | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
return json_loads(raw_value)
|
||||
|
||||
|
||||
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
"""Simple wrapper for EnumTypeData values."""
|
||||
|
||||
@@ -300,11 +268,6 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
|
||||
|
||||
DPTYPE = DPType.INTEGER
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.native_unit = type_information.unit
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode.
|
||||
|
||||
@@ -389,16 +352,6 @@ def find_dpcode(
|
||||
) -> IntegerTypeData | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
|
||||
) -> TypeInformation | None: ...
|
||||
|
||||
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
@@ -428,7 +381,7 @@ def find_dpcode(
|
||||
for device_specs in lookup_tuple:
|
||||
if (
|
||||
(current_definition := device_specs.get(dpcode))
|
||||
and parse_dptype(current_definition.type) is dptype
|
||||
and current_definition.type == dptype
|
||||
and (
|
||||
type_information := type_information_cls.from_json(
|
||||
dpcode, current_definition.values
|
||||
@@ -438,3 +391,44 @@ def find_dpcode(
|
||||
return type_information
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ComplexValue:
|
||||
"""Complex value (for JSON/RAW parsing)."""
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> Self:
|
||||
"""Load JSON string and return a ComplexValue object."""
|
||||
raise NotImplementedError("from_json is not implemented for this type")
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data: str) -> Self | None:
|
||||
"""Decode base64 string and return a ComplexValue object."""
|
||||
raise NotImplementedError("from_raw is not implemented for this type")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElectricityValue(ComplexValue):
|
||||
"""Electricity complex value."""
|
||||
|
||||
electriccurrent: str | None = None
|
||||
power: str | None = None
|
||||
voltage: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> Self:
|
||||
"""Load JSON string and return a ElectricityValue object."""
|
||||
return cls(**json.loads(data.lower()))
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data: str) -> Self | None:
|
||||
"""Decode base64 string and return a ElectricityValue object."""
|
||||
raw = base64.b64decode(data)
|
||||
if len(raw) == 0:
|
||||
return None
|
||||
voltage = struct.unpack(">H", raw[0:2])[0] / 10.0
|
||||
electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0
|
||||
power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0
|
||||
return cls(
|
||||
electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage)
|
||||
)
|
||||
|
||||
@@ -502,19 +502,14 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
|
||||
self._attr_native_min_value = dpcode_wrapper.type_information.min_scaled
|
||||
self._attr_native_step = dpcode_wrapper.type_information.step_scaled
|
||||
if description.native_unit_of_measurement is None:
|
||||
self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit
|
||||
|
||||
self._validate_device_class_unit()
|
||||
|
||||
def _validate_device_class_unit(self) -> None:
|
||||
"""Validate device class unit compatibility."""
|
||||
self._attr_native_unit_of_measurement = dpcode_wrapper.type_information.unit
|
||||
|
||||
# Logic to ensure the set device class and API received Unit Of Measurement
|
||||
# match Home Assistants requirements.
|
||||
if (
|
||||
self.device_class is not None
|
||||
and not self.device_class.startswith(DOMAIN)
|
||||
and self.entity_description.native_unit_of_measurement is None
|
||||
and description.native_unit_of_measurement is None
|
||||
# we do not need to check mappings if the API UOM is allowed
|
||||
and self.native_unit_of_measurement
|
||||
not in NUMBER_DEVICE_CLASS_UNITS[self.device_class]
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -41,134 +42,41 @@ from .const import (
|
||||
)
|
||||
from .entity import TuyaEntity
|
||||
from .models import (
|
||||
DPCodeBase64Wrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
DPCodeTypeInformationWrapper,
|
||||
DPCodeWrapper,
|
||||
ComplexValue,
|
||||
ElectricityValue,
|
||||
EnumTypeData,
|
||||
IntegerTypeData,
|
||||
find_dpcode,
|
||||
)
|
||||
from .util import get_dptype
|
||||
|
||||
|
||||
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
"""Custom DPCode Wrapper for converting enum to wind direction."""
|
||||
|
||||
DPTYPE = DPType.ENUM
|
||||
|
||||
_WIND_DIRECTIONS = {
|
||||
"north": 0.0,
|
||||
"north_north_east": 22.5,
|
||||
"north_east": 45.0,
|
||||
"east_north_east": 67.5,
|
||||
"east": 90.0,
|
||||
"east_south_east": 112.5,
|
||||
"south_east": 135.0,
|
||||
"south_south_east": 157.5,
|
||||
"south": 180.0,
|
||||
"south_south_west": 202.5,
|
||||
"south_west": 225.0,
|
||||
"west_south_west": 247.5,
|
||||
"west": 270.0,
|
||||
"west_north_west": 292.5,
|
||||
"north_west": 315.0,
|
||||
"north_north_west": 337.5,
|
||||
}
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (
|
||||
raw_value := self._read_device_status_raw(device)
|
||||
) in self.type_information.range:
|
||||
return self._WIND_DIRECTIONS.get(raw_value)
|
||||
return None
|
||||
|
||||
|
||||
class _JsonElectricityCurrentWrapper(DPCodeJsonWrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity current from JSON."""
|
||||
|
||||
native_unit = UnitOfElectricCurrent.AMPERE
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_json(device)) is None:
|
||||
return None
|
||||
return raw_value.get("electricCurrent")
|
||||
|
||||
|
||||
class _JsonElectricityPowerWrapper(DPCodeJsonWrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity power from JSON."""
|
||||
|
||||
native_unit = UnitOfPower.KILO_WATT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_json(device)) is None:
|
||||
return None
|
||||
return raw_value.get("power")
|
||||
|
||||
|
||||
class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity voltage from JSON."""
|
||||
|
||||
native_unit = UnitOfElectricPotential.VOLT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_json(device)) is None:
|
||||
return None
|
||||
return raw_value.get("voltage")
|
||||
|
||||
|
||||
class _RawElectricityCurrentWrapper(DPCodeBase64Wrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity current from base64."""
|
||||
|
||||
native_unit = UnitOfElectricCurrent.MILLIAMPERE
|
||||
suggested_unit = UnitOfElectricCurrent.AMPERE
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_bytes(device)) is None:
|
||||
return None
|
||||
return struct.unpack(">L", b"\x00" + raw_value[2:5])[0]
|
||||
|
||||
|
||||
class _RawElectricityPowerWrapper(DPCodeBase64Wrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity power from base64."""
|
||||
|
||||
native_unit = UnitOfPower.WATT
|
||||
suggested_unit = UnitOfPower.KILO_WATT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_bytes(device)) is None:
|
||||
return None
|
||||
return struct.unpack(">L", b"\x00" + raw_value[5:8])[0]
|
||||
|
||||
|
||||
class _RawElectricityVoltageWrapper(DPCodeBase64Wrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity voltage from base64."""
|
||||
|
||||
native_unit = UnitOfElectricPotential.VOLT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_bytes(device)) is None:
|
||||
return None
|
||||
return struct.unpack(">H", raw_value[0:2])[0] / 10.0
|
||||
|
||||
|
||||
CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper)
|
||||
POWER_WRAPPER = (_RawElectricityPowerWrapper, _JsonElectricityPowerWrapper)
|
||||
VOLTAGE_WRAPPER = (_RawElectricityVoltageWrapper, _JsonElectricityVoltageWrapper)
|
||||
_WIND_DIRECTIONS = {
|
||||
"north": 0.0,
|
||||
"north_north_east": 22.5,
|
||||
"north_east": 45.0,
|
||||
"east_north_east": 67.5,
|
||||
"east": 90.0,
|
||||
"east_south_east": 112.5,
|
||||
"south_east": 135.0,
|
||||
"south_south_east": 157.5,
|
||||
"south": 180.0,
|
||||
"south_south_west": 202.5,
|
||||
"south_west": 225.0,
|
||||
"west_south_west": 247.5,
|
||||
"west": 270.0,
|
||||
"west_north_west": 292.5,
|
||||
"north_west": 315.0,
|
||||
"north_north_west": 337.5,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Tuya sensor entity."""
|
||||
|
||||
dpcode: DPCode | None = None
|
||||
wrapper_class: tuple[type[DPCodeTypeInformationWrapper], ...] | None = None
|
||||
complex_type: type[ComplexValue] | None = None
|
||||
subkey: str | None = None
|
||||
state_conversion: Callable[[Any], StateType] | None = None
|
||||
|
||||
|
||||
# Commonly used battery sensors, that are reused in the sensors down below.
|
||||
@@ -486,76 +394,85 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_A}electriccurrent",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
key=DPCode.PHASE_A,
|
||||
translation_key="phase_a_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_A}power",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
key=DPCode.PHASE_A,
|
||||
translation_key="phase_a_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_A}voltage",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
key=DPCode.PHASE_A,
|
||||
translation_key="phase_a_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_B}electriccurrent",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
key=DPCode.PHASE_B,
|
||||
translation_key="phase_b_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_B}power",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
key=DPCode.PHASE_B,
|
||||
translation_key="phase_b_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_B}voltage",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
key=DPCode.PHASE_B,
|
||||
translation_key="phase_b_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_C}electriccurrent",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
key=DPCode.PHASE_C,
|
||||
translation_key="phase_c_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_C}power",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
key=DPCode.PHASE_C,
|
||||
translation_key="phase_c_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_C}voltage",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
key=DPCode.PHASE_C,
|
||||
translation_key="phase_c_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.CUR_CURRENT,
|
||||
@@ -1055,7 +972,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
translation_key="wind_direction",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=(_WindDirectionWrapper,),
|
||||
state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)),
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.DEW_POINT_TEMP,
|
||||
@@ -1568,11 +1485,12 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.TOTAL_POWER}power",
|
||||
dpcode=DPCode.TOTAL_POWER,
|
||||
key=DPCode.TOTAL_POWER,
|
||||
translation_key="total_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.SUPPLY_FREQUENCY,
|
||||
@@ -1582,76 +1500,85 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_A}electriccurrent",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
key=DPCode.PHASE_A,
|
||||
translation_key="phase_a_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_A}power",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
key=DPCode.PHASE_A,
|
||||
translation_key="phase_a_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_A}voltage",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
key=DPCode.PHASE_A,
|
||||
translation_key="phase_a_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_B}electriccurrent",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
key=DPCode.PHASE_B,
|
||||
translation_key="phase_b_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_B}power",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
key=DPCode.PHASE_B,
|
||||
translation_key="phase_b_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_B}voltage",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
key=DPCode.PHASE_B,
|
||||
translation_key="phase_b_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_C}electriccurrent",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
key=DPCode.PHASE_C,
|
||||
translation_key="phase_c_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_C}power",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
key=DPCode.PHASE_C,
|
||||
translation_key="phase_c_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=f"{DPCode.PHASE_C}voltage",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
key=DPCode.PHASE_C,
|
||||
translation_key="phase_c_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
),
|
||||
),
|
||||
DeviceCategory.ZNNBQ: (
|
||||
@@ -1712,27 +1639,6 @@ SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP]
|
||||
SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG]
|
||||
|
||||
|
||||
def _get_dpcode_wrapper(
|
||||
device: CustomerDevice,
|
||||
description: TuyaSensorEntityDescription,
|
||||
) -> DPCodeWrapper | None:
|
||||
"""Get DPCode wrapper for an entity description."""
|
||||
dpcode = description.dpcode or description.key
|
||||
wrapper: DPCodeWrapper | None
|
||||
|
||||
if description.wrapper_class:
|
||||
for cls in description.wrapper_class:
|
||||
if wrapper := cls.find_dpcode(device, dpcode):
|
||||
return wrapper
|
||||
return None
|
||||
|
||||
for cls in (DPCodeIntegerWrapper, DPCodeEnumWrapper):
|
||||
if wrapper := cls.find_dpcode(device, dpcode):
|
||||
return wrapper
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -1749,9 +1655,9 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := SENSORS.get(device.category):
|
||||
entities.extend(
|
||||
TuyaSensorEntity(device, manager, description, dpcode_wrapper)
|
||||
TuyaSensorEntity(device, manager, description)
|
||||
for description in descriptions
|
||||
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
|
||||
if description.key in device.status
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -1767,25 +1673,35 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
|
||||
"""Tuya Sensor Entity."""
|
||||
|
||||
entity_description: TuyaSensorEntityDescription
|
||||
_dpcode_wrapper: DPCodeWrapper
|
||||
|
||||
_type: DPType | None = None
|
||||
_type_data: IntegerTypeData | EnumTypeData | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: TuyaSensorEntityDescription,
|
||||
dpcode_wrapper: DPCodeWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya sensor."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
self._attr_unique_id = (
|
||||
f"{super().unique_id}{description.key}{description.subkey or ''}"
|
||||
)
|
||||
|
||||
if description.native_unit_of_measurement is None:
|
||||
self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit
|
||||
if description.suggested_unit_of_measurement is None:
|
||||
self._attr_suggested_unit_of_measurement = dpcode_wrapper.suggested_unit
|
||||
if int_type := find_dpcode(self.device, description.key, dptype=DPType.INTEGER):
|
||||
self._type_data = int_type
|
||||
self._type = DPType.INTEGER
|
||||
if description.native_unit_of_measurement is None:
|
||||
self._attr_native_unit_of_measurement = int_type.unit
|
||||
elif enum_type := find_dpcode(
|
||||
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
self._type_data = enum_type
|
||||
self._type = DPType.ENUM
|
||||
else:
|
||||
self._type = get_dptype(self.device, DPCode(description.key))
|
||||
|
||||
self._validate_device_class_unit()
|
||||
|
||||
@@ -1836,4 +1752,55 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
# Only continue if data type is known
|
||||
if self._type not in (
|
||||
DPType.INTEGER,
|
||||
DPType.STRING,
|
||||
DPType.ENUM,
|
||||
DPType.JSON,
|
||||
DPType.RAW,
|
||||
):
|
||||
return None
|
||||
|
||||
# Raw value
|
||||
value = self.device.status.get(self.entity_description.key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Convert value, if required
|
||||
if (convert := self.entity_description.state_conversion) is not None:
|
||||
return convert(value)
|
||||
|
||||
# Scale integer/float value
|
||||
if isinstance(self._type_data, IntegerTypeData):
|
||||
return self._type_data.scale_value(value)
|
||||
|
||||
# Unexpected enum value
|
||||
if (
|
||||
isinstance(self._type_data, EnumTypeData)
|
||||
and value not in self._type_data.range
|
||||
):
|
||||
return None
|
||||
|
||||
# Get subkey value from Json string.
|
||||
if self._type is DPType.JSON:
|
||||
if (
|
||||
self.entity_description.complex_type is None
|
||||
or self.entity_description.subkey is None
|
||||
):
|
||||
return None
|
||||
values = self.entity_description.complex_type.from_json(value)
|
||||
return getattr(values, self.entity_description.subkey)
|
||||
|
||||
if self._type is DPType.RAW:
|
||||
if (
|
||||
self.entity_description.complex_type is None
|
||||
or self.entity_description.subkey is None
|
||||
or (raw_values := self.entity_description.complex_type.from_raw(value))
|
||||
is None
|
||||
):
|
||||
return None
|
||||
return getattr(raw_values, self.entity_description.subkey)
|
||||
|
||||
# Valid string or enum value
|
||||
return value
|
||||
|
||||
@@ -42,16 +42,6 @@ def get_dpcode(
|
||||
return None
|
||||
|
||||
|
||||
def parse_dptype(dptype: str) -> DPType | None:
|
||||
"""Parse DPType from device DPCode information."""
|
||||
try:
|
||||
return DPType(dptype)
|
||||
except ValueError:
|
||||
# Sometimes, we get ill-formed DPTypes from the cloud,
|
||||
# this fixes them and maps them to the correct DPType.
|
||||
return _DPTYPE_MAPPING.get(dptype)
|
||||
|
||||
|
||||
def get_dptype(
|
||||
device: CustomerDevice, dpcode: DPCode | None, *, prefer_function: bool = False
|
||||
) -> DPType | None:
|
||||
@@ -67,7 +57,13 @@ def get_dptype(
|
||||
|
||||
for device_specs in lookup_tuple:
|
||||
if current_definition := device_specs.get(dpcode):
|
||||
return parse_dptype(current_definition.type)
|
||||
current_type = current_definition.type
|
||||
try:
|
||||
return DPType(current_type)
|
||||
except ValueError:
|
||||
# Sometimes, we get ill-formed DPTypes from the cloud,
|
||||
# this fixes them and maps them to the correct DPType.
|
||||
return _DPTYPE_MAPPING.get(current_type)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -2,23 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
from uasiren.client import Client
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_REGION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import UkraineAlarmDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Ukraine Alarm as config entry."""
|
||||
@@ -40,56 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||
|
||||
if config_entry.version == 1:
|
||||
# Version 1 had states as first-class selections
|
||||
# Version 2 only allows states w/o districts, districts and communities
|
||||
region_id = config_entry.data[CONF_REGION]
|
||||
|
||||
websession = async_get_clientsession(hass)
|
||||
try:
|
||||
regions_data = await Client(websession).get_regions()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
_LOGGER.warning(
|
||||
"Could not migrate config entry %s: failed to fetch current regions: %s",
|
||||
config_entry.entry_id,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(regions_data, dict)
|
||||
|
||||
state_with_districts = None
|
||||
for state in regions_data["states"]:
|
||||
if state["regionId"] == region_id and state.get("regionChildIds"):
|
||||
state_with_districts = state
|
||||
break
|
||||
|
||||
if state_with_districts:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_state_region_{config_entry.entry_id}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_state_region",
|
||||
translation_placeholders={
|
||||
"region_name": config_entry.data.get(CONF_NAME, region_id),
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
hass.config_entries.async_update_entry(config_entry, version=2)
|
||||
_LOGGER.info("Migration to version %s successful", 2)
|
||||
return True
|
||||
|
||||
_LOGGER.error("Unknown version %s", config_entry.version)
|
||||
return False
|
||||
|
||||
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Ukraine Alarm."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new UkraineAlarmConfigFlow."""
|
||||
@@ -112,7 +112,7 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_finish_flow()
|
||||
|
||||
regions = {}
|
||||
if self.selected_region and step_id != "district":
|
||||
if self.selected_region:
|
||||
regions[self.selected_region["regionId"]] = self.selected_region[
|
||||
"regionName"
|
||||
]
|
||||
|
||||
@@ -13,19 +13,19 @@
|
||||
"data": {
|
||||
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
|
||||
},
|
||||
"description": "Choose the district you selected above or select a specific community within that district"
|
||||
"description": "If you want to monitor not only state and district, choose its specific community"
|
||||
},
|
||||
"district": {
|
||||
"data": {
|
||||
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
|
||||
},
|
||||
"description": "Choose a district to monitor within the selected state"
|
||||
"description": "If you want to monitor not only state, choose its specific district"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"region": "Region"
|
||||
},
|
||||
"description": "Choose a state"
|
||||
"description": "Choose state to monitor"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -50,11 +50,5 @@
|
||||
"name": "Urban fights"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_state_region": {
|
||||
"description": "The region `{region_name}` is a state-level region, which is no longer supported. Please remove this integration entry and add it again, selecting a district or community instead of the entire state.",
|
||||
"title": "State-level region monitoring is no longer supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"""Support for VELUX KLF 200 devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyvlx import PyVLX, PyVLXException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, LOGGER, PLATFORMS
|
||||
|
||||
type VeluxConfigEntry = ConfigEntry[PyVLX]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the velux component."""
|
||||
host = entry.data[CONF_HOST]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
@@ -30,39 +27,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
|
||||
|
||||
entry.runtime_data = pyvlx
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, f"gateway_{entry.entry_id}")},
|
||||
name="KLF 200 Gateway",
|
||||
manufacturer="Velux",
|
||||
model="KLF 200",
|
||||
hw_version=(
|
||||
str(pyvlx.klf200.version.hardwareversion) if pyvlx.klf200.version else None
|
||||
),
|
||||
sw_version=(
|
||||
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
|
||||
),
|
||||
)
|
||||
|
||||
async def on_hass_stop(event):
|
||||
"""Close connection when hass stops."""
|
||||
LOGGER.debug("Velux interface terminated")
|
||||
await pyvlx.disconnect()
|
||||
|
||||
async def async_reboot_gateway(service_call: ServiceCall) -> None:
|
||||
"""Reboot the gateway (deprecated - use button entity instead)."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_reboot_service",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_reboot_service",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
)
|
||||
|
||||
await pyvlx.reboot_gateway()
|
||||
|
||||
entry.async_on_unload(
|
||||
@@ -76,6 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -24,14 +24,14 @@ SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VeluxConfigEntry,
|
||||
config: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up rain sensor(s) for Velux platform."""
|
||||
pyvlx = config_entry.runtime_data
|
||||
pyvlx = config.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
VeluxRainSensor(node, config_entry.entry_id)
|
||||
VeluxRainSensor(node, config.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, Window) and node.rain_sensor
|
||||
)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Support for VELUX KLF 200 gateway button."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyvlx import PyVLX, PyVLXException
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VeluxConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up button entities for the Velux integration."""
|
||||
async_add_entities(
|
||||
[VeluxGatewayRebootButton(config_entry.entry_id, config_entry.runtime_data)]
|
||||
)
|
||||
|
||||
|
||||
class VeluxGatewayRebootButton(ButtonEntity):
|
||||
"""Representation of the Velux Gateway reboot button."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = ButtonDeviceClass.RESTART
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self, config_entry_id: str, pyvlx: PyVLX) -> None:
|
||||
"""Initialize the gateway reboot button."""
|
||||
self.pyvlx = pyvlx
|
||||
self._attr_unique_id = f"{config_entry_id}_reboot-gateway"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"gateway_{config_entry_id}")},
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press - reboot the gateway."""
|
||||
try:
|
||||
await self.pyvlx.reboot_gateway()
|
||||
except PyVLXException as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="reboot_failed",
|
||||
) from ex
|
||||
@@ -85,7 +85,7 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
updates={CONF_HOST: self.discovery_data[CONF_HOST]}
|
||||
)
|
||||
|
||||
# Abort if config_entry already exists without unique_id configured.
|
||||
# Abort if config_entry already exists without unigue_id configured.
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if (
|
||||
entry.data[CONF_HOST] == self.discovery_data[CONF_HOST]
|
||||
|
||||
@@ -5,11 +5,5 @@ from logging import getLogger
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "velux"
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SCENE,
|
||||
]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE]
|
||||
LOGGER = getLogger(__package__)
|
||||
|
||||
@@ -32,13 +32,13 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VeluxConfigEntry,
|
||||
config: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up cover(s) for Velux platform."""
|
||||
pyvlx = config_entry.runtime_data
|
||||
pyvlx = config.runtime_data
|
||||
async_add_entities(
|
||||
VeluxCover(node, config_entry.entry_id)
|
||||
VeluxCover(node, config.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, OpeningDevice)
|
||||
)
|
||||
|
||||
@@ -18,23 +18,22 @@ class VeluxEntity(Entity):
|
||||
def __init__(self, node: Node, config_entry_id: str) -> None:
|
||||
"""Initialize the Velux device."""
|
||||
self.node = node
|
||||
unique_id = (
|
||||
self._attr_unique_id = (
|
||||
node.serial_number
|
||||
if node.serial_number
|
||||
else f"{config_entry_id}_{node.node_id}"
|
||||
)
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
unique_id,
|
||||
node.serial_number
|
||||
if node.serial_number
|
||||
else f"{config_entry_id}_{node.node_id}",
|
||||
)
|
||||
},
|
||||
name=node.name if node.name else f"#{node.node_id}",
|
||||
serial_number=node.serial_number,
|
||||
via_device=(DOMAIN, f"gateway_{config_entry_id}"),
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -18,13 +18,13 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VeluxConfigEntry,
|
||||
config: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up light(s) for Velux platform."""
|
||||
pyvlx = config_entry.runtime_data
|
||||
pyvlx = config.runtime_data
|
||||
async_add_entities(
|
||||
VeluxLight(node, config_entry.entry_id)
|
||||
VeluxLight(node, config.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, LighteningDevice)
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: todo
|
||||
comment: needs to move to async_setup
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency:
|
||||
status: todo
|
||||
comment: release-builds need CI
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: todo
|
||||
comment: subscribe is ok, unsubscribe needs to be added
|
||||
entity-unique-id: done
|
||||
has-entity-name:
|
||||
status: todo
|
||||
comment: scenes need fixing
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: todo
|
||||
comment: needs rework, failure to setup currently only returns false
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: todo
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: todo
|
||||
comment: button still needs it
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: cleanup mock_config_entry vs mock_user_config_entry, cleanup mock_pyvlx vs mock_velux_client, remove unused freezer in test_cover_closed, add tests where missing
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: todo
|
||||
comment: scenes need devices
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -15,11 +15,11 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VeluxConfigEntry,
|
||||
config: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the scenes for Velux platform."""
|
||||
pyvlx = config_entry.runtime_data
|
||||
pyvlx = config.runtime_data
|
||||
|
||||
entities = [VeluxScene(scene) for scene in pyvlx.scenes]
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -36,20 +36,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"reboot_failed": {
|
||||
"message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_reboot_service": {
|
||||
"description": "The `velux.reboot_gateway` service is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.",
|
||||
"title": "Velux reboot service is deprecated"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reboot_gateway": {
|
||||
"description": "Reboots the KLF200 Gateway",
|
||||
"description": "Reboots the KLF200 Gateway.",
|
||||
"name": "Reboot gateway"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ from .utils import (
|
||||
get_compressors,
|
||||
get_device_serial,
|
||||
is_supported,
|
||||
normalize_state,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -1087,7 +1086,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
|
||||
ViCareSensorEntityDescription(
|
||||
key="compressor_phase",
|
||||
translation_key="compressor_phase",
|
||||
value_getter=lambda api: normalize_state(api.getPhase()),
|
||||
value_getter=lambda api: api.getPhase(),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -213,18 +213,7 @@
|
||||
"name": "Compressor hours load class 5"
|
||||
},
|
||||
"compressor_phase": {
|
||||
"name": "Compressor phase",
|
||||
"state": {
|
||||
"cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]",
|
||||
"defrost": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::defrosting%]",
|
||||
"heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"passive_defrost": "Passive defrosting",
|
||||
"pause": "[%key:common::state::idle%]",
|
||||
"preparing": "Preparing",
|
||||
"preparing_defrost": "Preparing defrost",
|
||||
"ready": "[%key:common::state::idle%]"
|
||||
}
|
||||
"name": "Compressor phase"
|
||||
},
|
||||
"compressor_starts": {
|
||||
"name": "Compressor starts"
|
||||
|
||||
@@ -133,8 +133,3 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
|
||||
def filter_state(state: str) -> str | None:
|
||||
"""Return the state if not 'nothing' or 'unknown'."""
|
||||
return None if state in ("nothing", "unknown") else state
|
||||
|
||||
|
||||
def normalize_state(state: str) -> str:
|
||||
"""Return the state with underscores instead of hyphens."""
|
||||
return state.replace("-", "_")
|
||||
|
||||
@@ -14,12 +14,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .const import API_URL, DOMAIN, LOGGER
|
||||
from .const import API_URL, LOGGER
|
||||
from .coordinator import (
|
||||
HeatPumpInfo,
|
||||
WeheatConfigEntry,
|
||||
@@ -33,13 +32,7 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool:
|
||||
"""Set up Weheat from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
|
||||
@@ -124,10 +124,5 @@
|
||||
"name": "Water outlet temperature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ def check_deprecated_entity(
|
||||
return False
|
||||
|
||||
|
||||
def profile_pic(person: Person, _: Title | None = None) -> str | None:
|
||||
def profile_pic(person: Person, _: Title | None) -> str | None:
|
||||
"""Return the gamer pic."""
|
||||
|
||||
# Xbox sometimes returns a domain that uses a wrong certificate which
|
||||
|
||||
@@ -29,19 +29,6 @@
|
||||
"gamer_score": {
|
||||
"default": "mdi:alpha-g-circle"
|
||||
},
|
||||
"in_party": {
|
||||
"default": "mdi:headset",
|
||||
"state": {
|
||||
"0": "mdi:headset-off"
|
||||
}
|
||||
},
|
||||
"join_restrictions": {
|
||||
"default": "mdi:account-voice-off",
|
||||
"state": {
|
||||
"invite_only": "mdi:email-newsletter",
|
||||
"joinable": "mdi:account-multiple-plus-outline"
|
||||
}
|
||||
},
|
||||
"last_online": {
|
||||
"default": "mdi:account-clock"
|
||||
},
|
||||
|
||||
@@ -2,85 +2,67 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
|
||||
from httpx import HTTPStatusError, RequestError, TimeoutException
|
||||
from pythonxbox.api.provider.titlehub.models import Image, Title, TitleFields
|
||||
from pydantic import ValidationError
|
||||
from pythonxbox.api.client import XboxLiveClient
|
||||
from pythonxbox.api.provider.catalog.models import FieldsTemplate, Image
|
||||
from pythonxbox.api.provider.gameclips.models import GameclipsResponse
|
||||
from pythonxbox.api.provider.screenshots.models import ScreenshotResponse
|
||||
from pythonxbox.api.provider.smartglass.models import InstalledPackage
|
||||
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass
|
||||
from homeassistant.components.media_player import MediaClass
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
PlayMedia,
|
||||
Unresolvable,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .binary_sensor import profile_pic
|
||||
from .browse_media import _find_media_image
|
||||
from .const import DOMAIN
|
||||
from .coordinator import XboxConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_GAMECLIPS = "gameclips"
|
||||
ATTR_SCREENSHOTS = "screenshots"
|
||||
ATTR_GAME_MEDIA = "game_media"
|
||||
ATTR_COMMUNITY_GAMECLIPS = "community_gameclips"
|
||||
ATTR_COMMUNITY_SCREENSHOTS = "community_screenshots"
|
||||
|
||||
MAP_TITLE = {
|
||||
ATTR_GAMECLIPS: "Gameclips",
|
||||
ATTR_SCREENSHOTS: "Screenshots",
|
||||
ATTR_GAME_MEDIA: "Game media",
|
||||
ATTR_COMMUNITY_GAMECLIPS: "Community gameclips",
|
||||
ATTR_COMMUNITY_SCREENSHOTS: "Community screenshots",
|
||||
}
|
||||
|
||||
MIME_TYPE_MAP = {
|
||||
ATTR_GAMECLIPS: "video/mp4",
|
||||
ATTR_COMMUNITY_GAMECLIPS: "video/mp4",
|
||||
ATTR_SCREENSHOTS: "image/png",
|
||||
ATTR_COMMUNITY_SCREENSHOTS: "image/png",
|
||||
"gameclips": "video/mp4",
|
||||
"screenshots": "image/png",
|
||||
}
|
||||
|
||||
MEDIA_CLASS_MAP = {
|
||||
ATTR_GAMECLIPS: MediaClass.VIDEO,
|
||||
ATTR_COMMUNITY_GAMECLIPS: MediaClass.VIDEO,
|
||||
ATTR_SCREENSHOTS: MediaClass.IMAGE,
|
||||
ATTR_COMMUNITY_SCREENSHOTS: MediaClass.IMAGE,
|
||||
ATTR_GAME_MEDIA: MediaClass.IMAGE,
|
||||
"gameclips": MediaClass.VIDEO,
|
||||
"screenshots": MediaClass.IMAGE,
|
||||
}
|
||||
|
||||
SEPARATOR = "/"
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> XboxSource:
|
||||
async def async_get_media_source(hass: HomeAssistant):
|
||||
"""Set up Xbox media source."""
|
||||
|
||||
return XboxSource(hass)
|
||||
entry: XboxConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
client = entry.runtime_data.client
|
||||
return XboxSource(hass, client)
|
||||
|
||||
|
||||
class XboxMediaSourceIdentifier:
|
||||
"""Media item identifier."""
|
||||
@callback
|
||||
def async_parse_identifier(
|
||||
item: MediaSourceItem,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Parse identifier."""
|
||||
identifier = item.identifier or ""
|
||||
start = ["", "", ""]
|
||||
items = identifier.lstrip("/").split("~~", 2)
|
||||
return tuple(items + start[len(items) :]) # type: ignore[return-value]
|
||||
|
||||
xuid = title_id = media_type = media_id = ""
|
||||
|
||||
def __init__(self, item: MediaSourceItem) -> None:
|
||||
"""Initialize identifier."""
|
||||
if item.identifier is not None:
|
||||
self.xuid, _, self.title_id = (item.identifier).partition(SEPARATOR)
|
||||
self.title_id, _, self.media_type = (self.title_id).partition(SEPARATOR)
|
||||
self.media_type, _, self.media_id = (self.media_type).partition(SEPARATOR)
|
||||
@dataclass
|
||||
class XboxMediaItem:
|
||||
"""Represents gameclip/screenshot media."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Build identifier."""
|
||||
|
||||
return SEPARATOR.join(
|
||||
[i for i in (self.xuid, self.title_id, self.media_type, self.media_id) if i]
|
||||
)
|
||||
caption: str
|
||||
thumbnail: str
|
||||
uri: str
|
||||
media_class: str
|
||||
|
||||
|
||||
class XboxSource(MediaSource):
|
||||
@@ -88,573 +70,202 @@ class XboxSource(MediaSource):
|
||||
|
||||
name: str = "Xbox Game Media"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
def __init__(self, hass: HomeAssistant, client: XboxLiveClient) -> None:
|
||||
"""Initialize Xbox source."""
|
||||
super().__init__(DOMAIN)
|
||||
self.hass = hass
|
||||
|
||||
self.hass: HomeAssistant = hass
|
||||
self.client: XboxLiveClient = client
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve media to a url."""
|
||||
identifier = XboxMediaSourceIdentifier(item)
|
||||
|
||||
if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)):
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="xbox_not_configured",
|
||||
)
|
||||
try:
|
||||
entry: XboxConfigEntry = next(
|
||||
e for e in entries if e.unique_id == identifier.xuid
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_configured",
|
||||
) from e
|
||||
|
||||
client = entry.runtime_data.client
|
||||
|
||||
if identifier.media_type in (ATTR_GAMECLIPS, ATTR_COMMUNITY_GAMECLIPS):
|
||||
try:
|
||||
if identifier.media_type == ATTR_GAMECLIPS:
|
||||
gameclips_response = (
|
||||
await client.gameclips.get_recent_clips_by_xuid(
|
||||
identifier.xuid, identifier.title_id, max_items=999
|
||||
)
|
||||
)
|
||||
else:
|
||||
gameclips_response = (
|
||||
await client.gameclips.get_recent_community_clips_by_title_id(
|
||||
identifier.title_id
|
||||
)
|
||||
)
|
||||
except TimeoutException as e:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
gameclips = gameclips_response.game_clips
|
||||
try:
|
||||
clip = next(
|
||||
g for g in gameclips if g.game_clip_id == identifier.media_id
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="media_not_found",
|
||||
) from e
|
||||
return PlayMedia(clip.game_clip_uris[0].uri, MIME_TYPE_MAP[ATTR_GAMECLIPS])
|
||||
|
||||
if identifier.media_type in (ATTR_SCREENSHOTS, ATTR_COMMUNITY_SCREENSHOTS):
|
||||
try:
|
||||
if identifier.media_type == ATTR_SCREENSHOTS:
|
||||
screenshot_response = (
|
||||
await client.screenshots.get_recent_screenshots_by_xuid(
|
||||
identifier.xuid, identifier.title_id, max_items=999
|
||||
)
|
||||
)
|
||||
else:
|
||||
screenshot_response = await client.screenshots.get_recent_community_screenshots_by_title_id(
|
||||
identifier.title_id
|
||||
)
|
||||
except TimeoutException as e:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
screenshots = screenshot_response.screenshots
|
||||
try:
|
||||
img = next(
|
||||
s for s in screenshots if s.screenshot_id == identifier.media_id
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="media_not_found",
|
||||
) from e
|
||||
return PlayMedia(
|
||||
img.screenshot_uris[0].uri, MIME_TYPE_MAP[identifier.media_type]
|
||||
)
|
||||
if identifier.media_type == ATTR_GAME_MEDIA:
|
||||
try:
|
||||
images = (
|
||||
(await client.titlehub.get_title_info(identifier.title_id))
|
||||
.titles[0]
|
||||
.images
|
||||
)
|
||||
except TimeoutException as e:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
if images is not None:
|
||||
try:
|
||||
return PlayMedia(
|
||||
images[int(identifier.media_id)].url,
|
||||
MIME_TYPE_MAP[ATTR_SCREENSHOTS],
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="media_not_found",
|
||||
)
|
||||
_, category, url = async_parse_identifier(item)
|
||||
kind = category.split("#", 1)[1]
|
||||
return PlayMedia(url, MIME_TYPE_MAP[kind])
|
||||
|
||||
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
|
||||
"""Return media."""
|
||||
if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)):
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="xbox_not_configured",
|
||||
)
|
||||
title, category, _ = async_parse_identifier(item)
|
||||
|
||||
# if there is only one entry we can directly jump to it
|
||||
if not item.identifier and len(entries) > 1:
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaClass.IMAGE,
|
||||
title="Xbox Game Media",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[*await self._build_accounts(entries)],
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
)
|
||||
if not title:
|
||||
return await self._build_game_library()
|
||||
|
||||
identifier = XboxMediaSourceIdentifier(item)
|
||||
if not identifier.xuid and len(entries) == 1:
|
||||
if TYPE_CHECKING:
|
||||
assert entries[0].unique_id
|
||||
identifier.xuid = entries[0].unique_id
|
||||
if not category:
|
||||
return _build_categories(title)
|
||||
|
||||
try:
|
||||
entry: XboxConfigEntry = next(
|
||||
e for e in entries if e.unique_id == identifier.xuid
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_configured",
|
||||
) from e
|
||||
return await self._build_media_items(title, category)
|
||||
|
||||
if not identifier.title_id:
|
||||
return await self._build_game_library(entry)
|
||||
async def _build_game_library(self):
|
||||
"""Display installed games across all consoles."""
|
||||
apps = await self.client.smartglass.get_installed_apps()
|
||||
games = {
|
||||
game.one_store_product_id: game
|
||||
for game in apps.result
|
||||
if game.is_game and game.title_id
|
||||
}
|
||||
|
||||
if not identifier.media_type:
|
||||
return await self._build_game_title(entry, identifier)
|
||||
app_details = await self.client.catalog.get_products(
|
||||
games.keys(),
|
||||
FieldsTemplate.BROWSE,
|
||||
)
|
||||
|
||||
return await self._build_game_media(entry, identifier)
|
||||
|
||||
async def _build_accounts(
|
||||
self, entries: list[XboxConfigEntry]
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""List Xbox accounts."""
|
||||
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=entry.unique_id,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaClass.DIRECTORY,
|
||||
title=entry.title,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail=gamerpic(entry),
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
async def _build_game_library(self, entry: XboxConfigEntry) -> BrowseMediaSource:
|
||||
"""Display played games."""
|
||||
images = {
|
||||
prod.product_id: prod.localized_properties[0].images
|
||||
for prod in app_details.products
|
||||
}
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=entry.unique_id,
|
||||
identifier="",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaClass.DIRECTORY,
|
||||
title=f"Xbox / {entry.title}",
|
||||
media_content_type="",
|
||||
title="Xbox Game Media",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[*await self._build_games(entry)],
|
||||
children=[_build_game_item(game, images) for game in games.values()],
|
||||
children_media_class=MediaClass.GAME,
|
||||
)
|
||||
|
||||
async def _build_games(self, entry: XboxConfigEntry) -> list[BrowseMediaSource]:
|
||||
"""List Xbox games for the selected account."""
|
||||
async def _build_media_items(self, title, category):
|
||||
"""Fetch requested gameclip/screenshot media."""
|
||||
title_id, _, thumbnail = title.split("#", 2)
|
||||
owner, kind = category.split("#", 1)
|
||||
|
||||
client = entry.runtime_data.client
|
||||
if TYPE_CHECKING:
|
||||
assert entry.unique_id
|
||||
fields = [
|
||||
TitleFields.ACHIEVEMENT,
|
||||
TitleFields.STATS,
|
||||
TitleFields.IMAGE,
|
||||
]
|
||||
try:
|
||||
games = await client.titlehub.get_title_history(
|
||||
entry.unique_id, fields, max_items=999
|
||||
)
|
||||
except TimeoutException as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{entry.unique_id}/{game.title_id}",
|
||||
media_class=MediaClass.GAME,
|
||||
media_content_type=MediaClass.GAME,
|
||||
title=game.name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
thumbnail=game_thumbnail(game.images or []),
|
||||
)
|
||||
for game in games.titles
|
||||
if game.achievement and game.achievement.source_version != 0
|
||||
]
|
||||
|
||||
async def _build_game_title(
|
||||
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
|
||||
) -> BrowseMediaSource:
|
||||
"""Display game title."""
|
||||
client = entry.runtime_data.client
|
||||
try:
|
||||
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
|
||||
except TimeoutException as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
items: list[XboxMediaItem] = []
|
||||
with suppress(ValidationError): # Unexpected API response
|
||||
if kind == "gameclips":
|
||||
if owner == "my":
|
||||
response: GameclipsResponse = (
|
||||
await self.client.gameclips.get_recent_clips_by_xuid(
|
||||
self.client.xuid, title_id
|
||||
)
|
||||
)
|
||||
elif owner == "community":
|
||||
response: GameclipsResponse = await self.client.gameclips.get_recent_community_clips_by_title_id(
|
||||
title_id
|
||||
)
|
||||
else:
|
||||
return None
|
||||
items = [
|
||||
XboxMediaItem(
|
||||
item.user_caption
|
||||
or dt_util.as_local(item.date_recorded).strftime(
|
||||
"%b. %d, %Y %I:%M %p"
|
||||
),
|
||||
item.thumbnails[0].uri,
|
||||
item.game_clip_uris[0].uri,
|
||||
MediaClass.VIDEO,
|
||||
)
|
||||
for item in response.game_clips
|
||||
]
|
||||
elif kind == "screenshots":
|
||||
if owner == "my":
|
||||
response: ScreenshotResponse = (
|
||||
await self.client.screenshots.get_recent_screenshots_by_xuid(
|
||||
self.client.xuid, title_id
|
||||
)
|
||||
)
|
||||
elif owner == "community":
|
||||
response: ScreenshotResponse = await self.client.screenshots.get_recent_community_screenshots_by_title_id(
|
||||
title_id
|
||||
)
|
||||
else:
|
||||
return None
|
||||
items = [
|
||||
XboxMediaItem(
|
||||
item.user_caption
|
||||
or dt_util.as_local(item.date_taken).strftime(
|
||||
"%b. %d, %Y %I:%M%p"
|
||||
),
|
||||
item.thumbnails[0].uri,
|
||||
item.screenshot_uris[0].uri,
|
||||
MediaClass.IMAGE,
|
||||
)
|
||||
for item in response.screenshots
|
||||
]
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=str(identifier),
|
||||
media_class=MediaClass.GAME,
|
||||
media_content_type=MediaClass.GAME,
|
||||
title=f"Xbox / {entry.title} / {game.name}",
|
||||
identifier=f"{title}~~{category}",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type="",
|
||||
title=f"{owner.title()} {kind.title()}",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[*self._build_categories(identifier)],
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
children=[_build_media_item(title, category, item) for item in items],
|
||||
children_media_class=MEDIA_CLASS_MAP[kind],
|
||||
thumbnail=thumbnail,
|
||||
)
|
||||
|
||||
def _build_categories(
|
||||
self, identifier: XboxMediaSourceIdentifier
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""List media categories."""
|
||||
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{identifier}/{media_type}",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaClass.DIRECTORY,
|
||||
title=MAP_TITLE[media_type],
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MEDIA_CLASS_MAP[media_type],
|
||||
)
|
||||
for media_type in (
|
||||
ATTR_GAMECLIPS,
|
||||
ATTR_SCREENSHOTS,
|
||||
ATTR_COMMUNITY_GAMECLIPS,
|
||||
ATTR_COMMUNITY_SCREENSHOTS,
|
||||
ATTR_GAME_MEDIA,
|
||||
)
|
||||
]
|
||||
def _build_game_item(item: InstalledPackage, images: dict[str, list[Image]]):
|
||||
"""Build individual game."""
|
||||
thumbnail = ""
|
||||
image = _find_media_image(images.get(item.one_store_product_id, [])) # type: ignore[arg-type]
|
||||
if image is not None:
|
||||
thumbnail = image.uri
|
||||
if thumbnail[0] == "/":
|
||||
thumbnail = f"https:{thumbnail}"
|
||||
|
||||
async def _build_game_media(
|
||||
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
|
||||
) -> BrowseMediaSource:
|
||||
"""List game media."""
|
||||
client = entry.runtime_data.client
|
||||
try:
|
||||
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
|
||||
except TimeoutException as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{item.title_id}#{item.name}#{thumbnail}",
|
||||
media_class=MediaClass.GAME,
|
||||
media_content_type="",
|
||||
title=item.name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
thumbnail=thumbnail,
|
||||
)
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=str(identifier),
|
||||
media_class=MEDIA_CLASS_MAP[identifier.media_type],
|
||||
media_content_type=MediaClass.DIRECTORY,
|
||||
title=f"Xbox / {entry.title} / {game.name} / {MAP_TITLE[identifier.media_type]}",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[
|
||||
*await self._build_media_items_gameclips(entry, identifier)
|
||||
+ await self._build_media_items_community_gameclips(entry, identifier)
|
||||
+ await self._build_media_items_screenshots(entry, identifier)
|
||||
+ await self._build_media_items_community_screenshots(entry, identifier)
|
||||
+ self._build_media_items_promotional(identifier, game)
|
||||
],
|
||||
children_media_class=MEDIA_CLASS_MAP[identifier.media_type],
|
||||
)
|
||||
|
||||
async def _build_media_items_gameclips(
|
||||
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""List media items."""
|
||||
client = entry.runtime_data.client
|
||||
def _build_categories(title):
|
||||
"""Build base categories for Xbox media."""
|
||||
_, name, thumbnail = title.split("#", 2)
|
||||
base = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{title}",
|
||||
media_class=MediaClass.GAME,
|
||||
media_content_type="",
|
||||
title=name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[],
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
thumbnail=thumbnail,
|
||||
)
|
||||
|
||||
if identifier.media_type != ATTR_GAMECLIPS:
|
||||
return []
|
||||
try:
|
||||
gameclips = (
|
||||
await client.gameclips.get_recent_clips_by_xuid(
|
||||
identifier.xuid, identifier.title_id, max_items=999
|
||||
)
|
||||
).game_clips
|
||||
except TimeoutException as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{identifier}/{gameclip.game_clip_id}",
|
||||
media_class=MediaClass.VIDEO,
|
||||
media_content_type=MediaClass.VIDEO,
|
||||
title=(
|
||||
f"{gameclip.user_caption}"
|
||||
f"{' | ' if gameclip.user_caption else ''}"
|
||||
f"{dt_util.get_age(gameclip.date_recorded)}"
|
||||
),
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=gameclip.thumbnails[0].uri,
|
||||
)
|
||||
for gameclip in gameclips
|
||||
]
|
||||
|
||||
async def _build_media_items_community_gameclips(
|
||||
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""List media items."""
|
||||
client = entry.runtime_data.client
|
||||
|
||||
if identifier.media_type != ATTR_COMMUNITY_GAMECLIPS:
|
||||
return []
|
||||
try:
|
||||
gameclips = (
|
||||
await client.gameclips.get_recent_community_clips_by_title_id(
|
||||
identifier.title_id
|
||||
)
|
||||
).game_clips
|
||||
except TimeoutException as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{identifier}/{gameclip.game_clip_id}",
|
||||
media_class=MediaClass.VIDEO,
|
||||
media_content_type=MediaClass.VIDEO,
|
||||
title=(
|
||||
f"{gameclip.user_caption}"
|
||||
f"{' | ' if gameclip.user_caption else ''}"
|
||||
f"{dt_util.get_age(gameclip.date_recorded)}"
|
||||
),
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=gameclip.thumbnails[0].uri,
|
||||
)
|
||||
for gameclip in gameclips
|
||||
]
|
||||
|
||||
async def _build_media_items_screenshots(
|
||||
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""List media items."""
|
||||
client = entry.runtime_data.client
|
||||
|
||||
if identifier.media_type != ATTR_SCREENSHOTS:
|
||||
return []
|
||||
try:
|
||||
screenshots = (
|
||||
await client.screenshots.get_recent_screenshots_by_xuid(
|
||||
identifier.xuid, identifier.title_id, max_items=999
|
||||
)
|
||||
).screenshots
|
||||
except TimeoutException as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{identifier}/{screenshot.screenshot_id}",
|
||||
media_class=MediaClass.VIDEO,
|
||||
media_content_type=MediaClass.VIDEO,
|
||||
title=(
|
||||
f"{screenshot.user_caption}"
|
||||
f"{' | ' if screenshot.user_caption else ''}"
|
||||
f"{dt_util.get_age(screenshot.date_taken)} | {screenshot.resolution_height}p"
|
||||
),
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=screenshot.thumbnails[0].uri,
|
||||
)
|
||||
for screenshot in screenshots
|
||||
]
|
||||
|
||||
async def _build_media_items_community_screenshots(
|
||||
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""List media items."""
|
||||
client = entry.runtime_data.client
|
||||
|
||||
if identifier.media_type != ATTR_COMMUNITY_SCREENSHOTS:
|
||||
return []
|
||||
try:
|
||||
screenshots = (
|
||||
await client.screenshots.get_recent_community_screenshots_by_title_id(
|
||||
identifier.title_id
|
||||
)
|
||||
).screenshots
|
||||
except TimeoutException as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_exception",
|
||||
) from e
|
||||
except (RequestError, HTTPStatusError) as e:
|
||||
_LOGGER.debug("Xbox exception:", exc_info=True)
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_exception",
|
||||
) from e
|
||||
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{identifier}/{screenshot.screenshot_id}",
|
||||
media_class=MediaClass.VIDEO,
|
||||
media_content_type=MediaClass.VIDEO,
|
||||
title=(
|
||||
f"{screenshot.user_caption}"
|
||||
f"{' | ' if screenshot.user_caption else ''}"
|
||||
f"{dt_util.get_age(screenshot.date_taken)} | {screenshot.resolution_height}p"
|
||||
),
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=screenshot.thumbnails[0].uri,
|
||||
)
|
||||
for screenshot in screenshots
|
||||
]
|
||||
|
||||
def _build_media_items_promotional(
|
||||
self, identifier: XboxMediaSourceIdentifier, game: Title
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""List promotional game media."""
|
||||
|
||||
if identifier.media_type != ATTR_GAME_MEDIA:
|
||||
return []
|
||||
|
||||
return (
|
||||
[
|
||||
owners = ["my", "community"]
|
||||
kinds = ["gameclips", "screenshots"]
|
||||
for owner in owners:
|
||||
for kind in kinds:
|
||||
base.children.append(
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{identifier}/{game.images.index(image)}",
|
||||
media_class=MediaClass.VIDEO,
|
||||
media_content_type=MediaClass.VIDEO,
|
||||
title=image.type,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=image.url,
|
||||
identifier=f"{title}~~{owner}#{kind}",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type="",
|
||||
title=f"{owner.title()} {kind.title()}",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MEDIA_CLASS_MAP[kind],
|
||||
)
|
||||
for image in game.images
|
||||
]
|
||||
if game.images
|
||||
else []
|
||||
)
|
||||
)
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def gamerpic(config_entry: XboxConfigEntry) -> str | None:
|
||||
"""Return gamerpic."""
|
||||
coordinator = config_entry.runtime_data
|
||||
if TYPE_CHECKING:
|
||||
assert config_entry.unique_id
|
||||
person = coordinator.data.presence[coordinator.client.xuid]
|
||||
return profile_pic(person)
|
||||
|
||||
|
||||
def game_thumbnail(images: list[Image]) -> str | None:
|
||||
"""Return the title image."""
|
||||
|
||||
for img_type in ("BrandedKeyArt", "Poster", "BoxArt"):
|
||||
if match := next(
|
||||
(i for i in images if i.type == img_type),
|
||||
None,
|
||||
):
|
||||
return match.url
|
||||
|
||||
return None
|
||||
def _build_media_item(title: str, category: str, item: XboxMediaItem):
|
||||
"""Build individual media item."""
|
||||
kind = category.split("#", 1)[1]
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{title}~~{category}~~{item.uri}",
|
||||
media_class=item.media_class,
|
||||
media_content_type=MIME_TYPE_MAP[kind],
|
||||
title=item.caption,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=item.thumbnail,
|
||||
)
|
||||
|
||||
@@ -24,11 +24,6 @@ from homeassistant.helpers.typing import StateType
|
||||
from .coordinator import XboxConfigEntry
|
||||
from .entity import XboxBaseEntity, XboxBaseEntityDescription, check_deprecated_entity
|
||||
|
||||
MAP_JOIN_RESTRICTIONS = {
|
||||
"local": "invite_only",
|
||||
"followed": "joinable",
|
||||
}
|
||||
|
||||
|
||||
class XboxSensor(StrEnum):
|
||||
"""Xbox sensor."""
|
||||
@@ -42,8 +37,6 @@ class XboxSensor(StrEnum):
|
||||
FOLLOWER = "follower"
|
||||
NOW_PLAYING = "now_playing"
|
||||
FRIENDS = "friends"
|
||||
IN_PARTY = "in_party"
|
||||
JOIN_RESTRICTIONS = "join_restrictions"
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -102,18 +95,6 @@ def now_playing_attributes(_: Person, title: Title | None) -> dict[str, Any]:
|
||||
return attributes
|
||||
|
||||
|
||||
def join_restrictions(person: Person, _: Title | None = None) -> str | None:
|
||||
"""Join restrictions for current party the user is in."""
|
||||
|
||||
return (
|
||||
MAP_JOIN_RESTRICTIONS.get(
|
||||
person.multiplayer_summary.party_details[0].join_restriction
|
||||
)
|
||||
if person.multiplayer_summary and person.multiplayer_summary.party_details
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def title_logo(_: Person, title: Title | None) -> str | None:
|
||||
"""Get the game logo."""
|
||||
|
||||
@@ -178,22 +159,6 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
|
||||
translation_key=XboxSensor.FRIENDS,
|
||||
value_fn=lambda x, _: x.detail.friend_count if x.detail else None,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.IN_PARTY,
|
||||
translation_key=XboxSensor.IN_PARTY,
|
||||
value_fn=(
|
||||
lambda x, _: x.multiplayer_summary.in_party
|
||||
if x.multiplayer_summary
|
||||
else None
|
||||
),
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.JOIN_RESTRICTIONS,
|
||||
translation_key=XboxSensor.JOIN_RESTRICTIONS,
|
||||
value_fn=join_restrictions,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(MAP_JOIN_RESTRICTIONS.values()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -73,17 +73,6 @@
|
||||
"name": "Gamerscore",
|
||||
"unit_of_measurement": "points"
|
||||
},
|
||||
"in_party": {
|
||||
"name": "In party",
|
||||
"unit_of_measurement": "[%key:component::xbox::entity::sensor::following::unit_of_measurement%]"
|
||||
},
|
||||
"join_restrictions": {
|
||||
"name": "Party join restrictions",
|
||||
"state": {
|
||||
"invite_only": "Invite-only",
|
||||
"joinable": "Joinable"
|
||||
}
|
||||
},
|
||||
"last_online": {
|
||||
"name": "Last online"
|
||||
},
|
||||
@@ -109,12 +98,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"account_not_configured": {
|
||||
"message": "The Xbox account is not configured."
|
||||
},
|
||||
"media_not_found": {
|
||||
"message": "The requested media could not be found."
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
@@ -123,9 +106,6 @@
|
||||
},
|
||||
"timeout_exception": {
|
||||
"message": "Failed to connect to Xbox Network due to a connection timeout"
|
||||
},
|
||||
"xbox_not_configured": {
|
||||
"message": "The Xbox integration is not configured."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,7 +393,9 @@ async def async_setup_entry(
|
||||
and is_valid_notification_binary_sensor(info)
|
||||
):
|
||||
entities.extend(
|
||||
ZWaveNotificationBinarySensor(config_entry, driver, info, state_key)
|
||||
ZWaveNotificationBinarySensor(
|
||||
config_entry, driver, info, state_key, info.entity_description
|
||||
)
|
||||
for state_key in info.primary_value.metadata.states
|
||||
if int(state_key) not in info.entity_description.not_states
|
||||
and (
|
||||
|
||||
@@ -11,7 +11,6 @@ from jinja2.nodes import Node
|
||||
from jinja2.parser import Parser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.template import TemplateEnvironment
|
||||
|
||||
|
||||
@@ -27,7 +26,6 @@ class TemplateFunction:
|
||||
limited_ok: bool = (
|
||||
True # Whether this function is available in limited environments
|
||||
)
|
||||
requires_hass: bool = False # Whether this function requires hass to be available
|
||||
|
||||
|
||||
class BaseTemplateExtension(Extension):
|
||||
@@ -46,10 +44,6 @@ class BaseTemplateExtension(Extension):
|
||||
|
||||
if functions:
|
||||
for template_func in functions:
|
||||
# Skip functions that require hass when hass is not available
|
||||
if template_func.requires_hass and self.environment.hass is None:
|
||||
continue
|
||||
|
||||
# Skip functions not allowed in limited environments
|
||||
if self.environment.limited and not template_func.limited_ok:
|
||||
continue
|
||||
@@ -61,24 +55,6 @@ class BaseTemplateExtension(Extension):
|
||||
if template_func.as_test:
|
||||
environment.tests[template_func.name] = template_func.func
|
||||
|
||||
@property
|
||||
def hass(self) -> HomeAssistant:
|
||||
"""Return the Home Assistant instance.
|
||||
|
||||
This property should only be used in extensions that have functions
|
||||
marked with requires_hass=True, as it assumes hass is not None.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If hass is not available in the environment.
|
||||
"""
|
||||
if self.environment.hass is None:
|
||||
raise RuntimeError(
|
||||
"Home Assistant instance is not available. "
|
||||
"This property should only be used in extensions with "
|
||||
"functions marked requires_hass=True."
|
||||
)
|
||||
return self.environment.hass
|
||||
|
||||
def parse(self, parser: Parser) -> Node | list[Node]:
|
||||
"""Required by Jinja2 Extension base class."""
|
||||
return []
|
||||
|
||||
2
requirements_all.txt
generated
2
requirements_all.txt
generated
@@ -2050,7 +2050,7 @@ pyhaversion==22.8.0
|
||||
pyheos==1.0.6
|
||||
|
||||
# homeassistant.components.hive
|
||||
pyhive-integration==1.0.7
|
||||
pyhive-integration==1.0.6
|
||||
|
||||
# homeassistant.components.homematic
|
||||
pyhomematic==0.1.77
|
||||
|
||||
@@ -32,7 +32,7 @@ pytest-timeout==2.4.0
|
||||
pytest-unordered==0.7.0
|
||||
pytest-picked==0.5.1
|
||||
pytest-xdist==3.8.0
|
||||
pytest==9.0.0
|
||||
pytest==8.4.2
|
||||
requests-mock==1.12.1
|
||||
respx==0.22.0
|
||||
syrupy==5.0.0
|
||||
|
||||
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@@ -1709,7 +1709,7 @@ pyhaversion==22.8.0
|
||||
pyheos==1.0.6
|
||||
|
||||
# homeassistant.components.hive
|
||||
pyhive-integration==1.0.7
|
||||
pyhive-integration==1.0.6
|
||||
|
||||
# homeassistant.components.homematic
|
||||
pyhomematic==0.1.77
|
||||
|
||||
@@ -1034,6 +1034,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"v2c",
|
||||
"vallox",
|
||||
"vasttrafik",
|
||||
"velux",
|
||||
"venstar",
|
||||
"vera",
|
||||
"verisure",
|
||||
|
||||
@@ -6,11 +6,9 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from . import api
|
||||
@@ -28,13 +26,17 @@ type New_NameConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth]
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool:
|
||||
"""Set up NEW_NAME from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
"OAuth2 implementation temporarily unavailable, will retry"
|
||||
) from err
|
||||
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
# If using a requests-based API lib
|
||||
entry.runtime_data = api.ConfigEntryAuth(hass, session)
|
||||
|
||||
@@ -1,600 +0,0 @@
|
||||
"""Test alarm control panel trigger."""
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.alarm_control_panel.const import AlarmControlPanelState
|
||||
from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
async def test_alarm_armed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm armed trigger fires when an alarm is armed."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.armed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Trigger armed home
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Trigger armed away
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_AWAY)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Trigger armed night
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_NIGHT)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Trigger armed vacation
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_VACATION)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Trigger armed custom bypass
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_CUSTOM_BYPASS)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_armed_trigger_with_mode_filter(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm armed trigger with mode filter."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.armed",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
"options": {
|
||||
"mode": [
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
],
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Trigger matching armed home mode
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Trigger matching armed away mode
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_AWAY)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Trigger non-matching armed night mode - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_NIGHT)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Trigger non-matching armed vacation mode - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_VACATION)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_alarm_armed_trigger_ignores_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm armed trigger ignores unavailable states."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.armed",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to unavailable - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Trigger armed after unavailable - should trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_armed_trigger_ignores_non_armed_states(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm armed trigger ignores non-armed states."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.armed",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to disarmed - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to triggered - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to arming - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to pending - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.PENDING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to disarming - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_alarm_armed_trigger_from_unknown_state(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the trigger fires when entity goes from unknown/None to first armed state."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Do NOT set any initial state - entity starts with None state
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.armed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# First armed state should trigger even though entity had no previous state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_disarmed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm disarmed trigger fires when an alarm is disarmed."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.disarmed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Trigger disarmed
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_disarmed_trigger_ignores_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm disarmed trigger ignores unavailable states."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.disarmed",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to unavailable - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Trigger disarmed after unavailable - should trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_disarmed_trigger_ignores_non_disarmed_states(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm disarmed trigger ignores non-disarmed states."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.disarmed",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to armed home - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to triggered - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to arming - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_alarm_disarmed_trigger_from_unknown_state(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the trigger fires when entity goes from unknown/None to disarmed state."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Do NOT set any initial state - entity starts with None state
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.disarmed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# First disarmed state should trigger even though entity had no previous state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_triggered_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm triggered trigger fires when an alarm is triggered."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.triggered",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Trigger alarm triggered
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_triggered_trigger_ignores_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm triggered trigger ignores unavailable states."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.triggered",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to unavailable - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Trigger alarm triggered after unavailable - should trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_alarm_triggered_trigger_ignores_non_triggered_states(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the alarm triggered trigger ignores non-triggered states."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Set initial state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.triggered",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to armed home - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to disarmed - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Set to arming - should not trigger
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.ARMING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_alarm_triggered_trigger_from_unknown_state(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the trigger fires when entity goes from unknown/None to triggered state."""
|
||||
entity_id = "alarm_control_panel.test_alarm"
|
||||
await async_setup_component(hass, "alarm_control_panel", {})
|
||||
|
||||
# Do NOT set any initial state - entity starts with None state
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "alarm_control_panel.triggered",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# First triggered state should trigger even though entity had no previous state
|
||||
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_alarm_control_panel[amax_3000-None][alarm_control_panel.area1-entry]
|
||||
# name: test_alarm_control_panel[None-amax_3000][alarm_control_panel.area1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -34,7 +34,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_alarm_control_panel[amax_3000-None][alarm_control_panel.area1-state]
|
||||
# name: test_alarm_control_panel[None-amax_3000][alarm_control_panel.area1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'changed_by': None,
|
||||
@@ -51,7 +51,7 @@
|
||||
'state': 'disarmed',
|
||||
})
|
||||
# ---
|
||||
# name: test_alarm_control_panel[b5512-None][alarm_control_panel.area1-entry]
|
||||
# name: test_alarm_control_panel[None-b5512][alarm_control_panel.area1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -86,7 +86,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_alarm_control_panel[b5512-None][alarm_control_panel.area1-state]
|
||||
# name: test_alarm_control_panel[None-b5512][alarm_control_panel.area1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'changed_by': None,
|
||||
@@ -103,7 +103,7 @@
|
||||
'state': 'disarmed',
|
||||
})
|
||||
# ---
|
||||
# name: test_alarm_control_panel[solution_3000-None][alarm_control_panel.area1-entry]
|
||||
# name: test_alarm_control_panel[None-solution_3000][alarm_control_panel.area1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -138,7 +138,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_alarm_control_panel[solution_3000-None][alarm_control_panel.area1-state]
|
||||
# name: test_alarm_control_panel[None-solution_3000][alarm_control_panel.area1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'changed_by': None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_burglary_alarm_issues-entry]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_burglary_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -34,7 +34,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_burglary_alarm_issues-state]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_burglary_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Burglary alarm issues',
|
||||
@@ -47,7 +47,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_faulting_points-entry]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_faulting_points-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -82,7 +82,7 @@
|
||||
'unit_of_measurement': 'points',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_faulting_points-state]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_faulting_points-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Faulting points',
|
||||
@@ -96,7 +96,7 @@
|
||||
'state': '0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_fire_alarm_issues-entry]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_fire_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -131,7 +131,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_fire_alarm_issues-state]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_fire_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Fire alarm issues',
|
||||
@@ -144,7 +144,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_gas_alarm_issues-entry]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_gas_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -179,7 +179,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[amax_3000-None][sensor.area1_gas_alarm_issues-state]
|
||||
# name: test_sensor[None-amax_3000][sensor.area1_gas_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Gas alarm issues',
|
||||
@@ -192,7 +192,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_burglary_alarm_issues-entry]
|
||||
# name: test_sensor[None-b5512][sensor.area1_burglary_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -227,7 +227,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_burglary_alarm_issues-state]
|
||||
# name: test_sensor[None-b5512][sensor.area1_burglary_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Burglary alarm issues',
|
||||
@@ -240,7 +240,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_faulting_points-entry]
|
||||
# name: test_sensor[None-b5512][sensor.area1_faulting_points-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -275,7 +275,7 @@
|
||||
'unit_of_measurement': 'points',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_faulting_points-state]
|
||||
# name: test_sensor[None-b5512][sensor.area1_faulting_points-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Faulting points',
|
||||
@@ -289,7 +289,7 @@
|
||||
'state': '0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_fire_alarm_issues-entry]
|
||||
# name: test_sensor[None-b5512][sensor.area1_fire_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -324,7 +324,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_fire_alarm_issues-state]
|
||||
# name: test_sensor[None-b5512][sensor.area1_fire_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Fire alarm issues',
|
||||
@@ -337,7 +337,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_gas_alarm_issues-entry]
|
||||
# name: test_sensor[None-b5512][sensor.area1_gas_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -372,7 +372,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[b5512-None][sensor.area1_gas_alarm_issues-state]
|
||||
# name: test_sensor[None-b5512][sensor.area1_gas_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Gas alarm issues',
|
||||
@@ -385,7 +385,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_burglary_alarm_issues-entry]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_burglary_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -420,7 +420,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_burglary_alarm_issues-state]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_burglary_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Burglary alarm issues',
|
||||
@@ -433,7 +433,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_faulting_points-entry]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_faulting_points-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -468,7 +468,7 @@
|
||||
'unit_of_measurement': 'points',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_faulting_points-state]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_faulting_points-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Faulting points',
|
||||
@@ -482,7 +482,7 @@
|
||||
'state': '0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_fire_alarm_issues-entry]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_fire_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -517,7 +517,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_fire_alarm_issues-state]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_fire_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Fire alarm issues',
|
||||
@@ -530,7 +530,7 @@
|
||||
'state': 'no_issues',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_gas_alarm_issues-entry]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_gas_alarm_issues-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -565,7 +565,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[solution_3000-None][sensor.area1_gas_alarm_issues-state]
|
||||
# name: test_sensor[None-solution_3000][sensor.area1_gas_alarm_issues-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Area1 Gas alarm issues',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_switch[amax_3000-None][switch.main_door_locked-entry]
|
||||
# name: test_switch[None-amax_3000][switch.main_door_locked-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -34,7 +34,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[amax_3000-None][switch.main_door_locked-state]
|
||||
# name: test_switch[None-amax_3000][switch.main_door_locked-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Locked',
|
||||
@@ -47,7 +47,7 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[amax_3000-None][switch.main_door_momentarily_unlocked-entry]
|
||||
# name: test_switch[None-amax_3000][switch.main_door_momentarily_unlocked-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -82,7 +82,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[amax_3000-None][switch.main_door_momentarily_unlocked-state]
|
||||
# name: test_switch[None-amax_3000][switch.main_door_momentarily_unlocked-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Momentarily unlocked',
|
||||
@@ -95,7 +95,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[amax_3000-None][switch.main_door_secured-entry]
|
||||
# name: test_switch[None-amax_3000][switch.main_door_secured-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -130,7 +130,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[amax_3000-None][switch.main_door_secured-state]
|
||||
# name: test_switch[None-amax_3000][switch.main_door_secured-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Secured',
|
||||
@@ -143,7 +143,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[amax_3000-None][switch.output_a-entry]
|
||||
# name: test_switch[None-amax_3000][switch.output_a-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -178,7 +178,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[amax_3000-None][switch.output_a-state]
|
||||
# name: test_switch[None-amax_3000][switch.output_a-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Output A',
|
||||
@@ -191,7 +191,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.main_door_locked-entry]
|
||||
# name: test_switch[None-b5512][switch.main_door_locked-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -226,7 +226,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.main_door_locked-state]
|
||||
# name: test_switch[None-b5512][switch.main_door_locked-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Locked',
|
||||
@@ -239,7 +239,7 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.main_door_momentarily_unlocked-entry]
|
||||
# name: test_switch[None-b5512][switch.main_door_momentarily_unlocked-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -274,7 +274,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.main_door_momentarily_unlocked-state]
|
||||
# name: test_switch[None-b5512][switch.main_door_momentarily_unlocked-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Momentarily unlocked',
|
||||
@@ -287,7 +287,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.main_door_secured-entry]
|
||||
# name: test_switch[None-b5512][switch.main_door_secured-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -322,7 +322,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.main_door_secured-state]
|
||||
# name: test_switch[None-b5512][switch.main_door_secured-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Secured',
|
||||
@@ -335,7 +335,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.output_a-entry]
|
||||
# name: test_switch[None-b5512][switch.output_a-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -370,7 +370,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[b5512-None][switch.output_a-state]
|
||||
# name: test_switch[None-b5512][switch.output_a-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Output A',
|
||||
@@ -383,7 +383,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.main_door_locked-entry]
|
||||
# name: test_switch[None-solution_3000][switch.main_door_locked-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -418,7 +418,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.main_door_locked-state]
|
||||
# name: test_switch[None-solution_3000][switch.main_door_locked-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Locked',
|
||||
@@ -431,7 +431,7 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.main_door_momentarily_unlocked-entry]
|
||||
# name: test_switch[None-solution_3000][switch.main_door_momentarily_unlocked-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -466,7 +466,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.main_door_momentarily_unlocked-state]
|
||||
# name: test_switch[None-solution_3000][switch.main_door_momentarily_unlocked-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Momentarily unlocked',
|
||||
@@ -479,7 +479,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.main_door_secured-entry]
|
||||
# name: test_switch[None-solution_3000][switch.main_door_secured-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -514,7 +514,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.main_door_secured-state]
|
||||
# name: test_switch[None-solution_3000][switch.main_door_secured-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Main Door Secured',
|
||||
@@ -527,7 +527,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.output_a-entry]
|
||||
# name: test_switch[None-solution_3000][switch.output_a-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -562,7 +562,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch[solution_3000-None][switch.output_a-state]
|
||||
# name: test_switch[None-solution_3000][switch.output_a-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Output A',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests helpers."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -108,14 +108,13 @@ async def mock_config_entry_with_google_search(
|
||||
@pytest.fixture
|
||||
async def mock_init_component(
|
||||
hass: HomeAssistant, mock_config_entry: ConfigEntry
|
||||
) -> AsyncGenerator[None]:
|
||||
) -> None:
|
||||
"""Initialize integration."""
|
||||
with patch("google.genai.models.AsyncModels.get"):
|
||||
assert await async_setup_component(
|
||||
hass, "google_generative_ai_conversation", {}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"""The tests for the Assist satellite automation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_TARGET
|
||||
from homeassistant.core import Context, HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import mock_component
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_comp(hass: HomeAssistant) -> None:
|
||||
"""Initialize components."""
|
||||
mock_component(hass, "group")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "state"),
|
||||
[
|
||||
("assist_satellite.listening", "listening"),
|
||||
("assist_satellite.processing", "processing"),
|
||||
("assist_satellite.responding", "responding"),
|
||||
("assist_satellite.idle", "idle"),
|
||||
],
|
||||
)
|
||||
async def test_trigger_fires_on_state_change(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
trigger: str,
|
||||
state: str,
|
||||
) -> None:
|
||||
"""Test that the trigger fires when satellite changes to the specified state."""
|
||||
hass.states.async_set("assist_satellite.satellite_1", "idle")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: trigger,
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {"id": "{{ trigger.id}}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_1", state, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_listening_does_not_fire_on_other_states(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that listening trigger does not fire on other states."""
|
||||
hass.states.async_set("assist_satellite.satellite_1", "idle")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "assist_satellite.listening",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_1", "processing")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_1", "responding")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_1", "idle")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_does_not_fire_on_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that trigger does not fire when state becomes unavailable."""
|
||||
hass.states.async_set("assist_satellite.satellite_1", "idle")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "assist_satellite.listening",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_1", "unavailable")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_fires_with_multiple_entities(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test the firing with multiple entities."""
|
||||
hass.states.async_set("assist_satellite.satellite_1", "idle")
|
||||
hass.states.async_set("assist_satellite.satellite_2", "idle")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "assist_satellite.listening",
|
||||
CONF_TARGET: {
|
||||
CONF_ENTITY_ID: [
|
||||
"assist_satellite.satellite_1",
|
||||
"assist_satellite.satellite_2",
|
||||
]
|
||||
},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_1", "listening")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_2", "listening")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 2
|
||||
|
||||
|
||||
async def test_trigger_data_available(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that trigger data is available in action."""
|
||||
hass.states.async_set("assist_satellite.satellite_1", "idle")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "assist_satellite.listening",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {
|
||||
"entity_id": "{{ trigger.entity_id }}",
|
||||
"from_state": "{{ trigger.from_state.state }}",
|
||||
"to_state": "{{ trigger.to_state.state }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("assist_satellite.satellite_1", "listening")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data["entity_id"] == "assist_satellite.satellite_1"
|
||||
assert service_calls[0].data["from_state"] == "idle"
|
||||
assert service_calls[0].data["to_state"] == "listening"
|
||||
|
||||
|
||||
async def test_idle_trigger_fires_when_returning_to_idle(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that idle trigger fires when satellite returns to idle."""
|
||||
hass.states.async_set("assist_satellite.satellite_1", "listening")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "assist_satellite.idle",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Go to processing state first
|
||||
hass.states.async_set("assist_satellite.satellite_1", "processing")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Go back to idle
|
||||
hass.states.async_set("assist_satellite.satellite_1", "idle", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
@@ -1,416 +0,0 @@
|
||||
"""The tests for the Climate automation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_HVAC_ACTION,
|
||||
ATTR_HUMIDITY,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import mock_component
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_comp(hass: HomeAssistant) -> None:
|
||||
"""Initialize components."""
|
||||
mock_component(hass, "group")
|
||||
|
||||
|
||||
async def test_turns_on_trigger_fires_when_turning_on(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that turns_on trigger fires when climate turns on."""
|
||||
hass.states.async_set("climate.test", "off")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.turns_on",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "heat", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_turns_on_trigger_does_not_fire_when_already_on(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that turns_on trigger does not fire when already on."""
|
||||
hass.states.async_set("climate.test", "heat")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.turns_on",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "cool")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_turns_off_trigger_fires_when_turning_off(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that turns_off trigger fires when climate turns off."""
|
||||
hass.states.async_set("climate.test", "heat")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.turns_off",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "off", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_mode_changed_trigger_fires_on_mode_change(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that mode_changed trigger fires when mode changes."""
|
||||
hass.states.async_set("climate.test", "heat")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.mode_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "cool", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_mode_changed_trigger_filters_by_hvac_mode(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that mode_changed trigger filters by hvac_mode."""
|
||||
hass.states.async_set("climate.test", "off")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.mode_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
"hvac_mode": ["heat", "cool"],
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should trigger for heat
|
||||
hass.states.async_set("climate.test", "heat")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
# Should trigger for cool
|
||||
hass.states.async_set("climate.test", "cool")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 2
|
||||
|
||||
# Should not trigger for auto
|
||||
hass.states.async_set("climate.test", "auto")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 2
|
||||
|
||||
|
||||
async def test_cooling_trigger_fires_when_cooling_starts(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that cooling trigger fires when climate starts cooling."""
|
||||
hass.states.async_set("climate.test", "cool", {ATTR_HVAC_ACTION: "idle"})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.cooling",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "cool", {ATTR_HVAC_ACTION: "cooling"}, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_heating_trigger_fires_when_heating_starts(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that heating trigger fires when climate starts heating."""
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_HVAC_ACTION: "idle"})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.heating",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_HVAC_ACTION: "heating"}, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_drying_trigger_fires_when_drying_starts(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that drying trigger fires when climate starts drying."""
|
||||
hass.states.async_set("climate.test", "dry", {ATTR_HVAC_ACTION: "idle"})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.drying",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "dry", {ATTR_HVAC_ACTION: "drying"}, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_target_temperature_changed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that target_temperature_changed trigger fires."""
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 20})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.target_temperature_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 22}, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_target_temperature_changed_trigger_with_above(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that target_temperature_changed trigger filters by above threshold."""
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 20})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.target_temperature_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
"above": 22,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should not trigger at 21
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 21})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Should trigger at 23
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 23})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_current_temperature_changed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that current_temperature_changed trigger fires."""
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_CURRENT_TEMPERATURE: 20})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.current_temperature_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "heat", {ATTR_CURRENT_TEMPERATURE: 21}, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_target_humidity_changed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that target_humidity_changed trigger fires."""
|
||||
hass.states.async_set("climate.test", "dry", {ATTR_HUMIDITY: 50})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.target_humidity_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "dry", {ATTR_HUMIDITY: 60}, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_current_humidity_changed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that current_humidity_changed trigger fires."""
|
||||
hass.states.async_set("climate.test", "dry", {ATTR_CURRENT_HUMIDITY: 50})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.current_humidity_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "dry", {ATTR_CURRENT_HUMIDITY: 55}, context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_does_not_fire_on_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that trigger does not fire when state becomes unavailable."""
|
||||
hass.states.async_set("climate.test", "heat")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "climate.turns_off",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("climate.test", "unavailable")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
@@ -1,591 +0,0 @@
|
||||
"""The tests for the Cover automation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.cover import ATTR_CURRENT_POSITION
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_TARGET
|
||||
from homeassistant.core import Context, HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import mock_component
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_comp(hass: HomeAssistant) -> None:
|
||||
"""Initialize components."""
|
||||
mock_component(hass, "group")
|
||||
|
||||
|
||||
async def test_opens_trigger_fires_on_opening(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that opens trigger fires when cover starts opening."""
|
||||
hass.states.async_set("cover.test", "closed")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.opens",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "opening", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_opens_trigger_fires_on_open(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that opens trigger fires when cover becomes open."""
|
||||
hass.states.async_set("cover.test", "closed")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.opens",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "open", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_opens_trigger_fully_opened_option(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that opens trigger with fully_opened only fires at position 100."""
|
||||
hass.states.async_set("cover.test", "closed", {ATTR_CURRENT_POSITION: 0})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.opens",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
"fully_opened": True,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should not trigger at position 50
|
||||
hass.states.async_set("cover.test", "opening", {ATTR_CURRENT_POSITION: 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Should trigger at position 100
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_opens_trigger_device_class_filter(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that opens trigger filters by device class."""
|
||||
hass.states.async_set("cover.curtain", "closed", {"device_class": "curtain"})
|
||||
hass.states.async_set("cover.garage", "closed", {"device_class": "garage"})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.opens",
|
||||
CONF_TARGET: {
|
||||
CONF_ENTITY_ID: ["cover.curtain", "cover.garage"]
|
||||
},
|
||||
"device_class": ["curtain"],
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should trigger for curtain
|
||||
hass.states.async_set("cover.curtain", "opening", {"device_class": "curtain"})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
# Should not trigger for garage
|
||||
hass.states.async_set("cover.garage", "opening", {"device_class": "garage"})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_does_not_fire_on_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that trigger does not fire when state becomes unavailable."""
|
||||
hass.states.async_set("cover.test", "closed")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.opens",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "unavailable")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_trigger_data_available(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that trigger data is available in action."""
|
||||
hass.states.async_set("cover.test", "closed")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.opens",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {
|
||||
"entity_id": "{{ trigger.entity_id }}",
|
||||
"from_state": "{{ trigger.from_state.state }}",
|
||||
"to_state": "{{ trigger.to_state.state }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "opening")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data["entity_id"] == "cover.test"
|
||||
assert service_calls[0].data["from_state"] == "closed"
|
||||
assert service_calls[0].data["to_state"] == "opening"
|
||||
|
||||
|
||||
async def test_fires_with_multiple_entities(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test the firing with multiple entities."""
|
||||
hass.states.async_set("cover.test1", "closed")
|
||||
hass.states.async_set("cover.test2", "closed")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.opens",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: ["cover.test1", "cover.test2"]},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test1", "opening")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
hass.states.async_set("cover.test2", "opening")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 2
|
||||
|
||||
|
||||
async def test_closes_trigger_fires_on_closing(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that closes trigger fires when cover starts closing."""
|
||||
hass.states.async_set("cover.test", "open")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.closes",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "closing", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_closes_trigger_fires_on_closed(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that closes trigger fires when cover becomes closed."""
|
||||
hass.states.async_set("cover.test", "open")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.closes",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "closed", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_closes_trigger_fully_closed_option(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that closes trigger with fully_closed only fires at position 0."""
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.closes",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
"fully_closed": True,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should not trigger at position 50
|
||||
hass.states.async_set("cover.test", "closing", {ATTR_CURRENT_POSITION: 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Should trigger at position 0
|
||||
hass.states.async_set("cover.test", "closed", {ATTR_CURRENT_POSITION: 0})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_stops_trigger_fires_from_opening_to_open(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that stops trigger fires when cover stops from opening."""
|
||||
hass.states.async_set("cover.test", "opening")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.stops",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "open", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_stops_trigger_fires_from_closing_to_closed(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that stops trigger fires when cover stops from closing."""
|
||||
hass.states.async_set("cover.test", "closing")
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.stops",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "closed", context=context)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_stops_trigger_does_not_fire_on_other_changes(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that stops trigger does not fire on other state changes."""
|
||||
hass.states.async_set("cover.test", "closed")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.stops",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should not trigger when going from closed to opening
|
||||
hass.states.async_set("cover.test", "opening")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Should not trigger when going from open to closing
|
||||
hass.states.async_set("cover.test", "open")
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set("cover.test", "closing")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_position_changed_trigger_fires(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that position_changed trigger fires when position changes."""
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
|
||||
context = Context()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.position_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set(
|
||||
"cover.test", "open", {ATTR_CURRENT_POSITION: 75}, context=context
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].context.parent_id == context.id
|
||||
|
||||
|
||||
async def test_position_changed_trigger_with_lower_limit(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test position_changed trigger with lower limit."""
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.position_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
"lower": 50,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should trigger at position 75 (>= 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
# Should not trigger at position 25 (< 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 25})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_position_changed_trigger_with_upper_limit(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test position_changed trigger with upper limit."""
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 0})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.position_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
"upper": 50,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should trigger at position 25 (<= 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 25})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
# Should not trigger at position 75 (> 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_position_changed_trigger_with_above_limit(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test position_changed trigger with above limit."""
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 0})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.position_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
"above": 50,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should trigger at position 75 (> 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
# Should not trigger at position 50 (= 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_position_changed_trigger_with_below_limit(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test position_changed trigger with below limit."""
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.position_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
"below": 50,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Should trigger at position 25 (< 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 25})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
# Should not trigger at position 50 (= 50)
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_position_trigger_data_includes_positions(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that position trigger data includes position values."""
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "cover.position_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {
|
||||
"from_position": "{{ trigger.from_position }}",
|
||||
"to_position": "{{ trigger.to_position }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data["from_position"] == "100"
|
||||
assert service_calls[0].data["to_position"] == "75"
|
||||
@@ -41,16 +41,6 @@ def mock_homewizardenergy(
|
||||
"homeassistant.components.homewizard.config_flow.HomeWizardEnergyV1",
|
||||
new=homewizard,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.homewizard.has_v2_api",
|
||||
autospec=True,
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.homewizard.config_flow.has_v2_api",
|
||||
autospec=True,
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
client = homewizard.return_value
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Tests for the Iskra config flow."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyiskra.exceptions import (
|
||||
DeviceConnectionError,
|
||||
DeviceTimeoutError,
|
||||
@@ -38,15 +35,6 @@ from .const import (
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.iskra.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
# Test step_user with Rest API protocol
|
||||
async def test_user_rest_no_auth(hass: HomeAssistant, mock_pyiskra_rest) -> None:
|
||||
"""Test the user flow with Rest API protocol."""
|
||||
|
||||
@@ -1,650 +0,0 @@
|
||||
"""Test light trigger."""
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
async def test_light_turns_on_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the light turns on trigger fires when a light turns on."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state to off
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.turns_on",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Turn light on - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Turn light on again while already on - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_light_turns_on_trigger_ignores_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the light turns on trigger ignores unavailable states."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state to off
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.turns_on",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to unavailable - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Turn light on after unavailable - should not trigger (from unavailable)
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Turn light off
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Turn light on after being off - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_light_turns_on_multiple_lights(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test the turns on trigger with multiple lights."""
|
||||
entity_id_1 = "light.test_light_1"
|
||||
entity_id_2 = "light.test_light_2"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial states to off
|
||||
hass.states.async_set(entity_id_1, STATE_OFF)
|
||||
hass.states.async_set(entity_id_2, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.turns_on",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: [entity_id_1, entity_id_2],
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Turn first light on - should trigger
|
||||
hass.states.async_set(entity_id_1, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_1
|
||||
service_calls.clear()
|
||||
|
||||
# Turn second light on - should trigger
|
||||
hass.states.async_set(entity_id_2, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_2
|
||||
|
||||
|
||||
async def test_light_turns_off_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the light turns off trigger fires when a light turns off."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state to on
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.turns_off",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Turn light off - should trigger
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Turn light off again while already off - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_light_turns_off_trigger_ignores_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the light turns off trigger ignores unavailable states."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state to on
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.turns_off",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to unavailable - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Turn light off after unavailable - should not trigger (from unavailable)
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Turn light on
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Turn light off after being on - should trigger
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the brightness changed trigger fires when brightness changes."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state with brightness
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
"from_brightness": "{{ trigger.from_brightness }}",
|
||||
"to_brightness": "{{ trigger.to_brightness }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change brightness - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
assert service_calls[0].data["from_brightness"] == "100"
|
||||
assert service_calls[0].data["to_brightness"] == "150"
|
||||
service_calls.clear()
|
||||
|
||||
# Change brightness again - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data["from_brightness"] == "150"
|
||||
assert service_calls[0].data["to_brightness"] == "200"
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_same_brightness(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the brightness changed trigger does not fire when brightness is the same."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state with brightness
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Update state but keep brightness the same - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_with_lower_limit(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test the brightness changed trigger with lower limit."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state with brightness
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 50})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
"options": {
|
||||
"lower": 100,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change to brightness below lower limit - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 75})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Change to brightness at lower limit - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
|
||||
# Change to brightness above lower limit - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_with_upper_limit(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test the brightness changed trigger with upper limit."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state with brightness
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
"options": {
|
||||
"upper": 150,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change to brightness above upper limit - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 180})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Change to brightness at upper limit - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
|
||||
# Change to brightness below upper limit - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_with_above(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test the brightness changed trigger with above threshold."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state with brightness
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 50})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
"options": {
|
||||
"above": 100,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change to brightness at threshold - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Change to brightness above threshold - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 101})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
|
||||
# Change to brightness well above threshold - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_with_below(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test the brightness changed trigger with below threshold."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state with brightness
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
"options": {
|
||||
"below": 100,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change to brightness at threshold - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Change to brightness below threshold - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 99})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
|
||||
# Change to brightness well below threshold - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_ignores_unavailable(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the brightness changed trigger ignores unavailable states."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state with brightness
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Set to unavailable - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Change brightness after unavailable - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_from_no_brightness(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the trigger fires when brightness is added."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state without brightness (on/off only light)
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
"from_brightness": "{{ trigger.from_brightness }}",
|
||||
"to_brightness": "{{ trigger.to_brightness }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add brightness attribute - should trigger
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data["from_brightness"] == "None"
|
||||
assert service_calls[0].data["to_brightness"] == "100"
|
||||
|
||||
|
||||
async def test_light_brightness_changed_trigger_no_brightness(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the trigger does not fire when brightness is not present."""
|
||||
entity_id = "light.test_light"
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set initial state without brightness
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "light.brightness_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Turn light off and on without brightness - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
@@ -96,7 +96,6 @@ async def integration_fixture(
|
||||
"eve_energy_plug",
|
||||
"eve_energy_plug_patched",
|
||||
"eve_thermo",
|
||||
"eve_shutter",
|
||||
"eve_weather_sensor",
|
||||
"extended_color_light",
|
||||
"extractor_hood",
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
{
|
||||
"node_id": 148,
|
||||
"date_commissioned": "2025-11-07T16:57:31.360667",
|
||||
"last_interview": "2025-11-07T16:57:31.360690",
|
||||
"interview_version": 6,
|
||||
"available": true,
|
||||
"is_bridge": false,
|
||||
"attributes": {
|
||||
"0/29/0": [
|
||||
{
|
||||
"0": 18,
|
||||
"1": 1
|
||||
},
|
||||
{
|
||||
"0": 22,
|
||||
"1": 3
|
||||
}
|
||||
],
|
||||
"0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 52, 53, 56, 60, 62, 63],
|
||||
"0/29/2": [41],
|
||||
"0/29/3": [1],
|
||||
"0/29/65532": 0,
|
||||
"0/29/65533": 2,
|
||||
"0/29/65528": [],
|
||||
"0/29/65529": [],
|
||||
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/31/0": [
|
||||
{
|
||||
"1": 5,
|
||||
"2": 2,
|
||||
"3": [112233],
|
||||
"4": null,
|
||||
"254": 3
|
||||
}
|
||||
],
|
||||
"0/31/1": [],
|
||||
"0/31/2": 10,
|
||||
"0/31/3": 3,
|
||||
"0/31/4": 5,
|
||||
"0/31/65532": 1,
|
||||
"0/31/65533": 2,
|
||||
"0/31/65528": [],
|
||||
"0/31/65529": [],
|
||||
"0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/40/0": 18,
|
||||
"0/40/1": "Eve Systems",
|
||||
"0/40/2": 4874,
|
||||
"0/40/3": "Eve Shutter Switch 20ECI1701",
|
||||
"0/40/4": 96,
|
||||
"0/40/5": "",
|
||||
"0/40/6": "**REDACTED**",
|
||||
"0/40/7": 1,
|
||||
"0/40/8": "1.1",
|
||||
"0/40/9": 10203,
|
||||
"0/40/10": "3.6.1",
|
||||
"0/40/15": "**********",
|
||||
"0/40/18": "**********",
|
||||
"0/40/19": {
|
||||
"0": 3,
|
||||
"1": 3
|
||||
},
|
||||
"0/40/21": 17039616,
|
||||
"0/40/22": 1,
|
||||
"0/40/65532": 0,
|
||||
"0/40/65533": 4,
|
||||
"0/40/65528": [],
|
||||
"0/40/65529": [],
|
||||
"0/40/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531,
|
||||
65532, 65533
|
||||
],
|
||||
"0/42/0": [],
|
||||
"0/42/1": true,
|
||||
"0/42/2": 1,
|
||||
"0/42/3": null,
|
||||
"0/42/65532": 0,
|
||||
"0/42/65533": 1,
|
||||
"0/42/65528": [],
|
||||
"0/42/65529": [0],
|
||||
"0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/48/0": 0,
|
||||
"0/48/1": {
|
||||
"0": 60,
|
||||
"1": 900
|
||||
},
|
||||
"0/48/2": 0,
|
||||
"0/48/3": 0,
|
||||
"0/48/4": true,
|
||||
"0/48/65532": 0,
|
||||
"0/48/65533": 2,
|
||||
"0/48/65528": [1, 3, 5],
|
||||
"0/48/65529": [0, 2, 4],
|
||||
"0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/49/0": 1,
|
||||
"0/49/1": [
|
||||
{
|
||||
"0": "p0jbsOzJRNw=",
|
||||
"1": true
|
||||
}
|
||||
],
|
||||
"0/49/2": 10,
|
||||
"0/49/3": 20,
|
||||
"0/49/4": true,
|
||||
"0/49/5": 0,
|
||||
"0/49/6": "p0jbsOzJRNw=",
|
||||
"0/49/7": null,
|
||||
"0/49/9": 10,
|
||||
"0/49/10": 5,
|
||||
"0/49/65532": 2,
|
||||
"0/49/65533": 2,
|
||||
"0/49/65528": [1, 5, 7],
|
||||
"0/49/65529": [0, 3, 4, 6, 8],
|
||||
"0/49/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"0/50/65532": 0,
|
||||
"0/50/65533": 1,
|
||||
"0/50/65528": [1],
|
||||
"0/50/65529": [0],
|
||||
"0/50/65531": [65528, 65529, 65531, 65532, 65533],
|
||||
"0/51/0": [
|
||||
{
|
||||
"0": "ieee802154",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "Wi5/8pP0edY=",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 4
|
||||
}
|
||||
],
|
||||
"0/51/1": 1,
|
||||
"0/51/2": 213,
|
||||
"0/51/3": 0,
|
||||
"0/51/5": [],
|
||||
"0/51/6": [],
|
||||
"0/51/7": [],
|
||||
"0/51/8": false,
|
||||
"0/51/65532": 0,
|
||||
"0/51/65533": 2,
|
||||
"0/51/65528": [2],
|
||||
"0/51/65529": [0, 1],
|
||||
"0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/52/1": 10104,
|
||||
"0/52/2": 2008,
|
||||
"0/52/65532": 0,
|
||||
"0/52/65533": 1,
|
||||
"0/52/65528": [],
|
||||
"0/52/65529": [],
|
||||
"0/52/65531": [1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/53/0": 25,
|
||||
"0/53/1": 5,
|
||||
"0/53/2": "MyHome",
|
||||
"0/53/3": 4660,
|
||||
"0/53/4": 12054125955590472924,
|
||||
"0/53/5": "QP0ADbgAoAAA",
|
||||
"0/53/6": 0,
|
||||
"0/53/7": [
|
||||
{
|
||||
"0": 12864791528929066571,
|
||||
"1": 28,
|
||||
"2": 11264,
|
||||
"3": 411672,
|
||||
"4": 11555,
|
||||
"5": 3,
|
||||
"6": -53,
|
||||
"7": -53,
|
||||
"8": 44,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 13438285129078731668,
|
||||
"1": 16,
|
||||
"2": 18432,
|
||||
"3": 50641,
|
||||
"4": 10901,
|
||||
"5": 1,
|
||||
"6": -89,
|
||||
"7": -89,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 8265194500311707858,
|
||||
"1": 51,
|
||||
"2": 24576,
|
||||
"3": 75011,
|
||||
"4": 10782,
|
||||
"5": 2,
|
||||
"6": -84,
|
||||
"7": -84,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 14318601490803184919,
|
||||
"1": 16,
|
||||
"2": 27648,
|
||||
"3": 310236,
|
||||
"4": 10937,
|
||||
"5": 3,
|
||||
"6": -50,
|
||||
"7": -50,
|
||||
"8": 20,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 2202349555917590819,
|
||||
"1": 22,
|
||||
"2": 45056,
|
||||
"3": 86183,
|
||||
"4": 25554,
|
||||
"5": 3,
|
||||
"6": -78,
|
||||
"7": -85,
|
||||
"8": 3,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 4206032556233211940,
|
||||
"1": 63,
|
||||
"2": 53248,
|
||||
"3": 80879,
|
||||
"4": 10668,
|
||||
"5": 3,
|
||||
"6": -78,
|
||||
"7": -77,
|
||||
"8": 3,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 7085268071783685380,
|
||||
"1": 15,
|
||||
"2": 54272,
|
||||
"3": 4269,
|
||||
"4": 3159,
|
||||
"5": 3,
|
||||
"6": -76,
|
||||
"7": -74,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 10848996971365580420,
|
||||
"1": 17,
|
||||
"2": 60416,
|
||||
"3": 318410,
|
||||
"4": 10506,
|
||||
"5": 3,
|
||||
"6": -61,
|
||||
"7": -62,
|
||||
"8": 43,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
}
|
||||
],
|
||||
"0/53/8": [
|
||||
{
|
||||
"0": 12864791528929066571,
|
||||
"1": 11264,
|
||||
"2": 11,
|
||||
"3": 27,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 28,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 13438285129078731668,
|
||||
"1": 18432,
|
||||
"2": 18,
|
||||
"3": 53,
|
||||
"4": 1,
|
||||
"5": 1,
|
||||
"6": 2,
|
||||
"7": 16,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 8265194500311707858,
|
||||
"1": 24576,
|
||||
"2": 24,
|
||||
"3": 52,
|
||||
"4": 1,
|
||||
"5": 2,
|
||||
"6": 3,
|
||||
"7": 51,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 14318601490803184919,
|
||||
"1": 27648,
|
||||
"2": 27,
|
||||
"3": 11,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 16,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 6498271992183290326,
|
||||
"1": 40960,
|
||||
"2": 40,
|
||||
"3": 63,
|
||||
"4": 0,
|
||||
"5": 0,
|
||||
"6": 0,
|
||||
"7": 0,
|
||||
"8": true,
|
||||
"9": false
|
||||
},
|
||||
{
|
||||
"0": 2202349555917590819,
|
||||
"1": 45056,
|
||||
"2": 44,
|
||||
"3": 27,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 2,
|
||||
"7": 22,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 4206032556233211940,
|
||||
"1": 53248,
|
||||
"2": 52,
|
||||
"3": 59,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 63,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 7085268071783685380,
|
||||
"1": 54272,
|
||||
"2": 53,
|
||||
"3": 27,
|
||||
"4": 2,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 15,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 10848996971365580420,
|
||||
"1": 60416,
|
||||
"2": 59,
|
||||
"3": 11,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 17,
|
||||
"8": true,
|
||||
"9": true
|
||||
}
|
||||
],
|
||||
"0/53/9": 1938283056,
|
||||
"0/53/10": 68,
|
||||
"0/53/11": 65,
|
||||
"0/53/12": 8,
|
||||
"0/53/13": 27,
|
||||
"0/53/14": 1,
|
||||
"0/53/15": 1,
|
||||
"0/53/16": 1,
|
||||
"0/53/17": 0,
|
||||
"0/53/18": 1,
|
||||
"0/53/19": 1,
|
||||
"0/53/20": 0,
|
||||
"0/53/21": 0,
|
||||
"0/53/22": 759,
|
||||
"0/53/23": 737,
|
||||
"0/53/24": 22,
|
||||
"0/53/25": 737,
|
||||
"0/53/26": 737,
|
||||
"0/53/27": 22,
|
||||
"0/53/28": 759,
|
||||
"0/53/29": 0,
|
||||
"0/53/30": 0,
|
||||
"0/53/31": 0,
|
||||
"0/53/32": 0,
|
||||
"0/53/33": 529,
|
||||
"0/53/34": 0,
|
||||
"0/53/35": 0,
|
||||
"0/53/36": 0,
|
||||
"0/53/37": 0,
|
||||
"0/53/38": 0,
|
||||
"0/53/39": 3405,
|
||||
"0/53/40": 275,
|
||||
"0/53/41": 126,
|
||||
"0/53/42": 392,
|
||||
"0/53/43": 0,
|
||||
"0/53/44": 0,
|
||||
"0/53/45": 0,
|
||||
"0/53/46": 0,
|
||||
"0/53/47": 0,
|
||||
"0/53/48": 2796,
|
||||
"0/53/49": 9,
|
||||
"0/53/50": 18,
|
||||
"0/53/51": 10,
|
||||
"0/53/52": 0,
|
||||
"0/53/53": 0,
|
||||
"0/53/54": 70,
|
||||
"0/53/55": 110,
|
||||
"0/53/59": {
|
||||
"0": 672,
|
||||
"1": 143
|
||||
},
|
||||
"0/53/60": "AB//4A==",
|
||||
"0/53/61": {
|
||||
"0": true,
|
||||
"1": false,
|
||||
"2": true,
|
||||
"3": true,
|
||||
"4": true,
|
||||
"5": true,
|
||||
"6": false,
|
||||
"7": true,
|
||||
"8": true,
|
||||
"9": true,
|
||||
"10": true,
|
||||
"11": true
|
||||
},
|
||||
"0/53/62": [],
|
||||
"0/53/65532": 15,
|
||||
"0/53/65533": 3,
|
||||
"0/53/65528": [],
|
||||
"0/53/65529": [0],
|
||||
"0/53/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
|
||||
21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38,
|
||||
39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59,
|
||||
60, 61, 62, 65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"0/56/0": 815849852639528,
|
||||
"0/56/1": 2,
|
||||
"0/56/2": 2,
|
||||
"0/56/3": null,
|
||||
"0/56/5": [
|
||||
{
|
||||
"0": 3600,
|
||||
"1": 0,
|
||||
"2": "Europe/Paris"
|
||||
}
|
||||
],
|
||||
"0/56/6": [
|
||||
{
|
||||
"0": 0,
|
||||
"1": 0,
|
||||
"2": 828061200000000
|
||||
},
|
||||
{
|
||||
"0": 3600,
|
||||
"1": 828061200000000,
|
||||
"2": 846205200000000
|
||||
}
|
||||
],
|
||||
"0/56/7": 815853452640810,
|
||||
"0/56/8": 2,
|
||||
"0/56/10": 2,
|
||||
"0/56/11": 2,
|
||||
"0/56/65532": 9,
|
||||
"0/56/65533": 2,
|
||||
"0/56/65528": [3],
|
||||
"0/56/65529": [0, 1, 2, 4],
|
||||
"0/56/65531": [
|
||||
0, 1, 2, 3, 5, 6, 7, 8, 10, 11, 65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"0/60/0": 0,
|
||||
"0/60/1": null,
|
||||
"0/60/2": null,
|
||||
"0/60/65532": 1,
|
||||
"0/60/65533": 1,
|
||||
"0/60/65528": [],
|
||||
"0/60/65529": [0, 1, 2],
|
||||
"0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/62/0": [
|
||||
{
|
||||
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRlBgkBwEkCAEwCUEEQ2q1XJVV19WnxHHSfOUdx9bDmdDqjNtb9YgZA2j76IaZCChToVK6aKvw+YxIPL3mgzVfD08t2wHpcNjyBIAYFjcKNQEoARgkAgE2AwQCBAEYMAQUXObIaHWU7+qbdq7roNf1TweBIfMwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0BByE+Cvdi+klStM4F55ptZC4sE7IRIzqFHEUa2CZY2k7uTFPj9Yo1YzWgpnNJlAc0vnGXdN9E7B6yttZk4tSkZGA==",
|
||||
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY",
|
||||
"254": 3
|
||||
}
|
||||
],
|
||||
"0/62/1": [
|
||||
{
|
||||
"1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=",
|
||||
"2": 4939,
|
||||
"3": 2,
|
||||
"4": 148,
|
||||
"5": "ha-freebox",
|
||||
"254": 3
|
||||
}
|
||||
],
|
||||
"0/62/2": 5,
|
||||
"0/62/3": 3,
|
||||
"0/62/4": [
|
||||
"FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBBHhoDAkBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQKhZq5zQ3AYFGQVcWu+OD8c4yQyTpkGu09UkZu0SXSjWU0Onq7U6RnfhEnsCTZeNC3TB25octZQPnoe4yQyMhOMY",
|
||||
"FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=",
|
||||
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y"
|
||||
],
|
||||
"0/62/5": 3,
|
||||
"0/62/65532": 0,
|
||||
"0/62/65533": 1,
|
||||
"0/62/65528": [1, 3, 5, 8],
|
||||
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11],
|
||||
"0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/63/0": [],
|
||||
"0/63/1": [],
|
||||
"0/63/2": 4,
|
||||
"0/63/3": 3,
|
||||
"0/63/65532": 0,
|
||||
"0/63/65533": 2,
|
||||
"0/63/65528": [2, 5],
|
||||
"0/63/65529": [0, 1, 3, 4],
|
||||
"0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/3/0": 0,
|
||||
"1/3/1": 2,
|
||||
"1/3/65532": 0,
|
||||
"1/3/65533": 5,
|
||||
"1/3/65528": [],
|
||||
"1/3/65529": [0],
|
||||
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/4/0": 128,
|
||||
"1/4/65532": 1,
|
||||
"1/4/65533": 4,
|
||||
"1/4/65528": [0, 1, 2, 3],
|
||||
"1/4/65529": [0, 1, 2, 3, 4, 5],
|
||||
"1/4/65531": [0, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/29/0": [
|
||||
{
|
||||
"0": 514,
|
||||
"1": 3
|
||||
}
|
||||
],
|
||||
"1/29/1": [3, 4, 29, 258, 319486977],
|
||||
"1/29/2": [],
|
||||
"1/29/3": [],
|
||||
"1/29/65532": 0,
|
||||
"1/29/65533": 2,
|
||||
"1/29/65528": [],
|
||||
"1/29/65529": [],
|
||||
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/258/0": 0,
|
||||
"1/258/7": 9,
|
||||
"1/258/10": 0,
|
||||
"1/258/11": 0,
|
||||
"1/258/13": 0,
|
||||
"1/258/14": 0,
|
||||
"1/258/23": 0,
|
||||
"1/258/26": 0,
|
||||
"1/258/65532": 5,
|
||||
"1/258/65533": 5,
|
||||
"1/258/65528": [],
|
||||
"1/258/65529": [0, 1, 2, 5],
|
||||
"1/258/65531": [
|
||||
0, 7, 10, 11, 13, 14, 23, 26, 65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"1/319486977/319422464": "AAJgAAsCAAADAuEnBAxCSzM2TjJBMDEyMDWcAQD/BAECAKD5AQEdAQj/BCUCvg7wAcPxAf/vHwEAAAD//7mFDWkAAAAAzzAOaQAAAAAAZAAAAAAAAAD6AQDzFQHDAP8KFAAAAAEAAAAAAAAAAAAAAF0EAAAAAP4JEagIAABuCwAARQUFAAAAAEZUBW0jLA8AAEIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASQYFDAgQgAFEEQUMAAUDPAAAAOEpPkKHWiu/RxEFAiTUJG4lRyZ4AAAAPAAAAEgGBQAAAAAASgYFAAAAAAD/CyIJEAAAAAAAAAAA",
|
||||
"1/319486977/319422466": "2AAAAM0AAABxXL4uBiUBKQEaARcBGAEZAQMAABAAAAAAAgAAAAEAAA==",
|
||||
"1/319486977/319422467": "",
|
||||
"1/319486977/319422479": false,
|
||||
"1/319486977/319422480": false,
|
||||
"1/319486977/319422481": false,
|
||||
"1/319486977/319422482": 40960,
|
||||
"1/319486977/65532": 0,
|
||||
"1/319486977/65533": 1,
|
||||
"1/319486977/65528": [],
|
||||
"1/319486977/65529": [],
|
||||
"1/319486977/65531": [
|
||||
65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467,
|
||||
319422468, 319422469, 319422479, 319422480, 319422481, 319422482, 65532,
|
||||
65533
|
||||
]
|
||||
},
|
||||
"attribute_subscriptions": []
|
||||
}
|
||||
@@ -1271,55 +1271,6 @@
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[eve_shutter][button.eve_shutter_switch_20eci1701_identify-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'button.eve_shutter_switch_20eci1701_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Identify',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '00000000000004D2-0000000000000094-MatterNodeDevice-1-IdentifyButton-3-1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[eve_shutter][button.eve_shutter_switch_20eci1701_identify-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'identify',
|
||||
'friendly_name': 'Eve Shutter Switch 20ECI1701 Identify',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.eve_shutter_switch_20eci1701_identify',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[eve_thermo][button.eve_thermo_identify-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -1,55 +1,4 @@
|
||||
# serializer version: 1
|
||||
# name: test_covers[eve_shutter][cover.eve_shutter_switch_20eci1701-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'cover',
|
||||
'entity_category': None,
|
||||
'entity_id': 'cover.eve_shutter_switch_20eci1701',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <CoverDeviceClass.SHADE: 'shade'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CoverEntityFeature: 15>,
|
||||
'translation_key': None,
|
||||
'unique_id': '00000000000004D2-0000000000000094-MatterNodeDevice-1-MatterCoverPositionAwareLift-258-10',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_covers[eve_shutter][cover.eve_shutter_switch_20eci1701-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_position': 100,
|
||||
'device_class': 'shade',
|
||||
'friendly_name': 'Eve Shutter Switch 20ECI1701',
|
||||
'supported_features': <CoverEntityFeature: 15>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'cover.eve_shutter_switch_20eci1701',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'open',
|
||||
})
|
||||
# ---
|
||||
# name: test_covers[window_covering_full][cover.mock_full_window_covering-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -4582,55 +4582,6 @@
|
||||
'state': '220.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_shutter][sensor.eve_shutter_switch_20eci1701_target_opening_position-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.eve_shutter_switch_20eci1701_target_opening_position',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Target opening position',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'window_covering_target_position',
|
||||
'unique_id': '00000000000004D2-0000000000000094-MatterNodeDevice-1-TargetPositionLiftPercent100ths-258-11',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_shutter][sensor.eve_shutter_switch_20eci1701_target_opening_position-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Shutter Switch 20ECI1701 Target opening position',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.eve_shutter_switch_20eci1701_target_opening_position',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '100',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
"""Test media_player trigger."""
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_CONTENT_TYPE
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITY_ID,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
async def test_turns_on_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the turns on trigger fires when a media player turns on."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state to off
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.turns_on",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Turn on - should trigger
|
||||
hass.states.async_set(entity_id, STATE_IDLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Already on, change to playing - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_turns_off_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the turns off trigger fires when a media player turns off."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state to playing
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.turns_off",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Turn off - should trigger
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Already off - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_playing_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the playing trigger fires when a media player starts playing."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state to idle
|
||||
hass.states.async_set(entity_id, STATE_IDLE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.playing",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Start playing - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Pause then play again - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PAUSED)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_playing_trigger_with_media_content_type_filter(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the playing trigger with media content type filter."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state to idle
|
||||
hass.states.async_set(entity_id, STATE_IDLE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.playing",
|
||||
"target": {
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
},
|
||||
"options": {
|
||||
"media_content_type": ["music", "video"],
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Start playing music - should trigger
|
||||
hass.states.async_set(
|
||||
entity_id, STATE_PLAYING, {ATTR_MEDIA_CONTENT_TYPE: "music"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Stop and play video - should trigger
|
||||
hass.states.async_set(entity_id, STATE_IDLE)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(
|
||||
entity_id, STATE_PLAYING, {ATTR_MEDIA_CONTENT_TYPE: "video"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Stop and play podcast - should not trigger (not in filter)
|
||||
hass.states.async_set(entity_id, STATE_IDLE)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(
|
||||
entity_id, STATE_PLAYING, {ATTR_MEDIA_CONTENT_TYPE: "podcast"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_paused_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the paused trigger fires when a media player pauses."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state to playing
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.paused",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Pause - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PAUSED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Already paused - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_PAUSED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_stopped_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the stopped trigger fires when a media player stops playing."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state to playing
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.stopped",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Stop to idle - should trigger
|
||||
hass.states.async_set(entity_id, STATE_IDLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Start playing again and then stop to off - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Stop from paused - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_id, STATE_PAUSED)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_id, STATE_IDLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_muted_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the muted trigger fires when a media player gets muted."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state with volume unmuted
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_MUTED
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.muted",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Mute - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Already muted - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_unmuted_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the unmuted trigger fires when a media player gets unmuted."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state with volume muted
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_MUTED
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.unmuted",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Unmute - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Already unmuted - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_volume_changed_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the volume changed trigger fires when volume changes."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state with volume
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.5})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.volume_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change volume - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Same volume - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_volume_changed_trigger_with_above_threshold(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the volume changed trigger with above threshold."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state with volume
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.3})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.volume_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
"options": {
|
||||
"above": 0.5,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change volume above threshold - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Change volume but still below threshold - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.4})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
async def test_volume_changed_trigger_with_below_threshold(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test that the volume changed trigger with below threshold."""
|
||||
entity_id = "media_player.test"
|
||||
await async_setup_component(hass, "media_player", {})
|
||||
|
||||
# Set initial state with volume
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"triggers": {
|
||||
"trigger": "media_player.volume_changed",
|
||||
"target": {CONF_ENTITY_ID: entity_id},
|
||||
"options": {
|
||||
"below": 0.5,
|
||||
},
|
||||
},
|
||||
"actions": {
|
||||
"action": "test.automation",
|
||||
"data": {
|
||||
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Change volume below threshold - should trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.3})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Change volume but still above threshold - should not trigger
|
||||
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.6})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
@@ -23,7 +23,7 @@ MOCK_SERVER_ID = "1234"
|
||||
def mock_get_server_info() -> Generator[AsyncMock]:
|
||||
"""Mock the function to get server info."""
|
||||
with patch(
|
||||
"homeassistant.components.music_assistant.config_flow._get_server_info"
|
||||
"homeassistant.components.music_assistant.config_flow.get_server_info"
|
||||
) as mock_get_server_info:
|
||||
mock_get_server_info.return_value = ServerInfoMessage.from_json(
|
||||
load_fixture("server_info_message.json", DOMAIN)
|
||||
|
||||
@@ -66,7 +66,7 @@ async def test_full_flow(
|
||||
assert result["result"].unique_id == "1234"
|
||||
|
||||
|
||||
async def test_zeroconf_flow(
|
||||
async def test_zero_conf_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_get_server_info: AsyncMock,
|
||||
) -> None:
|
||||
@@ -90,21 +90,21 @@ async def test_zeroconf_flow(
|
||||
assert result["result"].unique_id == "1234"
|
||||
|
||||
|
||||
async def test_zeroconf_invalid_discovery_info(
|
||||
async def test_zero_conf_missing_server_id(
|
||||
hass: HomeAssistant,
|
||||
mock_get_server_info: AsyncMock,
|
||||
) -> None:
|
||||
"""Test zeroconf flow with invalid discovery info."""
|
||||
bad_zeroconf_data = deepcopy(ZEROCONF_DATA)
|
||||
bad_zeroconf_data.properties.pop("server_id")
|
||||
"""Test zeroconf flow with missing server id."""
|
||||
bad_zero_conf_data = deepcopy(ZEROCONF_DATA)
|
||||
bad_zero_conf_data.properties.pop("server_id")
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=bad_zeroconf_data,
|
||||
data=bad_zero_conf_data,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "invalid_discovery_info"
|
||||
assert result["reason"] == "missing_server_id"
|
||||
|
||||
|
||||
async def test_duplicate_user(
|
||||
@@ -324,6 +324,46 @@ async def test_zeroconf_existing_entry_working_url(
|
||||
assert mock_config_entry.data[CONF_URL] == "http://localhost:8095"
|
||||
|
||||
|
||||
async def test_zeroconf_existing_entry_broken_url(
|
||||
hass: HomeAssistant,
|
||||
mock_get_server_info: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test zeroconf flow when existing entry has broken URL."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Create modified zeroconf data with different base_url
|
||||
modified_zeroconf_data = deepcopy(ZEROCONF_DATA)
|
||||
modified_zeroconf_data.properties["base_url"] = "http://discovered-working-url:8095"
|
||||
|
||||
# Mock server info with the discovered URL
|
||||
server_info = ServerInfoMessage.from_json(
|
||||
await async_load_fixture(hass, "server_info_message.json", DOMAIN)
|
||||
)
|
||||
server_info.base_url = "http://discovered-working-url:8095"
|
||||
mock_get_server_info.return_value = server_info
|
||||
|
||||
# First call (testing current URL) should fail, second call (testing discovered URL) should succeed
|
||||
mock_get_server_info.side_effect = [
|
||||
CannotConnect("cannot_connect"), # Current URL fails
|
||||
server_info, # Discovered URL works
|
||||
]
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=modified_zeroconf_data,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Should proceed to discovery confirm because current URL is broken
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
# Verify the URL was updated in the config entry
|
||||
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert updated_entry.data[CONF_URL] == "http://discovered-working-url:8095"
|
||||
|
||||
|
||||
async def test_zeroconf_existing_entry_ignored(
|
||||
hass: HomeAssistant,
|
||||
mock_get_server_info: AsyncMock,
|
||||
@@ -356,3 +396,7 @@ async def test_zeroconf_existing_entry_ignored(
|
||||
# Should abort because entry was ignored (respect user's choice)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
# Verify the ignored entry was not modified
|
||||
ignored_entry = hass.config_entries.async_get_entry(ignored_config_entry.entry_id)
|
||||
assert ignored_entry.data == {} # Still no URL field
|
||||
assert ignored_entry.source == SOURCE_IGNORE
|
||||
|
||||
@@ -119,7 +119,7 @@ def selected_platforms(platforms: list[Platform]) -> Iterator[None]:
|
||||
with (
|
||||
patch("homeassistant.components.netatmo.data_handler.PLATFORMS", platforms),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.async_get_config_entry_implementation",
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.webhook_generate_url",
|
||||
|
||||
@@ -416,7 +416,7 @@ async def test_camera_reconnect_webhook(
|
||||
) as mock_auth,
|
||||
patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.async_get_config_entry_implementation",
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.webhook_generate_url",
|
||||
@@ -515,7 +515,7 @@ async def test_setup_component_no_devices(
|
||||
) as mock_auth,
|
||||
patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.async_get_config_entry_implementation",
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.webhook_generate_url",
|
||||
@@ -558,7 +558,7 @@ async def test_camera_image_raises_exception(
|
||||
) as mock_auth,
|
||||
patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.async_get_config_entry_implementation",
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.netatmo.webhook_generate_url",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user