mirror of
https://github.com/home-assistant/core.git
synced 2025-11-12 20:40:18 +00:00
Compare commits
106 Commits
fail_on_te
...
claude/tri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f3c3e5b0d | ||
|
|
b46640a1c2 | ||
|
|
f7727e8192 | ||
|
|
296c41c46c | ||
|
|
a7d5140f80 | ||
|
|
e8cd2ad1e6 | ||
|
|
10bd2ffc5f | ||
|
|
b9dac02e8e | ||
|
|
8605eb046a | ||
|
|
26b4fa5d39 | ||
|
|
be23d3d43d | ||
|
|
a4fbb597f4 | ||
|
|
ee11fc37d5 | ||
|
|
9900e49bcc | ||
|
|
3feb3fefef | ||
|
|
b3e4f6dc43 | ||
|
|
a9ba0bea8f | ||
|
|
bafa1e250d | ||
|
|
734c6f27c6 | ||
|
|
37eed7fae8 | ||
|
|
37aa4c68d9 | ||
|
|
80f8d94db4 | ||
|
|
7c0a7f4f9d | ||
|
|
5d5d7f7acf | ||
|
|
f0feb93fe1 | ||
|
|
9361ccbfc2 | ||
|
|
e9c76f1053 | ||
|
|
36b100c40a | ||
|
|
60107a1492 | ||
|
|
aedd48c298 | ||
|
|
febbb85532 | ||
|
|
af67a35b75 | ||
|
|
dd34d458f5 | ||
|
|
603d4bcf87 | ||
|
|
2dadc1f2b3 | ||
|
|
936151fae5 | ||
|
|
9760eb7f2b | ||
|
|
7851bed00c | ||
|
|
6aba0b20c6 | ||
|
|
cadfed2348 | ||
|
|
44e2fa6996 | ||
|
|
d0ff617e17 | ||
|
|
8e499569a4 | ||
|
|
5e0ebddd6f | ||
|
|
c0f61f6c2b | ||
|
|
df60de38b0 | ||
|
|
cb086bb8e9 | ||
|
|
ee2e9dc7d6 | ||
|
|
85cd3c68b7 | ||
|
|
1b0b6e63f2 | ||
|
|
12fc79e8d3 | ||
|
|
ca2e7b9509 | ||
|
|
8e8becc43e | ||
|
|
dcec6c3dc8 | ||
|
|
c0e59c4508 | ||
|
|
cd379aadbf | ||
|
|
ccdd54b187 | ||
|
|
3f22dbaa2e | ||
|
|
c18dc0a9ab | ||
|
|
f0e4296d93 | ||
|
|
b3750109c6 | ||
|
|
93025c9845 | ||
|
|
df348644b1 | ||
|
|
8749b0d750 | ||
|
|
a6a1519c06 | ||
|
|
3068e19843 | ||
|
|
55feb1e735 | ||
|
|
bb7dc69131 | ||
|
|
aa9003a524 | ||
|
|
4e9da5249d | ||
|
|
f502739df2 | ||
|
|
0f2ff29378 | ||
|
|
2921e7ed3c | ||
|
|
25d44e8d37 | ||
|
|
0a480a26a3 | ||
|
|
d5da64dd8d | ||
|
|
92adcd8635 | ||
|
|
ee0c4b15c2 | ||
|
|
507f54198e | ||
|
|
0ed342b433 | ||
|
|
363c86faf3 | ||
|
|
095a7ad060 | ||
|
|
ab5981bbbd | ||
|
|
ac2fb53dfd | ||
|
|
02ff5de1ff | ||
|
|
5cd5d480d9 | ||
|
|
a3c7d772fc | ||
|
|
fe0c69dba7 | ||
|
|
e5365234c3 | ||
|
|
1531175bd3 | ||
|
|
62add59ff4 | ||
|
|
d8daca657b | ||
|
|
1891da46ea | ||
|
|
22ae894745 | ||
|
|
160810c69d | ||
|
|
2ae23b920a | ||
|
|
a7edfb082f | ||
|
|
3ac203b05f | ||
|
|
7c3eb19fc4 | ||
|
|
70c6fac743 | ||
|
|
e19d7250d5 | ||
|
|
a850d5dba7 | ||
|
|
0cf0f10654 | ||
|
|
8429f154ca | ||
|
|
7b4f5ad362 | ||
|
|
583b439557 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -622,7 +622,7 @@ jobs:
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
/homeassistant/components/mullvad/ @meichthys
|
||||
/tests/components/mullvad/ @meichthys
|
||||
/homeassistant/components/music_assistant/ @music-assistant
|
||||
/tests/components/music_assistant/ @music-assistant
|
||||
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
|
||||
/tests/components/music_assistant/ @music-assistant @arturpragacz
|
||||
/homeassistant/components/mutesync/ @currentoor
|
||||
/tests/components/mutesync/ @currentoor
|
||||
/homeassistant/components/my/ @home-assistant/core
|
||||
|
||||
@@ -143,5 +143,28 @@
|
||||
"name": "Trigger"
|
||||
}
|
||||
},
|
||||
"title": "Alarm control panel"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
283
homeassistant/components/alarm_control_panel/trigger.py
Normal file
283
homeassistant/components/alarm_control_panel/trigger.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""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
|
||||
30
homeassistant/components/alarm_control_panel/triggers.yaml
Normal file
30
homeassistant/components/alarm_control_panel/triggers.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
armed:
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
fields:
|
||||
mode:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- value: armed_home
|
||||
label: Home
|
||||
- value: armed_away
|
||||
label: Away
|
||||
- value: armed_night
|
||||
label: Night
|
||||
- value: armed_vacation
|
||||
label: Vacation
|
||||
- value: armed_custom_bypass
|
||||
label: Custom bypass
|
||||
disarmed:
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
triggered:
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
@@ -98,5 +98,27 @@
|
||||
"name": "Start conversation"
|
||||
}
|
||||
},
|
||||
"title": "Assist satellite"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
homeassistant/components/assist_satellite/trigger.py
Normal file
140
homeassistant/components/assist_satellite/trigger.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
19
homeassistant/components/assist_satellite/triggers.yaml
Normal file
19
homeassistant/components/assist_satellite/triggers.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
listening:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
|
||||
processing:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
|
||||
responding:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
|
||||
idle:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
@@ -285,5 +285,93 @@
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
"title": "Climate"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
817
homeassistant/components/climate/trigger.py
Normal file
817
homeassistant/components/climate/trigger.py
Normal file
@@ -0,0 +1,817 @@
|
||||
"""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
|
||||
128
homeassistant/components/climate/triggers.yaml
Normal file
128
homeassistant/components/climate/triggers.yaml
Normal file
@@ -0,0 +1,128 @@
|
||||
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
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
import voluptuous as vol
|
||||
@@ -21,6 +21,7 @@ from homeassistant.core import (
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
@@ -52,6 +53,8 @@ from .const import (
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
|
||||
hass.data[DATA_COMPONENT] = entity_component
|
||||
|
||||
agent_config = config.get(DOMAIN, {})
|
||||
await async_setup_default_agent(
|
||||
hass, entity_component, config_intents=agent_config.get("intents", {})
|
||||
)
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
hass_config_path = hass.config.path()
|
||||
config_intents = _get_config_intents(config, hass_config_path)
|
||||
manager.update_config_intents(config_intents)
|
||||
|
||||
await async_setup_default_agent(hass, entity_component)
|
||||
|
||||
async def handle_process(service: ServiceCall) -> ServiceResponse:
|
||||
"""Parse text into commands."""
|
||||
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def handle_reload(service: ServiceCall) -> None:
|
||||
"""Reload intents."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
language = service.data.get(ATTR_LANGUAGE)
|
||||
if language is None:
|
||||
conf = await async_integration_yaml_config(hass, DOMAIN)
|
||||
if conf is not None:
|
||||
config_intents = _get_config_intents(conf, hass_config_path)
|
||||
manager.update_config_intents(config_intents)
|
||||
|
||||
agent = manager.default_agent
|
||||
if agent is not None:
|
||||
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
|
||||
await agent.async_reload(language=language)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
|
||||
"""Return config intents."""
|
||||
intents = config.get(DOMAIN, {}).get("intents", {})
|
||||
return {
|
||||
"intents": {
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in intents.items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
@@ -147,6 +147,7 @@ class AgentManager:
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.config_intents: dict[str, Any] = {}
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
|
||||
@callback
|
||||
@@ -199,9 +200,16 @@ class AgentManager:
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_config_intents(self.config_intents)
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self.config_intents = intents
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_config_intents(intents)
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
|
||||
@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
|
||||
"""Supported features of the conversation entity."""
|
||||
|
||||
CONTROL = 1
|
||||
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
|
||||
@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog
|
||||
from .const import DOMAIN, ConversationEntityFeature
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||
|
||||
ERROR_SENTINEL = object()
|
||||
@@ -202,10 +205,9 @@ class IntentCache:
|
||||
async def async_setup_default_agent(
|
||||
hass: HomeAssistant,
|
||||
entity_component: EntityComponent[ConversationEntity],
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up entity registry listener for the default agent."""
|
||||
agent = DefaultAgent(hass, config_intents)
|
||||
agent = DefaultAgent(hass)
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
|
||||
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
|
||||
_attr_name = "Home Assistant"
|
||||
_attr_supported_features = ConversationEntityFeature.CONTROL
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = config_intents
|
||||
# Intents from common conversation config
|
||||
self._config_intents: dict[str, Any] = {}
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
@callback
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self._config_intents = intents
|
||||
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
async def async_prepare(self, language: str | None = None) -> None:
|
||||
"""Load intents for a language."""
|
||||
if language is None:
|
||||
@@ -1159,33 +1169,10 @@ class DefaultAgent(ConversationEntity):
|
||||
custom_sentences_path,
|
||||
)
|
||||
|
||||
# Load sentences from HA config for default language only
|
||||
if self._config_intents and (
|
||||
self.hass.config.language in (language, language_variant)
|
||||
):
|
||||
hass_config_path = self.hass.config.path()
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
{
|
||||
"intents": {
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in self._config_intents.items()
|
||||
}
|
||||
},
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Loaded intents from configuration.yaml",
|
||||
)
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
self._config_intents,
|
||||
)
|
||||
|
||||
if not intents_dict:
|
||||
return None
|
||||
|
||||
@@ -136,5 +136,75 @@
|
||||
"name": "Toggle tilt"
|
||||
}
|
||||
},
|
||||
"title": "Cover"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
453
homeassistant/components/cover/trigger.py
Normal file
453
homeassistant/components/cover/trigger.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""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
|
||||
101
homeassistant/components/cover/triggers.yaml
Normal file
101
homeassistant/components/cover/triggers.yaml
Normal file
@@ -0,0 +1,101 @@
|
||||
opens:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
fully_opened:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
|
||||
closes:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
fully_closed:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
|
||||
stops:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
|
||||
position_changed:
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
fields:
|
||||
lower:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
upper:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
above:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
below:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
device_class:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
options:
|
||||
- curtain
|
||||
- shutter
|
||||
- blind
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["apyhiveapi"],
|
||||
"requirements": ["pyhive-integration==1.0.6"]
|
||||
"requirements": ["pyhive-integration==1.0.7"]
|
||||
}
|
||||
|
||||
@@ -1237,7 +1237,7 @@
|
||||
"message": "Error obtaining data from the API: {error}"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation temporarily unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"pause_program": {
|
||||
"message": "Error pausing program: {error}"
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiopvapi.resources.model import PowerviewData
|
||||
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||
from aiopvapi.rooms import Rooms
|
||||
from aiopvapi.scenes import Scenes
|
||||
from aiopvapi.shades import Shades
|
||||
@@ -16,7 +17,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
|
||||
from .coordinator import PowerviewShadeUpdateCoordinator
|
||||
from .model import PowerviewConfigEntry, PowerviewEntryData
|
||||
from .shade_data import PowerviewShadeData
|
||||
from .util import async_connect_hub
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
|
||||
from aiopvapi.helpers.aiorequest import PvApiMaintenance
|
||||
from aiopvapi.hub import Hub
|
||||
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||
from aiopvapi.shades import Shades
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import HUB_EXCEPTIONS
|
||||
from .shade_data import PowerviewShadeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
|
||||
async def _async_execute_move(self, move: ShadePosition) -> None:
|
||||
"""Execute a move that can affect multiple positions."""
|
||||
_LOGGER.debug("Move request %s: %s", self.name, move)
|
||||
# Store the requested positions so subsequent move
|
||||
# requests contain the secondary shade positions
|
||||
self.data.update_shade_position(self._shade.id, move)
|
||||
async with self.coordinator.radio_operation_lock:
|
||||
response = await self._shade.move(move)
|
||||
_LOGGER.debug("Move response %s: %s", self.name, response)
|
||||
|
||||
# Process the response from the hub (including new positions)
|
||||
self.data.update_shade_position(self._shade.id, response)
|
||||
|
||||
async def _async_set_cover_position(self, target_hass_position: int) -> None:
|
||||
"""Move the shade to a position."""
|
||||
target_hass_position = self._clamp_cover_limit(target_hass_position)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
from aiopvapi.resources.shade import BaseShade, ShadePosition
|
||||
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import PowerviewShadeUpdateCoordinator
|
||||
from .model import PowerviewDeviceInfo
|
||||
from .shade_data import PowerviewShadeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Shade data for the Hunter Douglas PowerView integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import fields
|
||||
from typing import Any
|
||||
|
||||
from aiopvapi.resources.model import PowerviewData
|
||||
from aiopvapi.resources.shade import BaseShade, ShadePosition
|
||||
|
||||
from .util import async_map_data_by_id
|
||||
|
||||
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
|
||||
|
||||
|
||||
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
|
||||
"""Copy position data from source to target for None values only."""
|
||||
for field in POSITION_FIELDS:
|
||||
if (value := getattr(source, field.name)) is not None:
|
||||
setattr(target, field.name, value)
|
||||
|
||||
|
||||
class PowerviewShadeData:
|
||||
"""Coordinate shade data between multiple api calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init the shade data."""
|
||||
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
|
||||
self._shade_group_data_by_id: dict[int, BaseShade] = {}
|
||||
self.positions: dict[int, ShadePosition] = {}
|
||||
|
||||
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
|
||||
"""Get data for the shade."""
|
||||
return self._raw_data_by_id[shade_id]
|
||||
|
||||
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
|
||||
"""Get data for all shades."""
|
||||
return self._raw_data_by_id
|
||||
|
||||
def get_shade(self, shade_id: int) -> BaseShade:
|
||||
"""Get specific shade from the coordinator."""
|
||||
return self._shade_group_data_by_id[shade_id]
|
||||
|
||||
def get_shade_position(self, shade_id: int) -> ShadePosition:
|
||||
"""Get positions for a shade."""
|
||||
if shade_id not in self.positions:
|
||||
shade_position = ShadePosition()
|
||||
# If we have the group data, use it to populate the initial position
|
||||
if shade := self._shade_group_data_by_id.get(shade_id):
|
||||
copy_position_data(shade.current_position, shade_position)
|
||||
self.positions[shade_id] = shade_position
|
||||
return self.positions[shade_id]
|
||||
|
||||
def update_from_group_data(self, shade_id: int) -> None:
|
||||
"""Process an update from the group data."""
|
||||
data = self._shade_group_data_by_id[shade_id]
|
||||
copy_position_data(data.current_position, self.get_shade_position(data.id))
|
||||
|
||||
def store_group_data(self, shade_data: PowerviewData) -> None:
|
||||
"""Store data from the all shades endpoint.
|
||||
|
||||
This does not update the shades or positions (self.positions)
|
||||
as the data may be stale. update_from_group_data
|
||||
with a shade_id will update a specific shade
|
||||
from the group data.
|
||||
"""
|
||||
self._shade_group_data_by_id = shade_data.processed
|
||||
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
|
||||
|
||||
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
|
||||
"""Update a single shades position."""
|
||||
copy_position_data(new_position, self.get_shade_position(shade_id))
|
||||
|
||||
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
|
||||
"""Update a single shades velocity."""
|
||||
# the hub will always return a velocity of 0 on initial connect,
|
||||
# separate definition to store consistent value in HA
|
||||
# this value is purely driven from HA
|
||||
if shade_data.velocity is not None:
|
||||
self.get_shade_position(shade_id).velocity = shade_data.velocity
|
||||
@@ -2,25 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from aiopvapi.helpers.aiorequest import AioRequest
|
||||
from aiopvapi.helpers.constants import ATTR_ID
|
||||
from aiopvapi.hub import Hub
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .model import PowerviewAPI, PowerviewDeviceInfo
|
||||
|
||||
|
||||
@callback
|
||||
def async_map_data_by_id(data: Iterable[dict[str | int, Any]]):
|
||||
"""Return a dict with the key being the id for a list of entries."""
|
||||
return {entry[ATTR_ID]: entry for entry in data}
|
||||
|
||||
|
||||
async def async_connect_hub(
|
||||
hass: HomeAssistant, address: str, api_version: int | None = None
|
||||
) -> PowerviewAPI:
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,
|
||||
@@ -155,7 +156,8 @@ class HyperionCamera(Camera):
|
||||
"""Update Hyperion components."""
|
||||
if not img:
|
||||
return
|
||||
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
|
||||
# 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)
|
||||
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
|
||||
return
|
||||
async with self._image_cond:
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from propcache.api import cached_property
|
||||
from pyituran import Vehicle
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
|
||||
super().__init__(coordinator, license_plate, description.key)
|
||||
self.entity_description = description
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
|
||||
"""Initialize the device tracker."""
|
||||
super().__init__(coordinator, license_plate, "device_tracker")
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
return self.vehicle.gps_coordinates[0]
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
return self.vehicle.gps_coordinates[1]
|
||||
|
||||
@@ -6,7 +6,6 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from propcache.api import cached_property
|
||||
from pyituran import Vehicle
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
|
||||
super().__init__(coordinator, license_plate, description.key)
|
||||
self.entity_description = description
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the device."""
|
||||
return self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
@@ -462,5 +462,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Light"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
288
homeassistant/components/light/trigger.py
Normal file
288
homeassistant/components/light/trigger.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""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
|
||||
43
homeassistant/components/light/triggers.yaml
Normal file
43
homeassistant/components/light/triggers.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
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
|
||||
@@ -353,17 +353,13 @@ DISCOVERY_SCHEMAS = [
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
# DeviceFault or SupplyFault bit enabled
|
||||
device_to_ha={
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kLocalOverride: False,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning: False,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemotePressure: False,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteFlow: False,
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteTemperature: False,
|
||||
}.get,
|
||||
device_to_ha=lambda x: bool(
|
||||
x
|
||||
& (
|
||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
|
||||
| clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
|
||||
)
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(
|
||||
@@ -377,9 +373,9 @@ DISCOVERY_SCHEMAS = [
|
||||
key="PumpStatusRunning",
|
||||
translation_key="pump_running",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
device_to_ha=lambda x: (
|
||||
device_to_ha=lambda x: bool(
|
||||
x
|
||||
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
|
||||
& clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
@@ -395,8 +391,8 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="dishwasher_alarm_inflow",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_to_ha=lambda x: (
|
||||
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
|
||||
device_to_ha=lambda x: bool(
|
||||
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
@@ -410,8 +406,8 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="alarm_door",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_to_ha=lambda x: (
|
||||
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
|
||||
device_to_ha=lambda x: bool(
|
||||
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
@@ -481,8 +477,8 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="alarm_door",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_to_ha=lambda x: (
|
||||
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
|
||||
device_to_ha=lambda x: bool(
|
||||
x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
|
||||
@@ -367,5 +367,63 @@
|
||||
"name": "Turn up volume"
|
||||
}
|
||||
},
|
||||
"title": "Media player"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
676
homeassistant/components/media_player/trigger.py
Normal file
676
homeassistant/components/media_player/trigger.py
Normal file
@@ -0,0 +1,676 @@
|
||||
"""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
|
||||
65
homeassistant/components/media_player/triggers.yaml
Normal file
65
homeassistant/components/media_player/triggers.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
@@ -1009,7 +1009,7 @@
|
||||
"cleaning_care_program": "Cleaning/care program",
|
||||
"maintenance_program": "Maintenance program",
|
||||
"normal_operation_mode": "Normal operation mode",
|
||||
"own_program": "Own program"
|
||||
"own_program": "Program"
|
||||
}
|
||||
},
|
||||
"remaining_time": {
|
||||
@@ -1089,7 +1089,7 @@
|
||||
"message": "Invalid device targeted."
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"set_program_error": {
|
||||
"message": "'Set program' action failed: {status} / {message}"
|
||||
|
||||
@@ -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 SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
@@ -21,21 +21,14 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
DEFAULT_URL = "http://mass.local:8095"
|
||||
DEFAULT_TITLE = "Music Assistant"
|
||||
DEFAULT_URL = "http://mass.local:8095"
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_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)
|
||||
@@ -52,25 +45,17 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up flow instance."""
|
||||
self.server_info: ServerInfoMessage | None = None
|
||||
self.url: str | 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:
|
||||
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,
|
||||
)
|
||||
server_info = await _get_server_info(self.hass, user_input[CONF_URL])
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidServerVersion:
|
||||
@@ -79,68 +64,49 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={
|
||||
CONF_URL: user_input[CONF_URL],
|
||||
},
|
||||
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_show_form(
|
||||
step_id="user", data_schema=get_manual_schema(user_input), errors=errors
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={CONF_URL: user_input[CONF_URL]},
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=get_manual_schema({}))
|
||||
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=self.add_suggested_values_to_schema(
|
||||
STEP_USER_SCHEMA, suggested_values
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""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
|
||||
"""Handle a zeroconf discovery for a Music Assistant server."""
|
||||
try:
|
||||
await get_server_info(self.hass, self.server_info.base_url)
|
||||
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)
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
@@ -148,16 +114,16 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user-confirmation of discovered server."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.server_info is not None
|
||||
assert self.url is not None
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={
|
||||
CONF_URL: self.server_info.base_url,
|
||||
},
|
||||
data={CONF_URL: self.url},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"url": self.server_info.base_url},
|
||||
description_placeholders={"url": self.url},
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "music_assistant",
|
||||
"name": "Music Assistant",
|
||||
"after_dependencies": ["media_source", "media_player"],
|
||||
"codeowners": ["@music-assistant"],
|
||||
"codeowners": ["@music-assistant", "@arturpragacz"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"message": "Error while loading the integration."
|
||||
},
|
||||
"implementation_unavailable": {
|
||||
"message": "OAuth2 implementation is not available, will retry."
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"incorrect_oauth2_scope": {
|
||||
"message": "Stored permissions are invalid. Please login again to update permissions."
|
||||
|
||||
@@ -20,10 +20,11 @@ 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_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
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.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -73,17 +74,19 @@ 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."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, 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
|
||||
|
||||
# Set unique id if non was set (migration)
|
||||
if not entry.unique_id:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
|
||||
@@ -143,6 +143,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"public_weather": {
|
||||
|
||||
@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
|
||||
schema_errors |= validate_table_schema_supports_utf8(
|
||||
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
|
||||
)
|
||||
schema_errors |= validate_table_schema_has_correct_collation(
|
||||
instance, StatisticsMeta
|
||||
)
|
||||
for table in (Statistics, StatisticsShortTerm):
|
||||
schema_errors |= validate_db_schema_precision(instance, table)
|
||||
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
||||
|
||||
@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
|
||||
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
|
||||
STATES_META_SCHEMA_VERSION = 38
|
||||
CIRCULAR_MEAN_SCHEMA_VERSION = 49
|
||||
UNIT_CLASS_SCHEMA_VERSION = 51
|
||||
UNIT_CLASS_SCHEMA_VERSION = 52
|
||||
|
||||
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
|
||||
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
|
||||
|
||||
@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
|
||||
"""Base class for tables, used for schema migration."""
|
||||
|
||||
|
||||
SCHEMA_VERSION = 51
|
||||
SCHEMA_VERSION = 52
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
|
||||
from sqlalchemy import (
|
||||
ForeignKeyConstraint,
|
||||
MetaData,
|
||||
Table,
|
||||
cast as cast_,
|
||||
func,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.engine import CursorResult, Engine
|
||||
from sqlalchemy.exc import (
|
||||
DatabaseError,
|
||||
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
|
||||
from sqlalchemy.sql.expression import true
|
||||
from sqlalchemy.sql.expression import and_, true
|
||||
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
||||
from sqlalchemy.types import BINARY
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
@@ -2044,14 +2053,74 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
|
||||
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
|
||||
def _apply_update(self) -> None:
|
||||
"""Version specific update method."""
|
||||
# Add unit class column to StatisticsMeta
|
||||
# Replaced with version 52 which corrects issues with MySQL string comparisons.
|
||||
|
||||
|
||||
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
|
||||
def _apply_update(self) -> None:
|
||||
"""Version specific update method."""
|
||||
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||
self._apply_update_mysql()
|
||||
else:
|
||||
self._apply_update_postgresql_sqlite()
|
||||
|
||||
def _apply_update_mysql(self) -> None:
|
||||
"""Version specific update method for mysql."""
|
||||
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
||||
with session_scope(session=self.session_maker()) as session:
|
||||
connection = session.connection()
|
||||
for conv in _PRIMARY_UNIT_CONVERTERS:
|
||||
case_sensitive_units = {
|
||||
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
|
||||
}
|
||||
# Reset unit_class to None for entries that do not match
|
||||
# the valid units (case sensitive) but matched before due to
|
||||
# case insensitive comparisons.
|
||||
connection.execute(
|
||||
update(StatisticsMeta)
|
||||
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS))
|
||||
.where(
|
||||
and_(
|
||||
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
|
||||
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
|
||||
case_sensitive_units
|
||||
),
|
||||
)
|
||||
)
|
||||
.values(unit_class=None)
|
||||
)
|
||||
# Do an explicitly case sensitive match (actually binary) to set the
|
||||
# correct unit_class. This is needed because we use the case sensitive
|
||||
# utf8mb4_unicode_ci collation.
|
||||
connection.execute(
|
||||
update(StatisticsMeta)
|
||||
.where(
|
||||
and_(
|
||||
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
|
||||
case_sensitive_units
|
||||
),
|
||||
StatisticsMeta.unit_class.is_(None),
|
||||
)
|
||||
)
|
||||
.values(unit_class=conv.UNIT_CLASS)
|
||||
)
|
||||
|
||||
def _apply_update_postgresql_sqlite(self) -> None:
|
||||
"""Version specific update method for postgresql and sqlite."""
|
||||
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
||||
with session_scope(session=self.session_maker()) as session:
|
||||
connection = session.connection()
|
||||
for conv in _PRIMARY_UNIT_CONVERTERS:
|
||||
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
|
||||
# have case sensitive string comparisons by default, so we
|
||||
# can directly match on the valid units.
|
||||
connection.execute(
|
||||
update(StatisticsMeta)
|
||||
.where(
|
||||
and_(
|
||||
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
|
||||
StatisticsMeta.unit_class.is_(None),
|
||||
)
|
||||
)
|
||||
.values(unit_class=conv.UNIT_CLASS)
|
||||
)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
QUERY_STATISTIC_META = (
|
||||
QUERY_STATISTICS_META = (
|
||||
StatisticsMeta.id,
|
||||
StatisticsMeta.statistic_id,
|
||||
StatisticsMeta.source,
|
||||
@@ -55,7 +55,7 @@ def _generate_get_metadata_stmt(
|
||||
|
||||
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
|
||||
"""
|
||||
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
|
||||
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
|
||||
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
|
||||
columns.append(StatisticsMeta.mean_type)
|
||||
else:
|
||||
|
||||
@@ -12,10 +12,11 @@ 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_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
httpx_client,
|
||||
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.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -28,19 +29,21 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||
|
||||
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up SENZ from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
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)
|
||||
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
|
||||
senz_api = SENZAPI(auth)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class SENZClimate(CoordinatorEntity, ClimateEntity):
|
||||
class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
|
||||
"""Representation of a SENZ climate entity."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
29
homeassistant/components/senz/diagnostics.py
Normal file
29
homeassistant/components/senz/diagnostics.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""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,
|
||||
}
|
||||
93
homeassistant/components/senz/sensor.py
Normal file
93
homeassistant/components/senz/sensor.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""nVent RAYCHEM SENZ sensor platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiosenz import Thermostat
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import SENZDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class SenzSensorDescription(SensorEntityDescription):
|
||||
"""Describes SENZ sensor entity."""
|
||||
|
||||
value_fn: Callable[[Thermostat], str | int | float | None]
|
||||
|
||||
|
||||
SENSORS: tuple[SenzSensorDescription, ...] = (
|
||||
SenzSensorDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda data: data.current_temperatue,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the SENZ sensor entities from a config entry."""
|
||||
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
SENZSensor(thermostat, coordinator, description)
|
||||
for description in SENSORS
|
||||
for thermostat in coordinator.data.values()
|
||||
)
|
||||
|
||||
|
||||
class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of a SENZ sensor entity."""
|
||||
|
||||
entity_description: SenzSensorDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thermostat: Thermostat,
|
||||
coordinator: SENZDataUpdateCoordinator,
|
||||
description: SenzSensorDescription,
|
||||
) -> None:
|
||||
"""Init SENZ sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._thermostat = thermostat
|
||||
self._attr_unique_id = f"{thermostat.serial_number}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, thermostat.serial_number)},
|
||||
manufacturer="nVent Raychem",
|
||||
model="SENZ WIFI",
|
||||
name=thermostat.name,
|
||||
serial_number=thermostat.serial_number,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the thermostat is available."""
|
||||
return super().available and self._thermostat.online
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | float | int | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self._thermostat)
|
||||
@@ -25,5 +25,10 @@
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
|
||||
|
||||
from homeassistant.components.number import (
|
||||
DOMAIN as NUMBER_PLATFORM,
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberExtraStoredData,
|
||||
@@ -107,6 +108,9 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
|
||||
if description.mode_fn is not None:
|
||||
self._attr_mode = description.mode_fn(coordinator.device.config[key])
|
||||
|
||||
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return value of number."""
|
||||
@@ -181,7 +185,6 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
|
||||
("device", "valvePos"): BlockNumberDescription(
|
||||
key="device|valvepos",
|
||||
translation_key="valve_position",
|
||||
name="Valve position",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
available=lambda block: cast(int, block.valveError) != 1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -200,12 +203,12 @@ RPC_NUMBERS: Final = {
|
||||
key="blutrv",
|
||||
sub_key="current_C",
|
||||
translation_key="external_temperature",
|
||||
name="External temperature",
|
||||
native_min_value=-50,
|
||||
native_max_value=50,
|
||||
native_step=0.1,
|
||||
mode=NumberMode.BOX,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
method="blu_trv_set_external_temperature",
|
||||
entity_class=RpcBluTrvExtTempNumber,
|
||||
@@ -213,7 +216,7 @@ RPC_NUMBERS: Final = {
|
||||
"number_generic": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
removal_condition=lambda config, _, key: not is_view_for_platform(
|
||||
config, key, NUMBER_PLATFORM
|
||||
),
|
||||
max_fn=lambda config: config["max"],
|
||||
@@ -229,9 +232,11 @@ RPC_NUMBERS: Final = {
|
||||
"number_current_limit": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
translation_key="current_limit",
|
||||
device_class=NumberDeviceClass.CURRENT,
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
mode_fn=lambda _: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
@@ -241,10 +246,11 @@ RPC_NUMBERS: Final = {
|
||||
"number_position": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
translation_key="valve_position",
|
||||
entity_registry_enabled_default=False,
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
mode_fn=lambda _: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
@@ -254,10 +260,12 @@ RPC_NUMBERS: Final = {
|
||||
"number_target_humidity": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
translation_key="target_humidity",
|
||||
device_class=NumberDeviceClass.HUMIDITY,
|
||||
entity_registry_enabled_default=False,
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
mode_fn=lambda _: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
@@ -267,10 +275,12 @@ RPC_NUMBERS: Final = {
|
||||
"number_target_temperature": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
translation_key="target_temperature",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_registry_enabled_default=False,
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
mode_fn=lambda _: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
@@ -281,21 +291,20 @@ RPC_NUMBERS: Final = {
|
||||
key="blutrv",
|
||||
sub_key="pos",
|
||||
translation_key="valve_position",
|
||||
name="Valve position",
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
mode=NumberMode.SLIDER,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
method="blu_trv_set_valve_position",
|
||||
removal_condition=lambda config, _status, key: config[key].get("enable", True)
|
||||
removal_condition=lambda config, _, key: config[key].get("enable", True)
|
||||
is True,
|
||||
entity_class=RpcBluTrvNumber,
|
||||
),
|
||||
"left_slot_intensity": RpcNumberDescription(
|
||||
key="cury",
|
||||
sub_key="slots",
|
||||
name="Left slot intensity",
|
||||
translation_key="left_slot_intensity",
|
||||
value=lambda status, _: status["left"]["intensity"],
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
@@ -311,7 +320,7 @@ RPC_NUMBERS: Final = {
|
||||
"right_slot_intensity": RpcNumberDescription(
|
||||
key="cury",
|
||||
sub_key="slots",
|
||||
name="Right slot intensity",
|
||||
translation_key="right_slot_intensity",
|
||||
value=lambda status, _: status["right"]["intensity"],
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
@@ -402,6 +411,9 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
|
||||
self.restored_data: NumberExtraStoredData | None = None
|
||||
super().__init__(coordinator, block, attribute, description, entry)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -188,6 +188,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"current_limit": {
|
||||
"name": "Current limit"
|
||||
},
|
||||
"external_temperature": {
|
||||
"name": "External temperature"
|
||||
},
|
||||
"left_slot_intensity": {
|
||||
"name": "Left slot intensity"
|
||||
},
|
||||
"right_slot_intensity": {
|
||||
"name": "Right slot intensity"
|
||||
},
|
||||
"target_humidity": {
|
||||
"name": "Target humidity"
|
||||
},
|
||||
"target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"valve_position": {
|
||||
"name": "Valve position"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"cury_mode": {
|
||||
"name": "Mode",
|
||||
|
||||
@@ -75,6 +75,7 @@ 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],
|
||||
@@ -102,6 +103,10 @@ 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,
|
||||
@@ -119,6 +124,7 @@ 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,
|
||||
@@ -136,6 +142,7 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
140
homeassistant/components/switchbot/climate.py
Normal file
140
homeassistant/components/switchbot/climate.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Support for Switchbot Climate devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import switchbot
|
||||
from switchbot import (
|
||||
ClimateAction as SwitchBotClimateAction,
|
||||
ClimateMode as SwitchBotClimateMode,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import SwitchbotConfigEntry
|
||||
from .entity import SwitchbotEntity, exception_handler
|
||||
|
||||
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE = {
|
||||
SwitchBotClimateMode.HEAT: HVACMode.HEAT,
|
||||
SwitchBotClimateMode.OFF: HVACMode.OFF,
|
||||
}
|
||||
|
||||
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE = {
|
||||
HVACMode.HEAT: SwitchBotClimateMode.HEAT,
|
||||
HVACMode.OFF: SwitchBotClimateMode.OFF,
|
||||
}
|
||||
|
||||
SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION = {
|
||||
SwitchBotClimateAction.HEATING: HVACAction.HEATING,
|
||||
SwitchBotClimateAction.IDLE: HVACAction.IDLE,
|
||||
SwitchBotClimateAction.OFF: HVACAction.OFF,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SwitchbotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Switchbot climate based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([SwitchBotClimateEntity(coordinator)])
|
||||
|
||||
|
||||
class SwitchBotClimateEntity(SwitchbotEntity, ClimateEntity):
|
||||
"""Representation of a Switchbot Climate device."""
|
||||
|
||||
_device: switchbot.SwitchbotDevice
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_target_temperature_step = 0.5
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "climate"
|
||||
_attr_name = None
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature."""
|
||||
return self._device.min_temperature
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature."""
|
||||
return self._device.max_temperature
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return the list of available preset modes."""
|
||||
return self._device.preset_modes
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return self._device.preset_mode
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
return SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE.get(
|
||||
self._device.hvac_mode, HVACMode.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available HVAC modes."""
|
||||
return [
|
||||
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE[mode]
|
||||
for mode in self._device.hvac_modes
|
||||
]
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current HVAC action."""
|
||||
return SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION.get(
|
||||
self._device.hvac_action, HVACAction.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._device.current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._device.target_temperature
|
||||
|
||||
@exception_handler
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new HVAC mode."""
|
||||
return await self._device.set_hvac_mode(
|
||||
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE[hvac_mode]
|
||||
)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
return await self._device.set_preset_mode(preset_mode)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
return await self._device.set_target_temperature(temperature)
|
||||
@@ -58,6 +58,8 @@ 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 = {
|
||||
@@ -78,6 +80,7 @@ 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,
|
||||
@@ -95,6 +98,7 @@ 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 = {
|
||||
@@ -132,6 +136,7 @@ ENCRYPTED_MODELS = {
|
||||
SwitchbotModel.PLUG_MINI_EU,
|
||||
SwitchbotModel.RELAY_SWITCH_2PM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
|
||||
}
|
||||
|
||||
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
@@ -153,6 +158,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
|
||||
SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator,
|
||||
}
|
||||
|
||||
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"climate": {
|
||||
"climate": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"manual": "mdi:hand-back-right",
|
||||
"off": "mdi:hvac-off",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_purifier": {
|
||||
"default": "mdi:air-purifier",
|
||||
|
||||
@@ -100,6 +100,19 @@
|
||||
"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": {
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"abort": {
|
||||
"already_configured": "Chat already configured"
|
||||
},
|
||||
"entry_type": "Allowed chat ID",
|
||||
"error": {
|
||||
"chat_not_found": "Chat not found"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
)
|
||||
@@ -61,6 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
except ValueError as e:
|
||||
# Remove invalid implementation from config entry then raise AuthFailed
|
||||
hass.config_entries.async_update_entry(
|
||||
|
||||
@@ -609,6 +609,9 @@
|
||||
"no_cable": {
|
||||
"message": "Charge cable will lock automatically when connected"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "{endpoint} data request failed: {message}"
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ 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,
|
||||
)
|
||||
@@ -86,7 +88,13 @@ 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."""
|
||||
implementation = await async_get_config_entry_implementation(hass, 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)
|
||||
|
||||
coordinator = ToonDataUpdateCoordinator(hass, entry, session)
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"update": {
|
||||
"description": "Updates all entities with fresh data from Toon.",
|
||||
|
||||
@@ -181,15 +181,14 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
|
||||
HVACMode.HEAT if self._thermostat_module.state else HVACMode.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
|
||||
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
|
||||
return True
|
||||
|
||||
self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode]
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
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 HomeAssistantError
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
from .const import (
|
||||
@@ -23,7 +24,7 @@ from .const import (
|
||||
SERVICE_START_TORRENT,
|
||||
SERVICE_STOP_TORRENT,
|
||||
)
|
||||
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
|
||||
from .coordinator import TransmissionDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,45 +68,52 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All(
|
||||
|
||||
|
||||
def _get_coordinator_from_service_data(
|
||||
hass: HomeAssistant, entry_id: str
|
||||
call: ServiceCall,
|
||||
) -> TransmissionDataUpdateCoordinator:
|
||||
"""Return coordinator for entry id."""
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
async def _async_add_torrent(service: ServiceCall) -> None:
|
||||
"""Add new torrent to download."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent: str = service.data[ATTR_TORRENT]
|
||||
download_path: str | None = service.data.get(ATTR_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()
|
||||
|
||||
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)
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("Could not add torrent: unsupported type or no permission")
|
||||
await service.hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def _async_start_torrent(service: ServiceCall) -> None:
|
||||
"""Start torrent."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
await service.hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id)
|
||||
await coordinator.async_request_refresh()
|
||||
@@ -113,8 +121,7 @@ async def _async_start_torrent(service: ServiceCall) -> None:
|
||||
|
||||
async def _async_stop_torrent(service: ServiceCall) -> None:
|
||||
"""Stop torrent."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
await service.hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id)
|
||||
await coordinator.async_request_refresh()
|
||||
@@ -122,8 +129,7 @@ async def _async_stop_torrent(service: ServiceCall) -> None:
|
||||
|
||||
async def _async_remove_torrent(service: ServiceCall) -> None:
|
||||
"""Remove torrent."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
delete_data = service.data[ATTR_DELETE_DATA]
|
||||
await service.hass.async_add_executor_job(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
add_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
@@ -18,6 +19,7 @@ add_torrent:
|
||||
remove_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
@@ -27,6 +29,7 @@ remove_torrent:
|
||||
selector:
|
||||
text:
|
||||
delete_data:
|
||||
required: true
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
@@ -34,17 +37,20 @@ remove_torrent:
|
||||
start_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
id:
|
||||
example: 123
|
||||
selector:
|
||||
text:
|
||||
|
||||
stop_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
id:
|
||||
required: true
|
||||
example: 123
|
||||
selector:
|
||||
text:
|
||||
|
||||
stop_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
|
||||
@@ -87,6 +87,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -19,9 +19,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import EnumTypeData, find_dpcode
|
||||
from .models import DPCodeEnumWrapper
|
||||
from .util import get_dpcode
|
||||
|
||||
|
||||
@@ -85,9 +85,21 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := ALARM.get(device.category):
|
||||
entities.extend(
|
||||
TuyaAlarmEntity(device, manager, description)
|
||||
TuyaAlarmEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
action_dpcode_wrapper=action_dpcode_wrapper,
|
||||
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, description.master_state
|
||||
),
|
||||
)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -103,7 +115,6 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
|
||||
_attr_name = None
|
||||
_attr_code_arm_required = False
|
||||
_master_state: EnumTypeData | None = None
|
||||
_alarm_msg_dpcode: DPCode | None = None
|
||||
|
||||
def __init__(
|
||||
@@ -111,33 +122,24 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: TuyaAlarmControlPanelEntityDescription,
|
||||
*,
|
||||
action_dpcode_wrapper: DPCodeEnumWrapper,
|
||||
state_dpcode_wrapper: DPCodeEnumWrapper | None,
|
||||
) -> None:
|
||||
"""Init Tuya Alarm."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._action_dpcode_wrapper = action_dpcode_wrapper
|
||||
self._state_dpcode_wrapper = state_dpcode_wrapper
|
||||
|
||||
# Determine supported modes
|
||||
if supported_modes := find_dpcode(
|
||||
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
if Mode.HOME in supported_modes.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
|
||||
if Mode.ARM in supported_modes.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
if Mode.SOS in supported_modes.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
|
||||
# Determine master state
|
||||
if enum_type := find_dpcode(
|
||||
self.device,
|
||||
description.master_state,
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
):
|
||||
self._master_state = enum_type
|
||||
if Mode.HOME in action_dpcode_wrapper.type_information.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if Mode.ARM in action_dpcode_wrapper.type_information.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if Mode.SOS in action_dpcode_wrapper.type_information.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
|
||||
# Determine alarm message
|
||||
if dp_code := get_dpcode(self.device, description.alarm_msg):
|
||||
@@ -149,8 +151,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
|
||||
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
|
||||
if (
|
||||
self._master_state is not None
|
||||
and self.device.status.get(self._master_state.dpcode) == State.ALARM
|
||||
self._state_dpcode_wrapper is not None
|
||||
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
|
||||
):
|
||||
# Only report as triggered if NOT a battery warning
|
||||
if (
|
||||
@@ -166,28 +168,26 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
def changed_by(self) -> str | None:
|
||||
"""Last change triggered by."""
|
||||
if (
|
||||
self._master_state is not None
|
||||
self._state_dpcode_wrapper is not None
|
||||
and self._alarm_msg_dpcode is not None
|
||||
and self.device.status.get(self._master_state.dpcode) == State.ALARM
|
||||
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
|
||||
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
|
||||
):
|
||||
return b64decode(encoded_msg).decode("utf-16be")
|
||||
return None
|
||||
|
||||
def alarm_disarm(self, code: str | None = None) -> None:
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send Disarm command."""
|
||||
self._send_command(
|
||||
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
|
||||
)
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
|
||||
|
||||
def alarm_arm_home(self, code: str | None = None) -> None:
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send Home command."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}])
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
|
||||
|
||||
def alarm_arm_away(self, code: str | None = None) -> None:
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send Arm command."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}])
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
|
||||
|
||||
def alarm_trigger(self, code: str | None = None) -> None:
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Send SOS command."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}])
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)
|
||||
|
||||
@@ -6,13 +6,14 @@ 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 remap_value
|
||||
from .util import parse_dptype, remap_value
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -134,6 +135,8 @@ _TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
DPType.BOOLEAN: TypeInformation,
|
||||
DPType.ENUM: EnumTypeData,
|
||||
DPType.INTEGER: IntegerTypeData,
|
||||
DPType.JSON: TypeInformation,
|
||||
DPType.RAW: TypeInformation,
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +147,9 @@ 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
|
||||
@@ -196,7 +202,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
def find_dpcode(
|
||||
cls,
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...],
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
) -> Self | None:
|
||||
@@ -210,6 +216,20 @@ 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.
|
||||
|
||||
@@ -235,6 +255,18 @@ 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."""
|
||||
|
||||
@@ -268,6 +300,11 @@ 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.
|
||||
|
||||
@@ -352,6 +389,16 @@ 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,
|
||||
@@ -381,7 +428,7 @@ def find_dpcode(
|
||||
for device_specs in lookup_tuple:
|
||||
if (
|
||||
(current_definition := device_specs.get(dpcode))
|
||||
and current_definition.type == dptype
|
||||
and parse_dptype(current_definition.type) is dptype
|
||||
and (
|
||||
type_information := type_information_cls.from_json(
|
||||
dpcode, current_definition.values
|
||||
@@ -391,44 +438,3 @@ def find_dpcode(
|
||||
return type_information
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ComplexValue:
|
||||
"""Complex value (for JSON/RAW parsing)."""
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> Self:
|
||||
"""Load JSON string and return a ComplexValue object."""
|
||||
raise NotImplementedError("from_json is not implemented for this type")
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data: str) -> Self | None:
|
||||
"""Decode base64 string and return a ComplexValue object."""
|
||||
raise NotImplementedError("from_raw is not implemented for this type")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElectricityValue(ComplexValue):
|
||||
"""Electricity complex value."""
|
||||
|
||||
electriccurrent: str | None = None
|
||||
power: str | None = None
|
||||
voltage: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> Self:
|
||||
"""Load JSON string and return a ElectricityValue object."""
|
||||
return cls(**json.loads(data.lower()))
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data: str) -> Self | None:
|
||||
"""Decode base64 string and return a ElectricityValue object."""
|
||||
raw = base64.b64decode(data)
|
||||
if len(raw) == 0:
|
||||
return None
|
||||
voltage = struct.unpack(">H", raw[0:2])[0] / 10.0
|
||||
electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0
|
||||
power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0
|
||||
return cls(
|
||||
electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage)
|
||||
)
|
||||
|
||||
@@ -502,14 +502,19 @@ 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.type_information.unit
|
||||
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."""
|
||||
|
||||
# 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 description.native_unit_of_measurement is None
|
||||
and self.entity_description.native_unit_of_measurement is None
|
||||
# we do not need to check mappings if the API UOM is allowed
|
||||
and self.native_unit_of_measurement
|
||||
not in NUMBER_DEVICE_CLASS_UNITS[self.device_class]
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import struct
|
||||
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -42,41 +41,134 @@ from .const import (
|
||||
)
|
||||
from .entity import TuyaEntity
|
||||
from .models import (
|
||||
ComplexValue,
|
||||
ElectricityValue,
|
||||
DPCodeBase64Wrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
DPCodeTypeInformationWrapper,
|
||||
DPCodeWrapper,
|
||||
EnumTypeData,
|
||||
IntegerTypeData,
|
||||
find_dpcode,
|
||||
)
|
||||
from .util import get_dptype
|
||||
|
||||
_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,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Tuya sensor entity."""
|
||||
|
||||
complex_type: type[ComplexValue] | None = None
|
||||
subkey: str | None = None
|
||||
state_conversion: Callable[[Any], StateType] | None = None
|
||||
dpcode: DPCode | None = None
|
||||
wrapper_class: tuple[type[DPCodeTypeInformationWrapper], ...] | None = None
|
||||
|
||||
|
||||
# Commonly used battery sensors, that are reused in the sensors down below.
|
||||
@@ -394,85 +486,76 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}electriccurrent",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}power",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}voltage",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}electriccurrent",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}power",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}voltage",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}electriccurrent",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}power",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}voltage",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.CUR_CURRENT,
|
||||
@@ -972,7 +1055,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
translation_key="wind_direction",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)),
|
||||
wrapper_class=(_WindDirectionWrapper,),
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.DEW_POINT_TEMP,
|
||||
@@ -1485,12 +1568,11 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.TOTAL_POWER,
|
||||
key=f"{DPCode.TOTAL_POWER}power",
|
||||
dpcode=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,
|
||||
@@ -1500,85 +1582,76 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}electriccurrent",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}power",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}voltage",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}electriccurrent",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}power",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}voltage",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}electriccurrent",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}power",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}voltage",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
),
|
||||
DeviceCategory.ZNNBQ: (
|
||||
@@ -1639,6 +1712,27 @@ 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,
|
||||
@@ -1655,9 +1749,9 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := SENSORS.get(device.category):
|
||||
entities.extend(
|
||||
TuyaSensorEntity(device, manager, description)
|
||||
TuyaSensorEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -1673,35 +1767,25 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
|
||||
"""Tuya Sensor Entity."""
|
||||
|
||||
entity_description: TuyaSensorEntityDescription
|
||||
|
||||
_type: DPType | None = None
|
||||
_type_data: IntegerTypeData | EnumTypeData | None = None
|
||||
_dpcode_wrapper: DPCodeWrapper
|
||||
|
||||
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}{description.subkey or ''}"
|
||||
)
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
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))
|
||||
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
|
||||
|
||||
self._validate_device_class_unit()
|
||||
|
||||
@@ -1752,55 +1836,4 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
# 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
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBooleanWrapper
|
||||
|
||||
SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
|
||||
DeviceCategory.CO2BJ: (
|
||||
@@ -64,9 +65,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := SIRENS.get(device.category):
|
||||
entities.extend(
|
||||
TuyaSirenEntity(device, manager, description)
|
||||
TuyaSirenEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -89,21 +94,23 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: SirenEntityDescription,
|
||||
dpcode_wrapper: DPCodeBooleanWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya Siren."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if siren is on."""
|
||||
return self.device.status.get(self.entity_description.key, False)
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the siren on."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": True}])
|
||||
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the siren off."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": False}])
|
||||
await self._async_send_dpcode_update(self._dpcode_wrapper, False)
|
||||
|
||||
@@ -42,6 +42,16 @@ 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:
|
||||
@@ -57,13 +67,7 @@ def get_dptype(
|
||||
|
||||
for device_specs in lookup_tuple:
|
||||
if current_definition := device_specs.get(dpcode):
|
||||
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 parse_dptype(current_definition.type)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
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."""
|
||||
@@ -30,3 +40,56 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||
|
||||
if config_entry.version == 1:
|
||||
# Version 1 had states as first-class selections
|
||||
# Version 2 only allows states w/o districts, districts and communities
|
||||
region_id = config_entry.data[CONF_REGION]
|
||||
|
||||
websession = async_get_clientsession(hass)
|
||||
try:
|
||||
regions_data = await Client(websession).get_regions()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
_LOGGER.warning(
|
||||
"Could not migrate config entry %s: failed to fetch current regions: %s",
|
||||
config_entry.entry_id,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(regions_data, dict)
|
||||
|
||||
state_with_districts = None
|
||||
for state in regions_data["states"]:
|
||||
if state["regionId"] == region_id and state.get("regionChildIds"):
|
||||
state_with_districts = state
|
||||
break
|
||||
|
||||
if state_with_districts:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_state_region_{config_entry.entry_id}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_state_region",
|
||||
translation_placeholders={
|
||||
"region_name": config_entry.data.get(CONF_NAME, region_id),
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
hass.config_entries.async_update_entry(config_entry, version=2)
|
||||
_LOGGER.info("Migration to version %s successful", 2)
|
||||
return True
|
||||
|
||||
_LOGGER.error("Unknown version %s", config_entry.version)
|
||||
return False
|
||||
|
||||
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Ukraine Alarm."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
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:
|
||||
if self.selected_region and step_id != "district":
|
||||
regions[self.selected_region["regionId"]] = self.selected_region[
|
||||
"regionName"
|
||||
]
|
||||
|
||||
@@ -13,19 +13,19 @@
|
||||
"data": {
|
||||
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
|
||||
},
|
||||
"description": "If you want to monitor not only state and district, choose its specific community"
|
||||
"description": "Choose the district you selected above or select a specific community within that district"
|
||||
},
|
||||
"district": {
|
||||
"data": {
|
||||
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
|
||||
},
|
||||
"description": "If you want to monitor not only state, choose its specific district"
|
||||
"description": "Choose a district to monitor within the selected state"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"region": "Region"
|
||||
},
|
||||
"description": "Choose state to monitor"
|
||||
"description": "Choose a state"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -50,5 +50,11 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"velbus-protocol"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["velbus-aio==2025.8.0"],
|
||||
"requirements": ["velbus-aio==2025.11.0"],
|
||||
"usb": [
|
||||
{
|
||||
"pid": "0B1B",
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""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: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
|
||||
"""Set up the velux component."""
|
||||
host = entry.data[CONF_HOST]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
@@ -27,12 +30,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
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(
|
||||
@@ -46,6 +76,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -24,14 +24,14 @@ SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: VeluxConfigEntry,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up rain sensor(s) for Velux platform."""
|
||||
pyvlx = config.runtime_data
|
||||
pyvlx = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
VeluxRainSensor(node, config.entry_id)
|
||||
VeluxRainSensor(node, config_entry.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, Window) and node.rain_sensor
|
||||
)
|
||||
|
||||
52
homeassistant/components/velux/button.py
Normal file
52
homeassistant/components/velux/button.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Support for VELUX KLF 200 gateway button."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyvlx import PyVLX, PyVLXException
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VeluxConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up button entities for the Velux integration."""
|
||||
async_add_entities(
|
||||
[VeluxGatewayRebootButton(config_entry.entry_id, config_entry.runtime_data)]
|
||||
)
|
||||
|
||||
|
||||
class VeluxGatewayRebootButton(ButtonEntity):
|
||||
"""Representation of the Velux Gateway reboot button."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = ButtonDeviceClass.RESTART
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self, config_entry_id: str, pyvlx: PyVLX) -> None:
|
||||
"""Initialize the gateway reboot button."""
|
||||
self.pyvlx = pyvlx
|
||||
self._attr_unique_id = f"{config_entry_id}_reboot-gateway"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"gateway_{config_entry_id}")},
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press - reboot the gateway."""
|
||||
try:
|
||||
await self.pyvlx.reboot_gateway()
|
||||
except PyVLXException as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="reboot_failed",
|
||||
) from ex
|
||||
@@ -85,7 +85,7 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
updates={CONF_HOST: self.discovery_data[CONF_HOST]}
|
||||
)
|
||||
|
||||
# Abort if config_entry already exists without unigue_id configured.
|
||||
# Abort if config_entry already exists without unique_id configured.
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if (
|
||||
entry.data[CONF_HOST] == self.discovery_data[CONF_HOST]
|
||||
|
||||
@@ -5,5 +5,11 @@ from logging import getLogger
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "velux"
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE]
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SCENE,
|
||||
]
|
||||
LOGGER = getLogger(__package__)
|
||||
|
||||
@@ -32,13 +32,13 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: VeluxConfigEntry,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up cover(s) for Velux platform."""
|
||||
pyvlx = config.runtime_data
|
||||
pyvlx = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
VeluxCover(node, config.entry_id)
|
||||
VeluxCover(node, config_entry.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, OpeningDevice)
|
||||
)
|
||||
|
||||
@@ -18,22 +18,23 @@ class VeluxEntity(Entity):
|
||||
def __init__(self, node: Node, config_entry_id: str) -> None:
|
||||
"""Initialize the Velux device."""
|
||||
self.node = node
|
||||
self._attr_unique_id = (
|
||||
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,
|
||||
node.serial_number
|
||||
if node.serial_number
|
||||
else f"{config_entry_id}_{node.node_id}",
|
||||
unique_id,
|
||||
)
|
||||
},
|
||||
name=node.name if node.name else f"#{node.node_id}",
|
||||
serial_number=node.serial_number,
|
||||
via_device=(DOMAIN, f"gateway_{config_entry_id}"),
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -18,13 +18,13 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: VeluxConfigEntry,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up light(s) for Velux platform."""
|
||||
pyvlx = config.runtime_data
|
||||
pyvlx = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
VeluxLight(node, config.entry_id)
|
||||
VeluxLight(node, config_entry.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, LighteningDevice)
|
||||
)
|
||||
|
||||
76
homeassistant/components/velux/quality_scale.yaml
Normal file
76
homeassistant/components/velux/quality_scale.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: todo
|
||||
comment: needs to move to async_setup
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency:
|
||||
status: todo
|
||||
comment: release-builds need CI
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: todo
|
||||
comment: subscribe is ok, unsubscribe needs to be added
|
||||
entity-unique-id: done
|
||||
has-entity-name:
|
||||
status: todo
|
||||
comment: scenes need fixing
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: todo
|
||||
comment: needs rework, failure to setup currently only returns false
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: todo
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: todo
|
||||
comment: button still needs it
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: cleanup mock_config_entry vs mock_user_config_entry, cleanup mock_pyvlx vs mock_velux_client, remove unused freezer in test_cover_closed, add tests where missing
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: todo
|
||||
comment: scenes need devices
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -15,11 +15,11 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: VeluxConfigEntry,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the scenes for Velux platform."""
|
||||
pyvlx = config.runtime_data
|
||||
pyvlx = config_entry.runtime_data
|
||||
|
||||
entities = [VeluxScene(scene) for scene in pyvlx.scenes]
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -36,9 +36,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER
|
||||
@@ -121,3 +122,21 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
hass.config_entries.async_update_entry(config_entry, minor_version=2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
manager = hass.data[DOMAIN][VS_MANAGER]
|
||||
await manager.get_devices()
|
||||
for dev in manager.devices:
|
||||
if isinstance(dev.sub_device_no, int):
|
||||
device_id = f"{dev.cid}{dev.sub_device_no!s}"
|
||||
else:
|
||||
device_id = dev.cid
|
||||
identifier = next(iter(device_entry.identifiers), None)
|
||||
if identifier and device_id == identifier[1]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -58,6 +58,7 @@ from .utils import (
|
||||
get_compressors,
|
||||
get_device_serial,
|
||||
is_supported,
|
||||
normalize_state,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -1086,7 +1087,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
|
||||
ViCareSensorEntityDescription(
|
||||
key="compressor_phase",
|
||||
translation_key="compressor_phase",
|
||||
value_getter=lambda api: api.getPhase(),
|
||||
value_getter=lambda api: normalize_state(api.getPhase()),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -213,7 +213,18 @@
|
||||
"name": "Compressor hours load class 5"
|
||||
},
|
||||
"compressor_phase": {
|
||||
"name": "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%]"
|
||||
}
|
||||
},
|
||||
"compressor_starts": {
|
||||
"name": "Compressor starts"
|
||||
|
||||
@@ -133,3 +133,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
|
||||
def filter_state(state: str) -> str | None:
|
||||
"""Return the state if not 'nothing' or 'unknown'."""
|
||||
return None if state in ("nothing", "unknown") else state
|
||||
|
||||
|
||||
def normalize_state(state: str) -> str:
|
||||
"""Return the state with underscores instead of hyphens."""
|
||||
return state.replace("-", "_")
|
||||
|
||||
@@ -14,11 +14,12 @@ 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, LOGGER
|
||||
from .const import API_URL, DOMAIN, LOGGER
|
||||
from .coordinator import (
|
||||
HeatPumpInfo,
|
||||
WeheatConfigEntry,
|
||||
@@ -32,7 +33,13 @@ 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."""
|
||||
implementation = await async_get_config_entry_implementation(hass, 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)
|
||||
|
||||
|
||||
@@ -124,5 +124,10 @@
|
||||
"name": "Water outlet temperature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ def check_deprecated_entity(
|
||||
return False
|
||||
|
||||
|
||||
def profile_pic(person: Person, _: Title | None) -> str | None:
|
||||
def profile_pic(person: Person, _: Title | None = None) -> str | None:
|
||||
"""Return the gamer pic."""
|
||||
|
||||
# Xbox sometimes returns a domain that uses a wrong certificate which
|
||||
|
||||
@@ -29,6 +29,19 @@
|
||||
"gamer_score": {
|
||||
"default": "mdi:alpha-g-circle"
|
||||
},
|
||||
"in_party": {
|
||||
"default": "mdi:headset",
|
||||
"state": {
|
||||
"0": "mdi:headset-off"
|
||||
}
|
||||
},
|
||||
"join_restrictions": {
|
||||
"default": "mdi:account-voice-off",
|
||||
"state": {
|
||||
"invite_only": "mdi:email-newsletter",
|
||||
"joinable": "mdi:account-multiple-plus-outline"
|
||||
}
|
||||
},
|
||||
"last_online": {
|
||||
"default": "mdi:account-clock"
|
||||
},
|
||||
|
||||
@@ -2,67 +2,85 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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 httpx import HTTPStatusError, RequestError, TimeoutException
|
||||
from pythonxbox.api.provider.titlehub.models import Image, Title, TitleFields
|
||||
|
||||
from homeassistant.components.media_player import MediaClass
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
PlayMedia,
|
||||
Unresolvable,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .browse_media import _find_media_image
|
||||
from .binary_sensor import profile_pic
|
||||
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 = {
|
||||
"gameclips": "video/mp4",
|
||||
"screenshots": "image/png",
|
||||
ATTR_GAMECLIPS: "video/mp4",
|
||||
ATTR_COMMUNITY_GAMECLIPS: "video/mp4",
|
||||
ATTR_SCREENSHOTS: "image/png",
|
||||
ATTR_COMMUNITY_SCREENSHOTS: "image/png",
|
||||
}
|
||||
|
||||
MEDIA_CLASS_MAP = {
|
||||
"gameclips": MediaClass.VIDEO,
|
||||
"screenshots": MediaClass.IMAGE,
|
||||
ATTR_GAMECLIPS: MediaClass.VIDEO,
|
||||
ATTR_COMMUNITY_GAMECLIPS: MediaClass.VIDEO,
|
||||
ATTR_SCREENSHOTS: MediaClass.IMAGE,
|
||||
ATTR_COMMUNITY_SCREENSHOTS: MediaClass.IMAGE,
|
||||
ATTR_GAME_MEDIA: MediaClass.IMAGE,
|
||||
}
|
||||
|
||||
SEPARATOR = "/"
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant):
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> XboxSource:
|
||||
"""Set up Xbox media source."""
|
||||
entry: XboxConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
client = entry.runtime_data.client
|
||||
return XboxSource(hass, client)
|
||||
|
||||
return XboxSource(hass)
|
||||
|
||||
|
||||
@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]
|
||||
class XboxMediaSourceIdentifier:
|
||||
"""Media item identifier."""
|
||||
|
||||
xuid = title_id = media_type = media_id = ""
|
||||
|
||||
@dataclass
|
||||
class XboxMediaItem:
|
||||
"""Represents gameclip/screenshot media."""
|
||||
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)
|
||||
|
||||
caption: str
|
||||
thumbnail: str
|
||||
uri: str
|
||||
media_class: str
|
||||
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]
|
||||
)
|
||||
|
||||
|
||||
class XboxSource(MediaSource):
|
||||
@@ -70,202 +88,573 @@ class XboxSource(MediaSource):
|
||||
|
||||
name: str = "Xbox Game Media"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: XboxLiveClient) -> None:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize Xbox source."""
|
||||
super().__init__(DOMAIN)
|
||||
|
||||
self.hass: HomeAssistant = hass
|
||||
self.client: XboxLiveClient = client
|
||||
self.hass = hass
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve media to a url."""
|
||||
_, category, url = async_parse_identifier(item)
|
||||
kind = category.split("#", 1)[1]
|
||||
return PlayMedia(url, MIME_TYPE_MAP[kind])
|
||||
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",
|
||||
)
|
||||
|
||||
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
|
||||
"""Return media."""
|
||||
title, category, _ = async_parse_identifier(item)
|
||||
if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)):
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="xbox_not_configured",
|
||||
)
|
||||
|
||||
if not title:
|
||||
return await self._build_game_library()
|
||||
# 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 category:
|
||||
return _build_categories(title)
|
||||
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
|
||||
|
||||
return await self._build_media_items(title, category)
|
||||
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
|
||||
|
||||
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.title_id:
|
||||
return await self._build_game_library(entry)
|
||||
|
||||
app_details = await self.client.catalog.get_products(
|
||||
games.keys(),
|
||||
FieldsTemplate.BROWSE,
|
||||
)
|
||||
if not identifier.media_type:
|
||||
return await self._build_game_title(entry, identifier)
|
||||
|
||||
images = {
|
||||
prod.product_id: prod.localized_properties[0].images
|
||||
for prod in app_details.products
|
||||
}
|
||||
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."""
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier="",
|
||||
identifier=entry.unique_id,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type="",
|
||||
title="Xbox Game Media",
|
||||
media_content_type=MediaClass.DIRECTORY,
|
||||
title=f"Xbox / {entry.title}",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[_build_game_item(game, images) for game in games.values()],
|
||||
children=[*await self._build_games(entry)],
|
||||
children_media_class=MediaClass.GAME,
|
||||
)
|
||||
|
||||
async def _build_media_items(self, title, category):
|
||||
"""Fetch requested gameclip/screenshot media."""
|
||||
title_id, _, thumbnail = title.split("#", 2)
|
||||
owner, kind = category.split("#", 1)
|
||||
async def _build_games(self, entry: XboxConfigEntry) -> list[BrowseMediaSource]:
|
||||
"""List Xbox games for the selected account."""
|
||||
|
||||
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
|
||||
]
|
||||
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
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=f"{title}~~{category}",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type="",
|
||||
title=f"{owner.title()} {kind.title()}",
|
||||
identifier=str(identifier),
|
||||
media_class=MediaClass.GAME,
|
||||
media_content_type=MediaClass.GAME,
|
||||
title=f"Xbox / {entry.title} / {game.name}",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[_build_media_item(title, category, item) for item in items],
|
||||
children_media_class=MEDIA_CLASS_MAP[kind],
|
||||
thumbnail=thumbnail,
|
||||
children=[*self._build_categories(identifier)],
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
]
|
||||
|
||||
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=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
|
||||
|
||||
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 (
|
||||
[
|
||||
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,
|
||||
)
|
||||
for image in game.images
|
||||
]
|
||||
if game.images
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
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,
|
||||
)
|
||||
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 _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,
|
||||
)
|
||||
def game_thumbnail(images: list[Image]) -> str | None:
|
||||
"""Return the title image."""
|
||||
|
||||
owners = ["my", "community"]
|
||||
kinds = ["gameclips", "screenshots"]
|
||||
for owner in owners:
|
||||
for kind in kinds:
|
||||
base.children.append(
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
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 img_type in ("BrandedKeyArt", "Poster", "BoxArt"):
|
||||
if match := next(
|
||||
(i for i in images if i.type == img_type),
|
||||
None,
|
||||
):
|
||||
return match.url
|
||||
|
||||
return base
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -24,6 +24,11 @@ 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."""
|
||||
@@ -37,6 +42,8 @@ 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)
|
||||
@@ -95,6 +102,18 @@ 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."""
|
||||
|
||||
@@ -159,6 +178,22 @@ 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()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user