Compare commits

..

1 Commits

Author SHA1 Message Date
Erik
5c0df09dc9 Remove template from sql service schema 2025-11-11 10:15:05 +01:00
164 changed files with 1144 additions and 13393 deletions

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -9,5 +9,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.7"]
"requirements": ["pyhive-integration==1.0.6"]
}

View File

@@ -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:

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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},
)

View File

@@ -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:

View File

@@ -143,11 +143,6 @@
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"options": {
"step": {
"public_weather": {

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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%]"
}
}
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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(

View File

@@ -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}"
}

View File

@@ -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)

View File

@@ -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.",

View File

@@ -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]

View File

@@ -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(

View File

@@ -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

View File

@@ -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": {

View File

@@ -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)
)

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
]

View File

@@ -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"
}
}
}

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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]

View File

@@ -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__)

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"
}
}

View File

@@ -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,
),
)

View File

@@ -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"

View File

@@ -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("-", "_")

View File

@@ -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)

View File

@@ -124,10 +124,5 @@
"name": "Water outlet temperature"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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,
)

View File

@@ -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()),
),
)

View File

@@ -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."
}
}
}

View File

@@ -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 (

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1034,6 +1034,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"v2c",
"vallox",
"vasttrafik",
"velux",
"venstar",
"vera",
"verisure",

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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',

View File

@@ -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',

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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",

View File

@@ -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": []
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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({

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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