mirror of
https://github.com/home-assistant/core.git
synced 2025-11-15 14:00:24 +00:00
Compare commits
244 Commits
claude/tri
...
flussButto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a3fd169b | ||
|
|
bff162255d | ||
|
|
0c45b7f615 | ||
|
|
bfa1116115 | ||
|
|
4984237987 | ||
|
|
3839573151 | ||
|
|
e02dc53df3 | ||
|
|
bedae1e12c | ||
|
|
b4eb73be98 | ||
|
|
0ac3f776fa | ||
|
|
8e8a4fff11 | ||
|
|
579ffcc64d | ||
|
|
81943fb31d | ||
|
|
70dd0bf12e | ||
|
|
c2d462c1e7 | ||
|
|
49e050cc60 | ||
|
|
f6d829a2f3 | ||
|
|
e44e3b6f25 | ||
|
|
af603661c0 | ||
|
|
35c6113777 | ||
|
|
3c2f729ddc | ||
|
|
0d63cb765f | ||
|
|
3cb414511b | ||
|
|
f55c36d42d | ||
|
|
26bb301cc0 | ||
|
|
4159e483ee | ||
|
|
7eb6f7cc07 | ||
|
|
a7d01b0b03 | ||
|
|
1e5cfddf83 | ||
|
|
006fc5b10a | ||
|
|
35a4b685b3 | ||
|
|
b166818ef4 | ||
|
|
34cd9f11d0 | ||
|
|
0711d62085 | ||
|
|
f70aeafb5f | ||
|
|
e2279b3589 | ||
|
|
87b68e99ec | ||
|
|
b6c8b787e8 | ||
|
|
78f26edc29 | ||
|
|
5e6a72de90 | ||
|
|
dcc559f8b6 | ||
|
|
eda49cced0 | ||
|
|
14e41ab119 | ||
|
|
46151456d8 | ||
|
|
39773a022a | ||
|
|
5f49a6450f | ||
|
|
dc8425c580 | ||
|
|
910bd371e4 | ||
|
|
802a225e11 | ||
|
|
84f66fa689 | ||
|
|
0b7e88d0e0 | ||
|
|
1fcaf95df5 | ||
|
|
6c7434531f | ||
|
|
5ec1c2b68b | ||
|
|
d8636d8346 | ||
|
|
434763c74d | ||
|
|
8cd2c1b43b | ||
|
|
44711787a4 | ||
|
|
98fd0ee683 | ||
|
|
303e4ce961 | ||
|
|
76f29298cd | ||
|
|
17f5d0a69f | ||
|
|
90561de438 | ||
|
|
becc106678 | ||
|
|
ca211b9504 | ||
|
|
110379402f | ||
|
|
2a9c9cf783 | ||
|
|
f9195d2212 | ||
|
|
185eb91116 | ||
|
|
6f4073a2b0 | ||
|
|
3542619e3a | ||
|
|
b84285107b | ||
|
|
526e225c8c | ||
|
|
7a9bcb52f9 | ||
|
|
6bdc60ae78 | ||
|
|
24948c4794 | ||
|
|
81cac62b26 | ||
|
|
67f0ab5ab2 | ||
|
|
afc2192723 | ||
|
|
ef7aef84a3 | ||
|
|
8989cf037d | ||
|
|
b993b7c375 | ||
|
|
615ab3e165 | ||
|
|
0eea2e66d4 | ||
|
|
917a6f0adf | ||
|
|
3d6386ef8d | ||
|
|
02c9c43697 | ||
|
|
2423b56d93 | ||
|
|
dd5a3d2dad | ||
|
|
b32bdd35b2 | ||
|
|
6cc636a547 | ||
|
|
954a803087 | ||
|
|
c57a46f777 | ||
|
|
286a3a0b53 | ||
|
|
f22e016efe | ||
|
|
430f7045ff | ||
|
|
b7c795e01f | ||
|
|
10032ec3fc | ||
|
|
524a9f6851 | ||
|
|
5ef84db2de | ||
|
|
2d1d8c1832 | ||
|
|
1c2b60e00b | ||
|
|
c2fa3c7e3a | ||
|
|
adfcdd1425 | ||
|
|
c843221e09 | ||
|
|
d2ff0c236d | ||
|
|
112d4dc745 | ||
|
|
d5d4d667b7 | ||
|
|
4bb8358bc2 | ||
|
|
02539023db | ||
|
|
f675e73bfd | ||
|
|
9862df3fea | ||
|
|
f323c4fe18 | ||
|
|
b83b3ad123 | ||
|
|
e1504e600a | ||
|
|
ea3e46d7bf | ||
|
|
642ed83e27 | ||
|
|
81b4679dfa | ||
|
|
6abca41273 | ||
|
|
52ce506b5c | ||
|
|
ed4094bf97 | ||
|
|
6e0a4b5506 | ||
|
|
f004aca704 | ||
|
|
05e458a056 | ||
|
|
9736429950 | ||
|
|
ca44bc0bc3 | ||
|
|
a6a1b0b85f | ||
|
|
28856d2d84 | ||
|
|
b45be9ae59 | ||
|
|
9bd09017bb | ||
|
|
117574d109 | ||
|
|
3c2419a02a | ||
|
|
c1f25d59a2 | ||
|
|
7abcb9c8b8 | ||
|
|
35663175db | ||
|
|
a28b447bb2 | ||
|
|
9f5132f685 | ||
|
|
ebdc70997f | ||
|
|
16a8bec8d1 | ||
|
|
0f38a7f811 | ||
|
|
aa8e13f48b | ||
|
|
243e96fded | ||
|
|
64fdec14f6 | ||
|
|
6606754521 | ||
|
|
1f571c7fce | ||
|
|
914fe97e22 | ||
|
|
a71a17191c | ||
|
|
486686ae75 | ||
|
|
ca2747dfc5 | ||
|
|
e847bd44e7 | ||
|
|
c6a96d8d42 | ||
|
|
df3f80f24c | ||
|
|
4489f90e46 | ||
|
|
8791323092 | ||
|
|
1ffe7492cf | ||
|
|
21b941aef2 | ||
|
|
178c280991 | ||
|
|
3560ce5935 | ||
|
|
f1512f9577 | ||
|
|
ce5a89b9b9 | ||
|
|
45e85e9cd2 | ||
|
|
04c312c4c0 | ||
|
|
aa2844a89a | ||
|
|
0de64f6892 | ||
|
|
32a9de7968 | ||
|
|
3f7628cd90 | ||
|
|
3a15527e12 | ||
|
|
2028138371 | ||
|
|
a33b1792ff | ||
|
|
43db503ead | ||
|
|
484ee3eeea | ||
|
|
4b44064946 | ||
|
|
745228ff89 | ||
|
|
1a069b52f1 | ||
|
|
c95e5b8883 | ||
|
|
7309ed04a3 | ||
|
|
bb62e9ec62 | ||
|
|
b2939a7bab | ||
|
|
5946ee1da6 | ||
|
|
02f207ea7d | ||
|
|
1877ecc845 | ||
|
|
c7ebde041f | ||
|
|
ef86318d3e | ||
|
|
198f71d69d | ||
|
|
a40579c813 | ||
|
|
a67244e955 | ||
|
|
6404e45dea | ||
|
|
db0d1878cf | ||
|
|
01db715d56 | ||
|
|
dd7ce1eaa9 | ||
|
|
0aef6abbe7 | ||
|
|
2809503dfa | ||
|
|
bec73b9e0a | ||
|
|
4efc7e9160 | ||
|
|
f3bc9e2feb | ||
|
|
b79b0da224 | ||
|
|
51b9285886 | ||
|
|
0bff111d81 | ||
|
|
b3ee022669 | ||
|
|
62c3bcc3ca | ||
|
|
8d46dbc9d2 | ||
|
|
b24d0e2998 | ||
|
|
467bb1bade | ||
|
|
620cc3b6e3 | ||
|
|
e0363d277f | ||
|
|
c0cc359672 | ||
|
|
4c36dd6f5b | ||
|
|
de7d2c9714 | ||
|
|
bb42dfa8c6 | ||
|
|
6532d6bfc6 | ||
|
|
b63e36f4bb | ||
|
|
efbba90cad | ||
|
|
b7a0b61933 | ||
|
|
a681070b77 | ||
|
|
2497713507 | ||
|
|
990ef3b0ef | ||
|
|
08cd9c1ba6 | ||
|
|
4632ea34a7 | ||
|
|
404662f4af | ||
|
|
b865c48bab | ||
|
|
044fd08046 | ||
|
|
1270ebc1a4 | ||
|
|
4aa41eaeac | ||
|
|
04d9273021 | ||
|
|
ae2c893d7b | ||
|
|
36b2949f32 | ||
|
|
330ed228be | ||
|
|
81ca4bc181 | ||
|
|
20c539150d | ||
|
|
60c76377bf | ||
|
|
3e0c40a047 | ||
|
|
7c65c0df83 | ||
|
|
976ebac457 | ||
|
|
7376835c7c | ||
|
|
4fc6b440e8 | ||
|
|
a1aaac5578 | ||
|
|
98fc5c22d1 | ||
|
|
ff1d4aaa76 | ||
|
|
34481d9b36 | ||
|
|
5d708e04d5 | ||
|
|
7d016e8689 | ||
|
|
43153dd61f | ||
|
|
56c4959639 | ||
|
|
196610fe33 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 1
|
CACHE_VERSION: 2
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 1
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2025.12"
|
HA_SHORT_VERSION: "2025.12"
|
||||||
|
|||||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -516,6 +516,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/flo/ @dmulcahey
|
/tests/components/flo/ @dmulcahey
|
||||||
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
|
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||||
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
|
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||||
|
/homeassistant/components/fluss/ @fluss
|
||||||
|
/tests/components/fluss/ @fluss
|
||||||
/homeassistant/components/flux_led/ @icemanch
|
/homeassistant/components/flux_led/ @icemanch
|
||||||
/tests/components/flux_led/ @icemanch
|
/tests/components/flux_led/ @icemanch
|
||||||
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
|
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
|
||||||
|
|||||||
@@ -143,28 +143,5 @@
|
|||||||
"name": "Trigger"
|
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
"""Provides triggers for alarm control panels."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast, override
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
CONF_OPTIONS,
|
|
||||||
CONF_TARGET,
|
|
||||||
STATE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.target import (
|
|
||||||
TargetStateChangedData,
|
|
||||||
async_track_target_selector_state_change_event,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import DOMAIN, AlarmControlPanelState
|
|
||||||
|
|
||||||
CONF_MODE = "mode"
|
|
||||||
|
|
||||||
ARMED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional(CONF_MODE, default=[]): vol.All(
|
|
||||||
cv.ensure_list,
|
|
||||||
[
|
|
||||||
vol.In(
|
|
||||||
[
|
|
||||||
AlarmControlPanelState.ARMED_HOME,
|
|
||||||
AlarmControlPanelState.ARMED_AWAY,
|
|
||||||
AlarmControlPanelState.ARMED_NIGHT,
|
|
||||||
AlarmControlPanelState.ARMED_VACATION,
|
|
||||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
DISARMED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
TRIGGERED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AlarmArmedTrigger(Trigger):
|
|
||||||
"""Trigger for when an alarm control panel is armed."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, ARMED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the alarm armed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
mode_filter = self._options[CONF_MODE]
|
|
||||||
|
|
||||||
# All armed states
|
|
||||||
armed_states = {
|
|
||||||
AlarmControlPanelState.ARMED_HOME,
|
|
||||||
AlarmControlPanelState.ARMED_AWAY,
|
|
||||||
AlarmControlPanelState.ARMED_NIGHT,
|
|
||||||
AlarmControlPanelState.ARMED_VACATION,
|
|
||||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
|
||||||
}
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if the new state is an armed state
|
|
||||||
if to_state.state not in armed_states:
|
|
||||||
return
|
|
||||||
|
|
||||||
# If mode filter is specified, check if the mode matches
|
|
||||||
if mode_filter and to_state.state not in mode_filter:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"alarm armed on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AlarmDisarmedTrigger(Trigger):
|
|
||||||
"""Trigger for when an alarm control panel is disarmed."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, DISARMED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the alarm disarmed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if the new state is disarmed
|
|
||||||
if to_state.state != AlarmControlPanelState.DISARMED:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"alarm disarmed on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AlarmTriggeredTrigger(Trigger):
|
|
||||||
"""Trigger for when an alarm control panel is triggered."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, TRIGGERED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the alarm triggered trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if the new state is triggered
|
|
||||||
if to_state.state != AlarmControlPanelState.TRIGGERED:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"alarm triggered on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
|
||||||
"armed": AlarmArmedTrigger,
|
|
||||||
"disarmed": AlarmDisarmedTrigger,
|
|
||||||
"triggered": AlarmTriggeredTrigger,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
|
||||||
"""Return the triggers for alarm control panels."""
|
|
||||||
return TRIGGERS
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
armed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: alarm_control_panel
|
|
||||||
fields:
|
|
||||||
mode:
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- value: armed_home
|
|
||||||
label: Home
|
|
||||||
- value: armed_away
|
|
||||||
label: Away
|
|
||||||
- value: armed_night
|
|
||||||
label: Night
|
|
||||||
- value: armed_vacation
|
|
||||||
label: Vacation
|
|
||||||
- value: armed_custom_bypass
|
|
||||||
label: Custom bypass
|
|
||||||
disarmed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: alarm_control_panel
|
|
||||||
triggered:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: alarm_control_panel
|
|
||||||
@@ -98,27 +98,5 @@
|
|||||||
"name": "Start conversation"
|
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"""Provides triggers for assist satellites."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast, override
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
CONF_TARGET,
|
|
||||||
STATE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.event import process_state_match
|
|
||||||
from homeassistant.helpers.target import (
|
|
||||||
TargetStateChangedData,
|
|
||||||
async_track_target_selector_state_change_event,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
STATE_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StateTriggerBase(Trigger):
|
|
||||||
"""Trigger for assist satellite state changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig, state: str) -> None:
|
|
||||||
"""Initialize the state trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
self._state = state
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
match_config_state = process_state_match(self._state)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if the new state matches the trigger state
|
|
||||||
if not match_config_state(to_state.state):
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"{entity_id} {self._state}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ListeningTrigger(StateTriggerBase):
|
|
||||||
"""Trigger for when a satellite starts listening."""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the listening trigger."""
|
|
||||||
super().__init__(hass, config, "listening")
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessingTrigger(StateTriggerBase):
|
|
||||||
"""Trigger for when a satellite starts processing."""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the processing trigger."""
|
|
||||||
super().__init__(hass, config, "processing")
|
|
||||||
|
|
||||||
|
|
||||||
class RespondingTrigger(StateTriggerBase):
|
|
||||||
"""Trigger for when a satellite starts responding."""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the responding trigger."""
|
|
||||||
super().__init__(hass, config, "responding")
|
|
||||||
|
|
||||||
|
|
||||||
class IdleTrigger(StateTriggerBase):
|
|
||||||
"""Trigger for when a satellite goes back to idle."""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the idle trigger."""
|
|
||||||
super().__init__(hass, config, "idle")
|
|
||||||
|
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
|
||||||
"listening": ListeningTrigger,
|
|
||||||
"processing": ProcessingTrigger,
|
|
||||||
"responding": RespondingTrigger,
|
|
||||||
"idle": IdleTrigger,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
|
||||||
"""Return the triggers for assist satellites."""
|
|
||||||
return TRIGGERS
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
listening:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: assist_satellite
|
|
||||||
|
|
||||||
processing:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: assist_satellite
|
|
||||||
|
|
||||||
responding:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: assist_satellite
|
|
||||||
|
|
||||||
idle:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: assist_satellite
|
|
||||||
@@ -285,93 +285,5 @@
|
|||||||
"name": "[%key:common::action::turn_on%]"
|
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,817 +0,0 @@
|
|||||||
"""Provides triggers for climate."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast, override
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
ATTR_TEMPERATURE,
|
|
||||||
CONF_ABOVE,
|
|
||||||
CONF_BELOW,
|
|
||||||
CONF_OPTIONS,
|
|
||||||
CONF_TARGET,
|
|
||||||
STATE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.target import (
|
|
||||||
TargetStateChangedData,
|
|
||||||
async_track_target_selector_state_change_event,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
ATTR_CURRENT_HUMIDITY,
|
|
||||||
ATTR_CURRENT_TEMPERATURE,
|
|
||||||
ATTR_HUMIDITY,
|
|
||||||
ATTR_HVAC_ACTION,
|
|
||||||
ATTR_HVAC_MODE,
|
|
||||||
DOMAIN,
|
|
||||||
HVAC_MODES,
|
|
||||||
HVACMode,
|
|
||||||
)
|
|
||||||
|
|
||||||
CLIMATE_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
MODE_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional(ATTR_HVAC_MODE, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [vol.In(HVAC_MODES)]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
THRESHOLD_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional(CONF_ABOVE): vol.Coerce(float),
|
|
||||||
vol.Optional(CONF_BELOW): vol.Coerce(float),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateTurnsOnTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate turns on."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate turns on trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if climate turned on (from off to any other mode)
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.state == HVACMode.OFF
|
|
||||||
and to_state.state != HVACMode.OFF
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} turned on",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateTurnsOffTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate turns off."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate turns off trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if climate turned off (from any mode to off)
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.state != HVACMode.OFF
|
|
||||||
and to_state.state == HVACMode.OFF
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} turned off",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateModeChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate mode changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, MODE_CHANGED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate mode changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
hvac_modes_filter = self._options[ATTR_HVAC_MODE]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if hvac_mode changed
|
|
||||||
if from_state is not None and from_state.state != to_state.state:
|
|
||||||
# If hvac_modes filter is specified, check if the new mode matches
|
|
||||||
if hvac_modes_filter and to_state.state not in hvac_modes_filter:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} mode changed to {to_state.state}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateCoolingTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate starts cooling."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate cooling trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if climate started cooling
|
|
||||||
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
|
|
||||||
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
|
|
||||||
|
|
||||||
if from_action != "cooling" and to_action == "cooling":
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} started cooling",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateHeatingTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate starts heating."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate heating trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if climate started heating
|
|
||||||
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
|
|
||||||
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
|
|
||||||
|
|
||||||
if from_action != "heating" and to_action == "heating":
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} started heating",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateDryingTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate starts drying."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate drying trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if climate started drying
|
|
||||||
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
|
|
||||||
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
|
|
||||||
|
|
||||||
if from_action != "drying" and to_action == "drying":
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} started drying",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateTargetTemperatureChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate target temperature changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate target temperature changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
above = self._options.get(CONF_ABOVE)
|
|
||||||
below = self._options.get(CONF_BELOW)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if target temperature changed
|
|
||||||
from_temp = (
|
|
||||||
from_state.attributes.get(ATTR_TEMPERATURE) if from_state else None
|
|
||||||
)
|
|
||||||
to_temp = to_state.attributes.get(ATTR_TEMPERATURE)
|
|
||||||
|
|
||||||
if to_temp is None or from_temp == to_temp:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Apply threshold filters if specified
|
|
||||||
if above is not None and to_temp <= above:
|
|
||||||
return
|
|
||||||
if below is not None and to_temp >= below:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} target temperature changed to {to_temp}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateCurrentTemperatureChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate current temperature changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate current temperature changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
above = self._options.get(CONF_ABOVE)
|
|
||||||
below = self._options.get(CONF_BELOW)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if current temperature changed
|
|
||||||
from_temp = (
|
|
||||||
from_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
|
||||||
if from_state
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
to_temp = to_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
|
||||||
|
|
||||||
if to_temp is None or from_temp == to_temp:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Apply threshold filters if specified
|
|
||||||
if above is not None and to_temp <= above:
|
|
||||||
return
|
|
||||||
if below is not None and to_temp >= below:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} current temperature changed to {to_temp}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateTargetHumidityChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate target humidity changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate target humidity changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
above = self._options.get(CONF_ABOVE)
|
|
||||||
below = self._options.get(CONF_BELOW)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if target humidity changed
|
|
||||||
from_humidity = (
|
|
||||||
from_state.attributes.get(ATTR_HUMIDITY) if from_state else None
|
|
||||||
)
|
|
||||||
to_humidity = to_state.attributes.get(ATTR_HUMIDITY)
|
|
||||||
|
|
||||||
if to_humidity is None or from_humidity == to_humidity:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Apply threshold filters if specified
|
|
||||||
if above is not None and to_humidity <= above:
|
|
||||||
return
|
|
||||||
if below is not None and to_humidity >= below:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} target humidity changed to {to_humidity}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClimateCurrentHumidityChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a climate current humidity changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the climate current humidity changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
above = self._options.get(CONF_ABOVE)
|
|
||||||
below = self._options.get(CONF_BELOW)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if current humidity changed
|
|
||||||
from_humidity = (
|
|
||||||
from_state.attributes.get(ATTR_CURRENT_HUMIDITY)
|
|
||||||
if from_state
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
to_humidity = to_state.attributes.get(ATTR_CURRENT_HUMIDITY)
|
|
||||||
|
|
||||||
if to_humidity is None or from_humidity == to_humidity:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Apply threshold filters if specified
|
|
||||||
if above is not None and to_humidity <= above:
|
|
||||||
return
|
|
||||||
if below is not None and to_humidity >= below:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"climate {entity_id} current humidity changed to {to_humidity}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
|
||||||
"turns_on": ClimateTurnsOnTrigger,
|
|
||||||
"turns_off": ClimateTurnsOffTrigger,
|
|
||||||
"mode_changed": ClimateModeChangedTrigger,
|
|
||||||
"cooling": ClimateCoolingTrigger,
|
|
||||||
"heating": ClimateHeatingTrigger,
|
|
||||||
"drying": ClimateDryingTrigger,
|
|
||||||
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
|
|
||||||
"current_temperature_changed": ClimateCurrentTemperatureChangedTrigger,
|
|
||||||
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
|
|
||||||
"current_humidity_changed": ClimateCurrentHumidityChangedTrigger,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
|
||||||
"""Return the triggers for climate."""
|
|
||||||
return TRIGGERS
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
turns_on:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
|
|
||||||
turns_off:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
|
|
||||||
mode_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
fields:
|
|
||||||
hvac_mode:
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
multiple: true
|
|
||||||
mode: dropdown
|
|
||||||
options:
|
|
||||||
- label: "Off"
|
|
||||||
value: "off"
|
|
||||||
- label: "Heat"
|
|
||||||
value: "heat"
|
|
||||||
- label: "Cool"
|
|
||||||
value: "cool"
|
|
||||||
- label: "Heat/Cool"
|
|
||||||
value: "heat_cool"
|
|
||||||
- label: "Auto"
|
|
||||||
value: "auto"
|
|
||||||
- label: "Dry"
|
|
||||||
value: "dry"
|
|
||||||
- label: "Fan only"
|
|
||||||
value: "fan_only"
|
|
||||||
|
|
||||||
cooling:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
|
|
||||||
heating:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
|
|
||||||
drying:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
|
|
||||||
target_temperature_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
fields:
|
|
||||||
above:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
step: 0.1
|
|
||||||
below:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
step: 0.1
|
|
||||||
|
|
||||||
current_temperature_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
fields:
|
|
||||||
above:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
step: 0.1
|
|
||||||
below:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
step: 0.1
|
|
||||||
|
|
||||||
target_humidity_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
fields:
|
|
||||||
above:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
below:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
|
|
||||||
current_humidity_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: climate
|
|
||||||
fields:
|
|
||||||
above:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
below:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
@@ -136,75 +136,5 @@
|
|||||||
"name": "Toggle tilt"
|
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,453 +0,0 @@
|
|||||||
"""Provides triggers for covers."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast, override
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
CONF_DEVICE_CLASS,
|
|
||||||
CONF_OPTIONS,
|
|
||||||
CONF_TARGET,
|
|
||||||
STATE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.target import (
|
|
||||||
TargetStateChangedData,
|
|
||||||
async_track_target_selector_state_change_event,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
CONF_LOWER = "lower"
|
|
||||||
CONF_UPPER = "upper"
|
|
||||||
CONF_ABOVE = "above"
|
|
||||||
CONF_BELOW = "below"
|
|
||||||
CONF_FULLY_OPENED = "fully_opened"
|
|
||||||
CONF_FULLY_CLOSED = "fully_closed"
|
|
||||||
|
|
||||||
OPENS_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional(CONF_FULLY_OPENED, default=False): cv.boolean,
|
|
||||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
CLOSES_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional(CONF_FULLY_CLOSED, default=False): cv.boolean,
|
|
||||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
STOPS_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
POSITION_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Exclusive(CONF_LOWER, "position_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
|
||||||
),
|
|
||||||
vol.Exclusive(CONF_UPPER, "position_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
|
||||||
),
|
|
||||||
vol.Exclusive(CONF_ABOVE, "position_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
|
||||||
),
|
|
||||||
vol.Exclusive(CONF_BELOW, "position_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=100)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CoverOpensTrigger(Trigger):
|
|
||||||
"""Trigger for when a cover opens."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, OPENS_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the cover opens trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
assert config.options is not None
|
|
||||||
self._target = config.target
|
|
||||||
self._options = config.options
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
fully_opened = self._options[CONF_FULLY_OPENED]
|
|
||||||
device_classes_filter = self._options[CONF_DEVICE_CLASS]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter by device class if specified
|
|
||||||
if device_classes_filter:
|
|
||||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
|
||||||
if device_class not in device_classes_filter:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when cover opens or is opening
|
|
||||||
if to_state.state in (CoverState.OPEN, CoverState.OPENING):
|
|
||||||
# If fully_opened is True, only trigger when position reaches 100
|
|
||||||
if fully_opened:
|
|
||||||
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
|
||||||
if current_position != 100:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Only trigger on state change, not if already in that state
|
|
||||||
if from_state and from_state.state == to_state.state:
|
|
||||||
# For fully_opened, allow triggering when position changes to 100
|
|
||||||
if fully_opened:
|
|
||||||
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
|
|
||||||
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
|
||||||
if from_position == to_position:
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"cover opened on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CoverClosesTrigger(Trigger):
|
|
||||||
"""Trigger for when a cover closes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, CLOSES_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the cover closes trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
assert config.options is not None
|
|
||||||
self._target = config.target
|
|
||||||
self._options = config.options
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
fully_closed = self._options[CONF_FULLY_CLOSED]
|
|
||||||
device_classes_filter = self._options[CONF_DEVICE_CLASS]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter by device class if specified
|
|
||||||
if device_classes_filter:
|
|
||||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
|
||||||
if device_class not in device_classes_filter:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when cover closes or is closing
|
|
||||||
if to_state.state in (CoverState.CLOSED, CoverState.CLOSING):
|
|
||||||
# If fully_closed is True, only trigger when position reaches 0
|
|
||||||
if fully_closed:
|
|
||||||
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
|
||||||
if current_position != 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Only trigger on state change, not if already in that state
|
|
||||||
if from_state and from_state.state == to_state.state:
|
|
||||||
# For fully_closed, allow triggering when position changes to 0
|
|
||||||
if fully_closed:
|
|
||||||
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
|
|
||||||
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
|
||||||
if from_position == to_position:
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"cover closed on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CoverStopsTrigger(Trigger):
|
|
||||||
"""Trigger for when a cover stops moving."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, STOPS_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the cover stops trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
assert config.options is not None
|
|
||||||
self._target = config.target
|
|
||||||
self._options = config.options
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
device_classes_filter = self._options[CONF_DEVICE_CLASS]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter by device class if specified
|
|
||||||
if device_classes_filter:
|
|
||||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
|
||||||
if device_class not in device_classes_filter:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when cover stops (from opening/closing to open/closed)
|
|
||||||
if from_state and from_state.state in (
|
|
||||||
CoverState.OPENING,
|
|
||||||
CoverState.CLOSING,
|
|
||||||
):
|
|
||||||
if to_state.state in (CoverState.OPEN, CoverState.CLOSED):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"cover stopped on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CoverPositionChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a cover's position changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, POSITION_CHANGED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the cover position changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
self._options = config.options or {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
lower_limit = self._options.get(CONF_LOWER)
|
|
||||||
upper_limit = self._options.get(CONF_UPPER)
|
|
||||||
above_limit = self._options.get(CONF_ABOVE)
|
|
||||||
below_limit = self._options.get(CONF_BELOW)
|
|
||||||
device_classes_filter = self._options.get(CONF_DEVICE_CLASS, [])
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter by device class if specified
|
|
||||||
if device_classes_filter:
|
|
||||||
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
|
|
||||||
if device_class not in device_classes_filter:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get position values
|
|
||||||
from_position = (
|
|
||||||
from_state.attributes.get(ATTR_CURRENT_POSITION) if from_state else None
|
|
||||||
)
|
|
||||||
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
|
|
||||||
|
|
||||||
# Only trigger if position value exists and has changed
|
|
||||||
if to_position is None or from_position == to_position:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Apply threshold filters if configured
|
|
||||||
if lower_limit is not None and to_position < lower_limit:
|
|
||||||
return
|
|
||||||
if upper_limit is not None and to_position > upper_limit:
|
|
||||||
return
|
|
||||||
if above_limit is not None and to_position <= above_limit:
|
|
||||||
return
|
|
||||||
if below_limit is not None and to_position >= below_limit:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
"from_position": from_position,
|
|
||||||
"to_position": to_position,
|
|
||||||
},
|
|
||||||
f"position changed on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
|
||||||
"opens": CoverOpensTrigger,
|
|
||||||
"closes": CoverClosesTrigger,
|
|
||||||
"stops": CoverStopsTrigger,
|
|
||||||
"position_changed": CoverPositionChangedTrigger,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
|
||||||
"""Return the triggers for covers."""
|
|
||||||
return TRIGGERS
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
opens:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: cover
|
|
||||||
fields:
|
|
||||||
fully_opened:
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
device_class:
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- curtain
|
|
||||||
- shutter
|
|
||||||
- blind
|
|
||||||
|
|
||||||
closes:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: cover
|
|
||||||
fields:
|
|
||||||
fully_closed:
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
device_class:
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- curtain
|
|
||||||
- shutter
|
|
||||||
- blind
|
|
||||||
|
|
||||||
stops:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: cover
|
|
||||||
fields:
|
|
||||||
device_class:
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- curtain
|
|
||||||
- shutter
|
|
||||||
- blind
|
|
||||||
|
|
||||||
position_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: cover
|
|
||||||
fields:
|
|
||||||
lower:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
mode: box
|
|
||||||
upper:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
mode: box
|
|
||||||
above:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
mode: box
|
|
||||||
below:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
mode: box
|
|
||||||
device_class:
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- curtain
|
|
||||||
- shutter
|
|
||||||
- blind
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyecobee"],
|
"loggers": ["pyecobee"],
|
||||||
"requirements": ["python-ecobee-api==0.2.20"],
|
"requirements": ["python-ecobee-api==0.3.2"],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
|
|||||||
84
homeassistant/components/emoncms/quality_scale.yaml
Normal file
84
homeassistant/components/emoncms/quality_scale.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
rules:
|
||||||
|
# todo : add get_feed_list to the library
|
||||||
|
# todo : see if we can drop some extra attributes
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration does not provide additional actions.
|
||||||
|
appropriate-polling: done
|
||||||
|
brands: done
|
||||||
|
common-modules: done
|
||||||
|
config-flow-test-coverage:
|
||||||
|
status: todo
|
||||||
|
comment: |
|
||||||
|
test_reconfigure_api_error should use a mock config entry fixture
|
||||||
|
test_user_flow_failure should use a mock config entry fixture
|
||||||
|
move test_user_flow_* to the top of the file
|
||||||
|
config-flow: done
|
||||||
|
dependency-transparency: done
|
||||||
|
docs-actions:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration does not provide additional actions.
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
entity-event-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
No events are explicitly registered by the integration.
|
||||||
|
entity-unique-id: done
|
||||||
|
has-entity-name: done
|
||||||
|
runtime-data: done
|
||||||
|
test-before-configure: done
|
||||||
|
test-before-setup: done
|
||||||
|
unique-config-entry: done
|
||||||
|
|
||||||
|
# Silver
|
||||||
|
action-exceptions: done
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters: done
|
||||||
|
docs-installation-parameters: done
|
||||||
|
entity-unavailable: todo
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: done
|
||||||
|
parallel-updates: todo
|
||||||
|
reauthentication-flow: todo
|
||||||
|
test-coverage:
|
||||||
|
status: todo
|
||||||
|
comment: |
|
||||||
|
test the entry state in test_failure
|
||||||
|
|
||||||
|
# Gold
|
||||||
|
devices: todo
|
||||||
|
diagnostics: todo
|
||||||
|
discovery-update-info: todo
|
||||||
|
discovery: todo
|
||||||
|
docs-data-update: done
|
||||||
|
docs-examples:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration does not provide any automation
|
||||||
|
docs-known-limitations: todo
|
||||||
|
docs-supported-devices: todo
|
||||||
|
docs-supported-functions: done
|
||||||
|
docs-troubleshooting: done
|
||||||
|
docs-use-cases: todo
|
||||||
|
dynamic-devices: todo
|
||||||
|
entity-category: todo
|
||||||
|
entity-device-class:
|
||||||
|
status: todo
|
||||||
|
comment: change device_class=SensorDeviceClass.SIGNAL_STRENGTH to SOUND_PRESSURE
|
||||||
|
entity-disabled-by-default: todo
|
||||||
|
entity-translations: done
|
||||||
|
exception-translations: todo
|
||||||
|
icon-translations: todo
|
||||||
|
reconfiguration-flow: done
|
||||||
|
repair-issues: todo
|
||||||
|
stale-devices: todo
|
||||||
|
|
||||||
|
# Platinum
|
||||||
|
async-dependency: done
|
||||||
|
inject-websession: done
|
||||||
|
strict-typing: todo
|
||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Literal, TypedDict
|
from typing import Literal, NotRequired, TypedDict
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
|
|||||||
class FlowFromGridSourceType(TypedDict):
|
class FlowFromGridSourceType(TypedDict):
|
||||||
"""Dictionary describing the 'from' stat for the grid source."""
|
"""Dictionary describing the 'from' stat for the grid source."""
|
||||||
|
|
||||||
# statistic_id of a an energy meter (kWh)
|
# statistic_id of an energy meter (kWh)
|
||||||
stat_energy_from: str
|
stat_energy_from: str
|
||||||
|
|
||||||
# statistic_id of costs ($) incurred from the energy meter
|
# statistic_id of costs ($) incurred from the energy meter
|
||||||
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
|
|||||||
number_energy_price: float | None # Price for energy ($/kWh)
|
number_energy_price: float | None # Price for energy ($/kWh)
|
||||||
|
|
||||||
|
|
||||||
|
class GridPowerSourceType(TypedDict):
|
||||||
|
"""Dictionary holding the source of grid power consumption."""
|
||||||
|
|
||||||
|
# statistic_id of a power meter (kW)
|
||||||
|
# negative values indicate grid return
|
||||||
|
stat_rate: str
|
||||||
|
|
||||||
|
|
||||||
class GridSourceType(TypedDict):
|
class GridSourceType(TypedDict):
|
||||||
"""Dictionary holding the source of grid energy consumption."""
|
"""Dictionary holding the source of grid energy consumption."""
|
||||||
|
|
||||||
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
|
|||||||
|
|
||||||
flow_from: list[FlowFromGridSourceType]
|
flow_from: list[FlowFromGridSourceType]
|
||||||
flow_to: list[FlowToGridSourceType]
|
flow_to: list[FlowToGridSourceType]
|
||||||
|
power: NotRequired[list[GridPowerSourceType]]
|
||||||
|
|
||||||
cost_adjustment_day: float
|
cost_adjustment_day: float
|
||||||
|
|
||||||
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
|
|||||||
type: Literal["solar"]
|
type: Literal["solar"]
|
||||||
|
|
||||||
stat_energy_from: str
|
stat_energy_from: str
|
||||||
|
stat_rate: NotRequired[str]
|
||||||
config_entry_solar_forecast: list[str] | None
|
config_entry_solar_forecast: list[str] | None
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
|
|||||||
|
|
||||||
stat_energy_from: str
|
stat_energy_from: str
|
||||||
stat_energy_to: str
|
stat_energy_to: str
|
||||||
|
# positive when discharging, negative when charging
|
||||||
|
stat_rate: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
class GasSourceType(TypedDict):
|
class GasSourceType(TypedDict):
|
||||||
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
|
|||||||
# This is an ever increasing value
|
# This is an ever increasing value
|
||||||
stat_consumption: str
|
stat_consumption: str
|
||||||
|
|
||||||
|
# Instantaneous rate of flow: W, L/min or m³/h
|
||||||
|
stat_rate: NotRequired[str]
|
||||||
|
|
||||||
# An optional custom name for display in energy graphs
|
# An optional custom name for display in energy graphs
|
||||||
name: str | None
|
name: str | None
|
||||||
|
|
||||||
# An optional statistic_id identifying a device
|
# An optional statistic_id identifying a device
|
||||||
# that includes this device's consumption in its total
|
# that includes this device's consumption in its total
|
||||||
included_in_stat: str | None
|
included_in_stat: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
class EnergyPreferences(TypedDict):
|
class EnergyPreferences(TypedDict):
|
||||||
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("stat_rate"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
||||||
"""Generate a validator that ensures a value is only used once."""
|
"""Generate a validator that ensures a value is only used once."""
|
||||||
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
|
|||||||
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
||||||
_generate_unique_value_validator("stat_energy_to"),
|
_generate_unique_value_validator("stat_energy_to"),
|
||||||
),
|
),
|
||||||
|
vol.Optional("power"): vol.All(
|
||||||
|
[GRID_POWER_SOURCE_SCHEMA],
|
||||||
|
_generate_unique_value_validator("stat_rate"),
|
||||||
|
),
|
||||||
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
|
|||||||
{
|
{
|
||||||
vol.Required("type"): "solar",
|
vol.Required("type"): "solar",
|
||||||
vol.Required("stat_energy_from"): str,
|
vol.Required("stat_energy_from"): str,
|
||||||
|
vol.Optional("stat_rate"): str,
|
||||||
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
|||||||
vol.Required("type"): "battery",
|
vol.Required("type"): "battery",
|
||||||
vol.Required("stat_energy_from"): str,
|
vol.Required("stat_energy_from"): str,
|
||||||
vol.Required("stat_energy_to"): str,
|
vol.Required("stat_energy_to"): str,
|
||||||
|
vol.Optional("stat_rate"): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||||
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
|
|||||||
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("stat_consumption"): str,
|
vol.Required("stat_consumption"): str,
|
||||||
|
vol.Optional("stat_rate"): str,
|
||||||
vol.Optional("name"): str,
|
vol.Optional("name"): str,
|
||||||
vol.Optional("included_in_stat"): str,
|
vol.Optional("included_in_stat"): str,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from homeassistant.const import (
|
|||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
|
UnitOfPower,
|
||||||
UnitOfVolume,
|
UnitOfVolume,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||||
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
|||||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
||||||
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
||||||
}
|
}
|
||||||
|
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
|
||||||
|
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
|
||||||
|
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
|
||||||
|
}
|
||||||
|
|
||||||
ENERGY_PRICE_UNITS = tuple(
|
ENERGY_PRICE_UNITS = tuple(
|
||||||
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
||||||
)
|
)
|
||||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
||||||
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
|
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
|
||||||
|
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
|
||||||
GAS_USAGE_DEVICE_CLASSES = (
|
GAS_USAGE_DEVICE_CLASSES = (
|
||||||
sensor.SensorDeviceClass.ENERGY,
|
sensor.SensorDeviceClass.ENERGY,
|
||||||
sensor.SensorDeviceClass.GAS,
|
sensor.SensorDeviceClass.GAS,
|
||||||
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
|
|||||||
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
|
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
if issue_type == POWER_UNIT_ERROR:
|
||||||
|
return {
|
||||||
|
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
|
||||||
|
}
|
||||||
if issue_type == GAS_UNIT_ERROR:
|
if issue_type == GAS_UNIT_ERROR:
|
||||||
return {
|
return {
|
||||||
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
|
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
|
||||||
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
|
|||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_validate_usage_stat(
|
def _async_validate_stat_common(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
stat_id: str,
|
stat_id: str,
|
||||||
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
|
|||||||
allowed_units: Mapping[str, Sequence[str]],
|
allowed_units: Mapping[str, Sequence[str]],
|
||||||
unit_error: str,
|
unit_error: str,
|
||||||
issues: ValidationIssues,
|
issues: ValidationIssues,
|
||||||
) -> None:
|
check_negative: bool = False,
|
||||||
"""Validate a statistic."""
|
) -> str | None:
|
||||||
|
"""Validate common aspects of a statistic.
|
||||||
|
|
||||||
|
Returns the entity_id if validation succeeds, None otherwise.
|
||||||
|
"""
|
||||||
if stat_id not in metadata:
|
if stat_id not in metadata:
|
||||||
issues.add_issue(hass, "statistics_not_defined", stat_id)
|
issues.add_issue(hass, "statistics_not_defined", stat_id)
|
||||||
|
|
||||||
has_entity_source = valid_entity_id(stat_id)
|
has_entity_source = valid_entity_id(stat_id)
|
||||||
|
|
||||||
if not has_entity_source:
|
if not has_entity_source:
|
||||||
return
|
return None
|
||||||
|
|
||||||
entity_id = stat_id
|
entity_id = stat_id
|
||||||
|
|
||||||
if not recorder.is_entity_recorded(hass, entity_id):
|
if not recorder.is_entity_recorded(hass, entity_id):
|
||||||
issues.add_issue(hass, "recorder_untracked", entity_id)
|
issues.add_issue(hass, "recorder_untracked", entity_id)
|
||||||
return
|
return None
|
||||||
|
|
||||||
if (state := hass.states.get(entity_id)) is None:
|
if (state := hass.states.get(entity_id)) is None:
|
||||||
issues.add_issue(hass, "entity_not_defined", entity_id)
|
issues.add_issue(hass, "entity_not_defined", entity_id)
|
||||||
return
|
return None
|
||||||
|
|
||||||
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||||
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
|
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
|
||||||
return
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_value: float | None = float(state.state)
|
current_value: float | None = float(state.state)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
|
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
|
||||||
return
|
return None
|
||||||
|
|
||||||
if current_value is not None and current_value < 0:
|
if check_negative and current_value is not None and current_value < 0:
|
||||||
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
|
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
|
||||||
|
|
||||||
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||||
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
|
|||||||
if device_class and unit not in allowed_units.get(device_class, []):
|
if device_class and unit not in allowed_units.get(device_class, []):
|
||||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||||
|
|
||||||
|
return entity_id
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_validate_usage_stat(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
|
stat_id: str,
|
||||||
|
allowed_device_classes: Sequence[str],
|
||||||
|
allowed_units: Mapping[str, Sequence[str]],
|
||||||
|
unit_error: str,
|
||||||
|
issues: ValidationIssues,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a statistic."""
|
||||||
|
entity_id = _async_validate_stat_common(
|
||||||
|
hass,
|
||||||
|
metadata,
|
||||||
|
stat_id,
|
||||||
|
allowed_device_classes,
|
||||||
|
allowed_units,
|
||||||
|
unit_error,
|
||||||
|
issues,
|
||||||
|
check_negative=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if entity_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||||
|
|
||||||
allowed_state_classes = [
|
allowed_state_classes = [
|
||||||
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
|
|||||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_validate_power_stat(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
|
stat_id: str,
|
||||||
|
allowed_device_classes: Sequence[str],
|
||||||
|
allowed_units: Mapping[str, Sequence[str]],
|
||||||
|
unit_error: str,
|
||||||
|
issues: ValidationIssues,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a power statistic."""
|
||||||
|
entity_id = _async_validate_stat_common(
|
||||||
|
hass,
|
||||||
|
metadata,
|
||||||
|
stat_id,
|
||||||
|
allowed_device_classes,
|
||||||
|
allowed_units,
|
||||||
|
unit_error,
|
||||||
|
issues,
|
||||||
|
check_negative=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if entity_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||||
|
|
||||||
|
if state_class != sensor.SensorStateClass.MEASUREMENT:
|
||||||
|
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_validate_cost_stat(
|
def _async_validate_cost_stat(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -309,11 +386,260 @@ def _async_validate_auto_generated_cost_entity(
|
|||||||
issues.add_issue(hass, "recorder_untracked", cost_entity_id)
|
issues.add_issue(hass, "recorder_untracked", cost_entity_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_grid_source(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
source: data.GridSourceType,
|
||||||
|
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
|
wanted_statistics_metadata: set[str],
|
||||||
|
source_result: ValidationIssues,
|
||||||
|
validate_calls: list[functools.partial[None]],
|
||||||
|
) -> None:
|
||||||
|
"""Validate grid energy source."""
|
||||||
|
flow_from: data.FlowFromGridSourceType
|
||||||
|
for flow_from in source["flow_from"]:
|
||||||
|
wanted_statistics_metadata.add(flow_from["stat_energy_from"])
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_usage_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
flow_from["stat_energy_from"],
|
||||||
|
ENERGY_USAGE_DEVICE_CLASSES,
|
||||||
|
ENERGY_USAGE_UNITS,
|
||||||
|
ENERGY_UNIT_ERROR,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (stat_cost := flow_from.get("stat_cost")) is not None:
|
||||||
|
wanted_statistics_metadata.add(stat_cost)
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_cost_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
stat_cost,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None:
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_price_entity,
|
||||||
|
hass,
|
||||||
|
entity_energy_price,
|
||||||
|
source_result,
|
||||||
|
ENERGY_PRICE_UNITS,
|
||||||
|
ENERGY_PRICE_UNIT_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
flow_from.get("entity_energy_price") is not None
|
||||||
|
or flow_from.get("number_energy_price") is not None
|
||||||
|
):
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_auto_generated_cost_entity,
|
||||||
|
hass,
|
||||||
|
flow_from["stat_energy_from"],
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
flow_to: data.FlowToGridSourceType
|
||||||
|
for flow_to in source["flow_to"]:
|
||||||
|
wanted_statistics_metadata.add(flow_to["stat_energy_to"])
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_usage_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
flow_to["stat_energy_to"],
|
||||||
|
ENERGY_USAGE_DEVICE_CLASSES,
|
||||||
|
ENERGY_USAGE_UNITS,
|
||||||
|
ENERGY_UNIT_ERROR,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (stat_compensation := flow_to.get("stat_compensation")) is not None:
|
||||||
|
wanted_statistics_metadata.add(stat_compensation)
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_cost_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
stat_compensation,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None:
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_price_entity,
|
||||||
|
hass,
|
||||||
|
entity_energy_price,
|
||||||
|
source_result,
|
||||||
|
ENERGY_PRICE_UNITS,
|
||||||
|
ENERGY_PRICE_UNIT_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
flow_to.get("entity_energy_price") is not None
|
||||||
|
or flow_to.get("number_energy_price") is not None
|
||||||
|
):
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_auto_generated_cost_entity,
|
||||||
|
hass,
|
||||||
|
flow_to["stat_energy_to"],
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for power_stat in source.get("power", []):
|
||||||
|
wanted_statistics_metadata.add(power_stat["stat_rate"])
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_power_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
power_stat["stat_rate"],
|
||||||
|
POWER_USAGE_DEVICE_CLASSES,
|
||||||
|
POWER_USAGE_UNITS,
|
||||||
|
POWER_UNIT_ERROR,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_gas_source(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
source: data.GasSourceType,
|
||||||
|
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
|
wanted_statistics_metadata: set[str],
|
||||||
|
source_result: ValidationIssues,
|
||||||
|
validate_calls: list[functools.partial[None]],
|
||||||
|
) -> None:
|
||||||
|
"""Validate gas energy source."""
|
||||||
|
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_usage_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
source["stat_energy_from"],
|
||||||
|
GAS_USAGE_DEVICE_CLASSES,
|
||||||
|
GAS_USAGE_UNITS,
|
||||||
|
GAS_UNIT_ERROR,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (stat_cost := source.get("stat_cost")) is not None:
|
||||||
|
wanted_statistics_metadata.add(stat_cost)
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_cost_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
stat_cost,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_price_entity,
|
||||||
|
hass,
|
||||||
|
entity_energy_price,
|
||||||
|
source_result,
|
||||||
|
GAS_PRICE_UNITS,
|
||||||
|
GAS_PRICE_UNIT_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
source.get("entity_energy_price") is not None
|
||||||
|
or source.get("number_energy_price") is not None
|
||||||
|
):
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_auto_generated_cost_entity,
|
||||||
|
hass,
|
||||||
|
source["stat_energy_from"],
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_water_source(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
source: data.WaterSourceType,
|
||||||
|
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
|
wanted_statistics_metadata: set[str],
|
||||||
|
source_result: ValidationIssues,
|
||||||
|
validate_calls: list[functools.partial[None]],
|
||||||
|
) -> None:
|
||||||
|
"""Validate water energy source."""
|
||||||
|
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_usage_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
source["stat_energy_from"],
|
||||||
|
WATER_USAGE_DEVICE_CLASSES,
|
||||||
|
WATER_USAGE_UNITS,
|
||||||
|
WATER_UNIT_ERROR,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (stat_cost := source.get("stat_cost")) is not None:
|
||||||
|
wanted_statistics_metadata.add(stat_cost)
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_cost_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
stat_cost,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_price_entity,
|
||||||
|
hass,
|
||||||
|
entity_energy_price,
|
||||||
|
source_result,
|
||||||
|
WATER_PRICE_UNITS,
|
||||||
|
WATER_PRICE_UNIT_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
source.get("entity_energy_price") is not None
|
||||||
|
or source.get("number_energy_price") is not None
|
||||||
|
):
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_auto_generated_cost_entity,
|
||||||
|
hass,
|
||||||
|
source["stat_energy_from"],
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||||
"""Validate the energy configuration."""
|
"""Validate the energy configuration."""
|
||||||
manager: data.EnergyManager = await data.async_get_manager(hass)
|
manager: data.EnergyManager = await data.async_get_manager(hass)
|
||||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
|
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
|
||||||
validate_calls = []
|
validate_calls: list[functools.partial[None]] = []
|
||||||
wanted_statistics_metadata: set[str] = set()
|
wanted_statistics_metadata: set[str] = set()
|
||||||
|
|
||||||
result = EnergyPreferencesValidation()
|
result = EnergyPreferencesValidation()
|
||||||
@@ -327,215 +653,35 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
|||||||
result.energy_sources.append(source_result)
|
result.energy_sources.append(source_result)
|
||||||
|
|
||||||
if source["type"] == "grid":
|
if source["type"] == "grid":
|
||||||
flow: data.FlowFromGridSourceType | data.FlowToGridSourceType
|
_validate_grid_source(
|
||||||
for flow in source["flow_from"]:
|
hass,
|
||||||
wanted_statistics_metadata.add(flow["stat_energy_from"])
|
source,
|
||||||
validate_calls.append(
|
statistics_metadata,
|
||||||
functools.partial(
|
wanted_statistics_metadata,
|
||||||
_async_validate_usage_stat,
|
source_result,
|
||||||
hass,
|
validate_calls,
|
||||||
statistics_metadata,
|
)
|
||||||
flow["stat_energy_from"],
|
|
||||||
ENERGY_USAGE_DEVICE_CLASSES,
|
|
||||||
ENERGY_USAGE_UNITS,
|
|
||||||
ENERGY_UNIT_ERROR,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (stat_cost := flow.get("stat_cost")) is not None:
|
|
||||||
wanted_statistics_metadata.add(stat_cost)
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_cost_stat,
|
|
||||||
hass,
|
|
||||||
statistics_metadata,
|
|
||||||
stat_cost,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif (
|
|
||||||
entity_energy_price := flow.get("entity_energy_price")
|
|
||||||
) is not None:
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_price_entity,
|
|
||||||
hass,
|
|
||||||
entity_energy_price,
|
|
||||||
source_result,
|
|
||||||
ENERGY_PRICE_UNITS,
|
|
||||||
ENERGY_PRICE_UNIT_ERROR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
flow.get("entity_energy_price") is not None
|
|
||||||
or flow.get("number_energy_price") is not None
|
|
||||||
):
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_auto_generated_cost_entity,
|
|
||||||
hass,
|
|
||||||
flow["stat_energy_from"],
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for flow in source["flow_to"]:
|
|
||||||
wanted_statistics_metadata.add(flow["stat_energy_to"])
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_usage_stat,
|
|
||||||
hass,
|
|
||||||
statistics_metadata,
|
|
||||||
flow["stat_energy_to"],
|
|
||||||
ENERGY_USAGE_DEVICE_CLASSES,
|
|
||||||
ENERGY_USAGE_UNITS,
|
|
||||||
ENERGY_UNIT_ERROR,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (stat_compensation := flow.get("stat_compensation")) is not None:
|
|
||||||
wanted_statistics_metadata.add(stat_compensation)
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_cost_stat,
|
|
||||||
hass,
|
|
||||||
statistics_metadata,
|
|
||||||
stat_compensation,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif (
|
|
||||||
entity_energy_price := flow.get("entity_energy_price")
|
|
||||||
) is not None:
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_price_entity,
|
|
||||||
hass,
|
|
||||||
entity_energy_price,
|
|
||||||
source_result,
|
|
||||||
ENERGY_PRICE_UNITS,
|
|
||||||
ENERGY_PRICE_UNIT_ERROR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
flow.get("entity_energy_price") is not None
|
|
||||||
or flow.get("number_energy_price") is not None
|
|
||||||
):
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_auto_generated_cost_entity,
|
|
||||||
hass,
|
|
||||||
flow["stat_energy_to"],
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif source["type"] == "gas":
|
elif source["type"] == "gas":
|
||||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
_validate_gas_source(
|
||||||
validate_calls.append(
|
hass,
|
||||||
functools.partial(
|
source,
|
||||||
_async_validate_usage_stat,
|
statistics_metadata,
|
||||||
hass,
|
wanted_statistics_metadata,
|
||||||
statistics_metadata,
|
source_result,
|
||||||
source["stat_energy_from"],
|
validate_calls,
|
||||||
GAS_USAGE_DEVICE_CLASSES,
|
|
||||||
GAS_USAGE_UNITS,
|
|
||||||
GAS_UNIT_ERROR,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (stat_cost := source.get("stat_cost")) is not None:
|
|
||||||
wanted_statistics_metadata.add(stat_cost)
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_cost_stat,
|
|
||||||
hass,
|
|
||||||
statistics_metadata,
|
|
||||||
stat_cost,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_price_entity,
|
|
||||||
hass,
|
|
||||||
entity_energy_price,
|
|
||||||
source_result,
|
|
||||||
GAS_PRICE_UNITS,
|
|
||||||
GAS_PRICE_UNIT_ERROR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
source.get("entity_energy_price") is not None
|
|
||||||
or source.get("number_energy_price") is not None
|
|
||||||
):
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_auto_generated_cost_entity,
|
|
||||||
hass,
|
|
||||||
source["stat_energy_from"],
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif source["type"] == "water":
|
elif source["type"] == "water":
|
||||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
_validate_water_source(
|
||||||
validate_calls.append(
|
hass,
|
||||||
functools.partial(
|
source,
|
||||||
_async_validate_usage_stat,
|
statistics_metadata,
|
||||||
hass,
|
wanted_statistics_metadata,
|
||||||
statistics_metadata,
|
source_result,
|
||||||
source["stat_energy_from"],
|
validate_calls,
|
||||||
WATER_USAGE_DEVICE_CLASSES,
|
|
||||||
WATER_USAGE_UNITS,
|
|
||||||
WATER_UNIT_ERROR,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (stat_cost := source.get("stat_cost")) is not None:
|
|
||||||
wanted_statistics_metadata.add(stat_cost)
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_cost_stat,
|
|
||||||
hass,
|
|
||||||
statistics_metadata,
|
|
||||||
stat_cost,
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_price_entity,
|
|
||||||
hass,
|
|
||||||
entity_energy_price,
|
|
||||||
source_result,
|
|
||||||
WATER_PRICE_UNITS,
|
|
||||||
WATER_PRICE_UNIT_ERROR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
source.get("entity_energy_price") is not None
|
|
||||||
or source.get("number_energy_price") is not None
|
|
||||||
):
|
|
||||||
validate_calls.append(
|
|
||||||
functools.partial(
|
|
||||||
_async_validate_auto_generated_cost_entity,
|
|
||||||
hass,
|
|
||||||
source["stat_energy_from"],
|
|
||||||
source_result,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif source["type"] == "solar":
|
elif source["type"] == "solar":
|
||||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||||
validate_calls.append(
|
validate_calls.append(
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
|
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
|
||||||
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
|
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
|
||||||
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
|
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
|
||||||
|
"ctmeters": envoy_data.ctmeters,
|
||||||
|
"ctmeters_phases": envoy_data.ctmeters_phases,
|
||||||
"dry_contact_status": envoy_data.dry_contact_status,
|
"dry_contact_status": envoy_data.dry_contact_status,
|
||||||
"dry_contact_settings": envoy_data.dry_contact_settings,
|
"dry_contact_settings": envoy_data.dry_contact_settings,
|
||||||
"inverters": envoy_data.inverters,
|
"inverters": envoy_data.inverters,
|
||||||
@@ -179,6 +181,7 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"ct_consumption_meter": envoy.consumption_meter_type,
|
"ct_consumption_meter": envoy.consumption_meter_type,
|
||||||
"ct_production_meter": envoy.production_meter_type,
|
"ct_production_meter": envoy.production_meter_type,
|
||||||
"ct_storage_meter": envoy.storage_meter_type,
|
"ct_storage_meter": envoy.storage_meter_type,
|
||||||
|
"ct_meters": list(envoy_data.ctmeters.keys()),
|
||||||
}
|
}
|
||||||
|
|
||||||
fixture_data: dict[str, Any] = {}
|
fixture_data: dict[str, Any] = {}
|
||||||
|
|||||||
@@ -399,330 +399,189 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
|||||||
cttype: str | None = None
|
cttype: str | None = None
|
||||||
|
|
||||||
|
|
||||||
CT_NET_CONSUMPTION_SENSORS = (
|
# All ct types unified in common setup
|
||||||
EnvoyCTSensorEntityDescription(
|
CT_SENSORS = (
|
||||||
key="lifetime_net_consumption",
|
[
|
||||||
translation_key="lifetime_net_consumption",
|
EnvoyCTSensorEntityDescription(
|
||||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
key=key,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
translation_key=key,
|
||||||
device_class=SensorDeviceClass.ENERGY,
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
suggested_display_precision=3,
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
value_fn=attrgetter("energy_delivered"),
|
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||||
on_phase=None,
|
suggested_display_precision=3,
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
value_fn=attrgetter("energy_delivered"),
|
||||||
),
|
on_phase=None,
|
||||||
EnvoyCTSensorEntityDescription(
|
cttype=cttype,
|
||||||
key="lifetime_net_production",
|
)
|
||||||
translation_key="lifetime_net_production",
|
for cttype, key in (
|
||||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
# Production CT energy_delivered is not used
|
||||||
device_class=SensorDeviceClass.ENERGY,
|
(CtType.STORAGE, "lifetime_battery_discharged"),
|
||||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
|
||||||
suggested_display_precision=3,
|
|
||||||
value_fn=attrgetter("energy_received"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="net_consumption",
|
|
||||||
translation_key="net_consumption",
|
|
||||||
native_unit_of_measurement=UnitOfPower.WATT,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.POWER,
|
|
||||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
|
||||||
suggested_display_precision=3,
|
|
||||||
value_fn=attrgetter("active_power"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="frequency",
|
|
||||||
translation_key="net_ct_frequency",
|
|
||||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.FREQUENCY,
|
|
||||||
suggested_display_precision=1,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("frequency"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="voltage",
|
|
||||||
translation_key="net_ct_voltage",
|
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
|
||||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
|
||||||
suggested_display_precision=1,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("voltage"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="net_ct_current",
|
|
||||||
translation_key="net_ct_current",
|
|
||||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.CURRENT,
|
|
||||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
|
||||||
suggested_display_precision=3,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("current"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="net_ct_powerfactor",
|
|
||||||
translation_key="net_ct_powerfactor",
|
|
||||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("power_factor"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="net_consumption_ct_metering_status",
|
|
||||||
translation_key="net_ct_metering_status",
|
|
||||||
device_class=SensorDeviceClass.ENUM,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
options=list(CtMeterStatus),
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("metering_status"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="net_consumption_ct_status_flags",
|
|
||||||
translation_key="net_ct_status_flags",
|
|
||||||
state_class=None,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.NET_CONSUMPTION,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CT_NET_CONSUMPTION_PHASE_SENSORS = {
|
|
||||||
(on_phase := PHASENAMES[phase]): [
|
|
||||||
replace(
|
|
||||||
sensor,
|
|
||||||
key=f"{sensor.key}_l{phase + 1}",
|
|
||||||
translation_key=f"{sensor.translation_key}_phase",
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
on_phase=on_phase,
|
|
||||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
|
||||||
)
|
)
|
||||||
for sensor in list(CT_NET_CONSUMPTION_SENSORS)
|
|
||||||
]
|
]
|
||||||
for phase in range(3)
|
+ [
|
||||||
}
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key=key,
|
||||||
CT_PRODUCTION_SENSORS = (
|
translation_key=key,
|
||||||
EnvoyCTSensorEntityDescription(
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
key="production_ct_frequency",
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
translation_key="production_ct_frequency",
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
suggested_display_precision=3,
|
||||||
device_class=SensorDeviceClass.FREQUENCY,
|
value_fn=attrgetter("energy_received"),
|
||||||
suggested_display_precision=1,
|
on_phase=None,
|
||||||
entity_registry_enabled_default=False,
|
cttype=cttype,
|
||||||
value_fn=attrgetter("frequency"),
|
)
|
||||||
on_phase=None,
|
for cttype, key in (
|
||||||
cttype=CtType.PRODUCTION,
|
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
|
||||||
),
|
# Production CT energy_received is not used
|
||||||
EnvoyCTSensorEntityDescription(
|
(CtType.STORAGE, "lifetime_battery_charged"),
|
||||||
key="production_ct_voltage",
|
)
|
||||||
translation_key="production_ct_voltage",
|
]
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
+ [
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
EnvoyCTSensorEntityDescription(
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
key=key,
|
||||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
translation_key=key,
|
||||||
suggested_display_precision=1,
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
entity_registry_enabled_default=False,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=attrgetter("voltage"),
|
device_class=SensorDeviceClass.POWER,
|
||||||
on_phase=None,
|
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||||
cttype=CtType.PRODUCTION,
|
suggested_display_precision=3,
|
||||||
),
|
value_fn=attrgetter("active_power"),
|
||||||
EnvoyCTSensorEntityDescription(
|
on_phase=None,
|
||||||
key="production_ct_current",
|
cttype=cttype,
|
||||||
translation_key="production_ct_current",
|
)
|
||||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
for cttype, key in (
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
(CtType.NET_CONSUMPTION, "net_consumption"),
|
||||||
device_class=SensorDeviceClass.CURRENT,
|
# Production CT active_power is not used
|
||||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
(CtType.STORAGE, "battery_discharge"),
|
||||||
suggested_display_precision=3,
|
)
|
||||||
entity_registry_enabled_default=False,
|
]
|
||||||
value_fn=attrgetter("current"),
|
+ [
|
||||||
on_phase=None,
|
EnvoyCTSensorEntityDescription(
|
||||||
cttype=CtType.PRODUCTION,
|
key=key,
|
||||||
),
|
translation_key=(translation_key if translation_key != "" else key),
|
||||||
EnvoyCTSensorEntityDescription(
|
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||||
key="production_ct_powerfactor",
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
translation_key="production_ct_powerfactor",
|
device_class=SensorDeviceClass.FREQUENCY,
|
||||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
suggested_display_precision=1,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
entity_registry_enabled_default=False,
|
||||||
suggested_display_precision=2,
|
value_fn=attrgetter("frequency"),
|
||||||
entity_registry_enabled_default=False,
|
on_phase=None,
|
||||||
value_fn=attrgetter("power_factor"),
|
cttype=cttype,
|
||||||
on_phase=None,
|
)
|
||||||
cttype=CtType.PRODUCTION,
|
for cttype, key, translation_key in (
|
||||||
),
|
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
|
||||||
EnvoyCTSensorEntityDescription(
|
(CtType.PRODUCTION, "production_ct_frequency", ""),
|
||||||
key="production_ct_metering_status",
|
(CtType.STORAGE, "storage_ct_frequency", ""),
|
||||||
translation_key="production_ct_metering_status",
|
)
|
||||||
device_class=SensorDeviceClass.ENUM,
|
]
|
||||||
options=list(CtMeterStatus),
|
+ [
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
EnvoyCTSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
key=key,
|
||||||
value_fn=attrgetter("metering_status"),
|
translation_key=(translation_key if translation_key != "" else key),
|
||||||
on_phase=None,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
cttype=CtType.PRODUCTION,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
EnvoyCTSensorEntityDescription(
|
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
key="production_ct_status_flags",
|
suggested_display_precision=1,
|
||||||
translation_key="production_ct_status_flags",
|
entity_registry_enabled_default=False,
|
||||||
state_class=None,
|
value_fn=attrgetter("voltage"),
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
on_phase=None,
|
||||||
entity_registry_enabled_default=False,
|
cttype=cttype,
|
||||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
)
|
||||||
on_phase=None,
|
for cttype, key, translation_key in (
|
||||||
cttype=CtType.PRODUCTION,
|
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
|
||||||
),
|
(CtType.PRODUCTION, "production_ct_voltage", ""),
|
||||||
)
|
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
|
||||||
|
)
|
||||||
CT_PRODUCTION_PHASE_SENSORS = {
|
]
|
||||||
(on_phase := PHASENAMES[phase]): [
|
+ [
|
||||||
replace(
|
EnvoyCTSensorEntityDescription(
|
||||||
sensor,
|
key=key,
|
||||||
key=f"{sensor.key}_l{phase + 1}",
|
translation_key=key,
|
||||||
translation_key=f"{sensor.translation_key}_phase",
|
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
entity_registry_enabled_default=False,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
on_phase=on_phase,
|
device_class=SensorDeviceClass.CURRENT,
|
||||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
|
suggested_display_precision=3,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("current"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=cttype,
|
||||||
|
)
|
||||||
|
for cttype, key in (
|
||||||
|
(CtType.NET_CONSUMPTION, "net_ct_current"),
|
||||||
|
(CtType.PRODUCTION, "production_ct_current"),
|
||||||
|
(CtType.STORAGE, "storage_ct_current"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key=key,
|
||||||
|
translation_key=key,
|
||||||
|
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=2,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("power_factor"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=cttype,
|
||||||
|
)
|
||||||
|
for cttype, key in (
|
||||||
|
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
|
||||||
|
(CtType.PRODUCTION, "production_ct_powerfactor"),
|
||||||
|
(CtType.STORAGE, "storage_ct_powerfactor"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key=key,
|
||||||
|
translation_key=(translation_key if translation_key != "" else key),
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
options=list(CtMeterStatus),
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("metering_status"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=cttype,
|
||||||
|
)
|
||||||
|
for cttype, key, translation_key in (
|
||||||
|
(
|
||||||
|
CtType.NET_CONSUMPTION,
|
||||||
|
"net_consumption_ct_metering_status",
|
||||||
|
"net_ct_metering_status",
|
||||||
|
),
|
||||||
|
(CtType.PRODUCTION, "production_ct_metering_status", ""),
|
||||||
|
(CtType.STORAGE, "storage_ct_metering_status", ""),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key=key,
|
||||||
|
translation_key=(translation_key if translation_key != "" else key),
|
||||||
|
state_class=None,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=cttype,
|
||||||
|
)
|
||||||
|
for cttype, key, translation_key in (
|
||||||
|
(
|
||||||
|
CtType.NET_CONSUMPTION,
|
||||||
|
"net_consumption_ct_status_flags",
|
||||||
|
"net_ct_status_flags",
|
||||||
|
),
|
||||||
|
(CtType.PRODUCTION, "production_ct_status_flags", ""),
|
||||||
|
(CtType.STORAGE, "storage_ct_status_flags", ""),
|
||||||
)
|
)
|
||||||
for sensor in list(CT_PRODUCTION_SENSORS)
|
|
||||||
]
|
]
|
||||||
for phase in range(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
CT_STORAGE_SENSORS = (
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="lifetime_battery_discharged",
|
|
||||||
translation_key="lifetime_battery_discharged",
|
|
||||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
||||||
device_class=SensorDeviceClass.ENERGY,
|
|
||||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
|
||||||
suggested_display_precision=3,
|
|
||||||
value_fn=attrgetter("energy_delivered"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="lifetime_battery_charged",
|
|
||||||
translation_key="lifetime_battery_charged",
|
|
||||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
||||||
device_class=SensorDeviceClass.ENERGY,
|
|
||||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
|
||||||
suggested_display_precision=3,
|
|
||||||
value_fn=attrgetter("energy_received"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="battery_discharge",
|
|
||||||
translation_key="battery_discharge",
|
|
||||||
native_unit_of_measurement=UnitOfPower.WATT,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.POWER,
|
|
||||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
|
||||||
suggested_display_precision=3,
|
|
||||||
value_fn=attrgetter("active_power"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="storage_ct_frequency",
|
|
||||||
translation_key="storage_ct_frequency",
|
|
||||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.FREQUENCY,
|
|
||||||
suggested_display_precision=1,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("frequency"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="storage_voltage",
|
|
||||||
translation_key="storage_ct_voltage",
|
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
|
||||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
|
||||||
suggested_display_precision=1,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("voltage"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="storage_ct_current",
|
|
||||||
translation_key="storage_ct_current",
|
|
||||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
device_class=SensorDeviceClass.CURRENT,
|
|
||||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
|
||||||
suggested_display_precision=3,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("current"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="storage_ct_powerfactor",
|
|
||||||
translation_key="storage_ct_powerfactor",
|
|
||||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("power_factor"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="storage_ct_metering_status",
|
|
||||||
translation_key="storage_ct_metering_status",
|
|
||||||
device_class=SensorDeviceClass.ENUM,
|
|
||||||
options=list(CtMeterStatus),
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("metering_status"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key="storage_ct_status_flags",
|
|
||||||
translation_key="storage_ct_status_flags",
|
|
||||||
state_class=None,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=CtType.STORAGE,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CT_STORAGE_PHASE_SENSORS = {
|
CT_PHASE_SENSORS = {
|
||||||
(on_phase := PHASENAMES[phase]): [
|
(on_phase := PHASENAMES[phase]): [
|
||||||
replace(
|
replace(
|
||||||
sensor,
|
sensor,
|
||||||
@@ -732,7 +591,7 @@ CT_STORAGE_PHASE_SENSORS = {
|
|||||||
on_phase=on_phase,
|
on_phase=on_phase,
|
||||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||||
)
|
)
|
||||||
for sensor in list(CT_STORAGE_SENSORS)
|
for sensor in list(CT_SENSORS)
|
||||||
]
|
]
|
||||||
for phase in range(3)
|
for phase in range(3)
|
||||||
}
|
}
|
||||||
@@ -1060,24 +919,14 @@ async def async_setup_entry(
|
|||||||
if envoy_data.ctmeters:
|
if envoy_data.ctmeters:
|
||||||
entities.extend(
|
entities.extend(
|
||||||
EnvoyCTEntity(coordinator, description)
|
EnvoyCTEntity(coordinator, description)
|
||||||
for sensors in (
|
for description in CT_SENSORS
|
||||||
CT_NET_CONSUMPTION_SENSORS,
|
|
||||||
CT_PRODUCTION_SENSORS,
|
|
||||||
CT_STORAGE_SENSORS,
|
|
||||||
)
|
|
||||||
for description in sensors
|
|
||||||
if description.cttype in envoy_data.ctmeters
|
if description.cttype in envoy_data.ctmeters
|
||||||
)
|
)
|
||||||
# Add Current Transformer phase entities
|
# Add Current Transformer phase entities
|
||||||
if ctmeters_phases := envoy_data.ctmeters_phases:
|
if ctmeters_phases := envoy_data.ctmeters_phases:
|
||||||
entities.extend(
|
entities.extend(
|
||||||
EnvoyCTPhaseEntity(coordinator, description)
|
EnvoyCTPhaseEntity(coordinator, description)
|
||||||
for sensors in (
|
for phase, descriptions in CT_PHASE_SENSORS.items()
|
||||||
CT_NET_CONSUMPTION_PHASE_SENSORS,
|
|
||||||
CT_PRODUCTION_PHASE_SENSORS,
|
|
||||||
CT_STORAGE_PHASE_SENSORS,
|
|
||||||
)
|
|
||||||
for phase, descriptions in sensors.items()
|
|
||||||
for description in descriptions
|
for description in descriptions
|
||||||
if (cttype := description.cttype) in ctmeters_phases
|
if (cttype := description.cttype) in ctmeters_phases
|
||||||
and phase in ctmeters_phases[cttype]
|
and phase in ctmeters_phases[cttype]
|
||||||
|
|||||||
31
homeassistant/components/fluss/__init__.py
Normal file
31
homeassistant/components/fluss/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""The Fluss+ integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_API_KEY, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .coordinator import FlussDataUpdateCoordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.BUTTON]
|
||||||
|
|
||||||
|
|
||||||
|
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: FlussConfigEntry,
|
||||||
|
) -> bool:
|
||||||
|
"""Set up Fluss+ from a config entry."""
|
||||||
|
coordinator = FlussDataUpdateCoordinator(hass, entry, entry.data[CONF_API_KEY])
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
entry.runtime_data = coordinator
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: FlussConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
38
homeassistant/components/fluss/button.py
Normal file
38
homeassistant/components/fluss/button.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Support for Fluss Devices."""
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .coordinator import FlussApiClientError, FlussDataUpdateCoordinator
|
||||||
|
from .entity import FlussEntity
|
||||||
|
|
||||||
|
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: FlussConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Fluss Devices, filtering out any invalid payloads."""
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
devices = coordinator.data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
FlussButton(coordinator, device_id, device)
|
||||||
|
for device_id, device in devices.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FlussButton(FlussEntity, ButtonEntity):
|
||||||
|
"""Representation of a Fluss button device."""
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Handle the button press."""
|
||||||
|
try:
|
||||||
|
await self.coordinator.api.async_trigger_device(self.device_id)
|
||||||
|
except FlussApiClientError as err:
|
||||||
|
raise HomeAssistantError(f"Failed to trigger device: {err}") from err
|
||||||
54
homeassistant/components/fluss/config_flow.py
Normal file
54
homeassistant/components/fluss/config_flow.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Config flow for Fluss+ integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fluss_api import (
|
||||||
|
FlussApiClient,
|
||||||
|
FlussApiClientAuthenticationError,
|
||||||
|
FlussApiClientCommunicationError,
|
||||||
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_API_KEY
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string})
|
||||||
|
|
||||||
|
|
||||||
|
class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Fluss+."""
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
api_key = user_input[CONF_API_KEY]
|
||||||
|
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||||
|
try:
|
||||||
|
FlussApiClient(
|
||||||
|
user_input[CONF_API_KEY], session=async_get_clientsession(self.hass)
|
||||||
|
)
|
||||||
|
except FlussApiClientCommunicationError:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except FlussApiClientAuthenticationError:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
LOGGER.exception("Unexpected exception occurred")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
if not errors:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="My Fluss+ Devices", data=user_input
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
9
homeassistant/components/fluss/const.py
Normal file
9
homeassistant/components/fluss/const.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Constants for the Fluss+ integration."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
DOMAIN = "fluss"
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
UPDATE_INTERVAL = 60 # seconds
|
||||||
|
UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL)
|
||||||
50
homeassistant/components/fluss/coordinator.py
Normal file
50
homeassistant/components/fluss/coordinator.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""DataUpdateCoordinator for Fluss+ integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fluss_api import (
|
||||||
|
FlussApiClient,
|
||||||
|
FlussApiClientAuthenticationError,
|
||||||
|
FlussApiClientError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
|
from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA
|
||||||
|
|
||||||
|
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
|
"""Manages fetching Fluss device data on a schedule."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, hass: HomeAssistant, config_entry: FlussConfigEntry, api_key: str
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
self.api = FlussApiClient(api_key, session=async_get_clientsession(hass))
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
LOGGER,
|
||||||
|
name=f"Fluss+ ({slugify(api_key[:8])})",
|
||||||
|
config_entry=config_entry,
|
||||||
|
update_interval=UPDATE_INTERVAL_TIMEDELTA,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Fetch data from the Fluss API and return as a dictionary keyed by deviceId."""
|
||||||
|
try:
|
||||||
|
devices = await self.api.async_get_devices()
|
||||||
|
except FlussApiClientAuthenticationError as err:
|
||||||
|
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||||
|
except FlussApiClientError as err:
|
||||||
|
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
|
||||||
|
|
||||||
|
return {device["deviceId"]: device for device in devices.get("devices", [])}
|
||||||
36
homeassistant/components/fluss/entity.py
Normal file
36
homeassistant/components/fluss/entity.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Base entities for the Fluss+ integration."""
|
||||||
|
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .coordinator import FlussDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class FlussEntity(CoordinatorEntity[FlussDataUpdateCoordinator]):
|
||||||
|
"""Base class for Fluss entities."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: FlussDataUpdateCoordinator,
|
||||||
|
device_id: str,
|
||||||
|
device: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the entity with a device ID and device data."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.device_id = device_id
|
||||||
|
self._device = device
|
||||||
|
self._attr_unique_id = f"{device_id}"
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={("fluss", device_id)},
|
||||||
|
name=device.get("deviceName"),
|
||||||
|
manufacturer="Fluss",
|
||||||
|
model="Fluss+ Device",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> dict:
|
||||||
|
"""Return the stored device data."""
|
||||||
|
return self._device
|
||||||
11
homeassistant/components/fluss/manifest.json
Normal file
11
homeassistant/components/fluss/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"domain": "fluss",
|
||||||
|
"name": "Fluss+",
|
||||||
|
"codeowners": ["@fluss"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/fluss",
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"loggers": ["fluss-api"],
|
||||||
|
"quality_scale": "bronze",
|
||||||
|
"requirements": ["fluss-api==0.1.9.17"]
|
||||||
|
}
|
||||||
69
homeassistant/components/fluss/quality_scale.yaml
Normal file
69
homeassistant/components/fluss/quality_scale.yaml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
No actions present
|
||||||
|
appropriate-polling: done
|
||||||
|
brands: done
|
||||||
|
common-modules: done
|
||||||
|
config-flow-test-coverage: done
|
||||||
|
config-flow: done
|
||||||
|
dependency-transparency: done
|
||||||
|
docs-actions: done
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
entity-event-setup: done
|
||||||
|
entity-unique-id: done
|
||||||
|
has-entity-name: done
|
||||||
|
runtime-data: done
|
||||||
|
test-before-configure: done
|
||||||
|
test-before-setup: done
|
||||||
|
unique-config-entry: done
|
||||||
|
# Silver
|
||||||
|
action-exceptions: todo
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters: done
|
||||||
|
docs-installation-parameters: done
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: done
|
||||||
|
parallel-updates: todo
|
||||||
|
reauthentication-flow: todo
|
||||||
|
test-coverage: todo
|
||||||
|
# Gold
|
||||||
|
entity-translations: done
|
||||||
|
entity-device-class: done
|
||||||
|
devices: done
|
||||||
|
entity-category: done
|
||||||
|
entity-disabled-by-default:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
Not needed
|
||||||
|
discovery: todo
|
||||||
|
stale-devices: todo
|
||||||
|
diagnostics: todo
|
||||||
|
exception-translations: todo
|
||||||
|
icon-translations:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
No icons used
|
||||||
|
reconfiguration-flow: todo
|
||||||
|
dynamic-devices: todo
|
||||||
|
discovery-update-info: todo
|
||||||
|
repair-issues:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
No issues to repair
|
||||||
|
docs-use-cases: done
|
||||||
|
docs-supported-devices: todo
|
||||||
|
docs-supported-functions: done
|
||||||
|
docs-data-update: todo
|
||||||
|
docs-known-limitations: done
|
||||||
|
docs-troubleshooting: todo
|
||||||
|
docs-examples: todo
|
||||||
|
|
||||||
|
# Platinum
|
||||||
|
async-dependency: done
|
||||||
|
inject-websession: done
|
||||||
|
strict-typing: todo
|
||||||
20
homeassistant/components/fluss/strings.json
Normal file
20
homeassistant/components/fluss/strings.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Your Fluss API key, available in the profile page of the Fluss+ app",
|
||||||
|
"data": {
|
||||||
|
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"api_key": "The API key found in the profile page of the Fluss+ app."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
|
|||||||
async def stream_source(self) -> str | None:
|
async def stream_source(self) -> str | None:
|
||||||
"""Return the stream source."""
|
"""Return the stream source."""
|
||||||
if self._rtsp_port:
|
if self._rtsp_port:
|
||||||
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
_username = quote(self._username)
|
||||||
|
_password = quote(self._password)
|
||||||
|
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
sidebar_title="climate",
|
sidebar_title="climate",
|
||||||
sidebar_default_visible=False,
|
sidebar_default_visible=False,
|
||||||
)
|
)
|
||||||
|
async_register_built_in_panel(
|
||||||
|
hass,
|
||||||
|
"home",
|
||||||
|
sidebar_icon="mdi:home",
|
||||||
|
sidebar_title="home",
|
||||||
|
sidebar_default_visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
async_register_built_in_panel(hass, "profile")
|
async_register_built_in_panel(hass, "profile")
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["googleapiclient"],
|
"loggers": ["googleapiclient"],
|
||||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
|
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,6 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aiohomeconnect"],
|
"loggers": ["aiohomeconnect"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aiohomeconnect==0.23.0"],
|
"requirements": ["aiohomeconnect==0.23.1"],
|
||||||
"zeroconf": ["_homeconnect._tcp.local."]
|
"zeroconf": ["_homeconnect._tcp.local."]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
|||||||
"""Set the program value."""
|
"""Set the program value."""
|
||||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||||
self._attr_current_option = (
|
self._attr_current_option = (
|
||||||
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
|
PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
|
||||||
if event
|
if event and isinstance(event_value := event.value, str)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
|||||||
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
||||||
self._update_native_value(status)
|
self._update_native_value(status)
|
||||||
|
|
||||||
def _update_native_value(self, status: str | float) -> None:
|
def _update_native_value(self, status: str | float | None) -> None:
|
||||||
"""Set the value of the sensor based on the given value."""
|
"""Set the value of the sensor based on the given value."""
|
||||||
|
if status is None:
|
||||||
|
self._attr_native_value = None
|
||||||
|
return
|
||||||
match self.device_class:
|
match self.device_class:
|
||||||
case SensorDeviceClass.TIMESTAMP:
|
case SensorDeviceClass.TIMESTAMP:
|
||||||
self._attr_native_value = dt_util.utcnow() + timedelta(
|
self._attr_native_value = dt_util.utcnow() + timedelta(
|
||||||
|
|||||||
@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
||||||
|
|
||||||
context: ConfigFlowContext
|
context: ConfigFlowContext
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
|
|
||||||
ZIGBEE_BAUDRATE = 460800
|
ZIGBEE_BAUDRATE = 460800
|
||||||
|
|
||||||
|
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
|
||||||
|
# baudrate method. Since the two are mutually exclusive we just use both.
|
||||||
|
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
]
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
|
||||||
"integration_type": "hardware",
|
"integration_type": "hardware",
|
||||||
|
"loggers": [
|
||||||
|
"bellows",
|
||||||
|
"universal_silabs_flasher",
|
||||||
|
"zigpy.serial",
|
||||||
|
"serial_asyncio_fast"
|
||||||
|
],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"usb": [
|
"usb": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantConnectZBT2ConfigEntry
|
from . import HomeAssistantConnectZBT2ConfigEntry
|
||||||
|
from .config_flow import ZBT2FirmwareMixin
|
||||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -134,7 +134,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Connect ZBT-2 firmware update entity."""
|
"""Connect ZBT-2 firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.RTS_DTR]
|
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
|
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
|
|
||||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
||||||
|
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
|
||||||
|
|
||||||
_picked_firmware_type: PickedFirmwareType
|
_picked_firmware_type: PickedFirmwareType
|
||||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||||
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
# Installing new firmware is only truly required if the wrong type is
|
# Installing new firmware is only truly required if the wrong type is
|
||||||
# installed: upgrading to the latest release of the current firmware type
|
# installed: upgrading to the latest release of the current firmware type
|
||||||
# isn't strictly necessary for functionality.
|
# isn't strictly necessary for functionality.
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
|
self._device,
|
||||||
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
firmware_install_required = self._probed_firmware_info is None or (
|
firmware_install_required = self._probed_firmware_info is None or (
|
||||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||||
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
progress_callback=lambda offset, total: self.async_update_progress(
|
progress_callback=lambda offset, total: self.async_update_progress(
|
||||||
offset / total
|
offset / total
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"universal-silabs-flasher==0.0.37",
|
"universal-silabs-flasher==0.1.0",
|
||||||
"ha-silabs-firmware-client==0.3.0"
|
"ha-silabs-firmware-client==0.3.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
|
|||||||
|
|
||||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||||
entity_description: FirmwareUpdateEntityDescription
|
entity_description: FirmwareUpdateEntityDescription
|
||||||
bootloader_reset_methods: list[ResetTarget] = []
|
BOOTLOADER_RESET_METHODS: list[ResetTarget]
|
||||||
|
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
|
||||||
|
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||||
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
|
|||||||
device=self._current_device,
|
device=self._current_device,
|
||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||||
bootloader_reset_methods=self.bootloader_reset_methods,
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
progress_callback=self._update_progress,
|
progress_callback=self._update_progress,
|
||||||
domain=self._config_entry.domain,
|
domain=self._config_entry.domain,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
from collections.abc import AsyncIterator, Callable, Sequence
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
|
|||||||
|
|
||||||
|
|
||||||
async def probe_silabs_firmware_info(
|
async def probe_silabs_firmware_info(
|
||||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
device: str,
|
||||||
|
*,
|
||||||
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
) -> FirmwareInfo | None:
|
) -> FirmwareInfo | None:
|
||||||
"""Probe the running firmware on a SiLabs device."""
|
"""Probe the running firmware on a SiLabs device."""
|
||||||
flasher = Flasher(
|
flasher = Flasher(
|
||||||
device=device,
|
device=device,
|
||||||
**(
|
probe_methods=tuple(
|
||||||
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
|
(m.as_flasher_application_type(), baudrate)
|
||||||
if probe_methods
|
for m, baudrate in application_probe_methods
|
||||||
else {}
|
),
|
||||||
|
bootloader_reset=tuple(
|
||||||
|
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
|
|||||||
|
|
||||||
|
|
||||||
async def probe_silabs_firmware_type(
|
async def probe_silabs_firmware_type(
|
||||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
device: str,
|
||||||
|
*,
|
||||||
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
) -> ApplicationType | None:
|
) -> ApplicationType | None:
|
||||||
"""Probe the running firmware type on a SiLabs device."""
|
"""Probe the running firmware type on a SiLabs device."""
|
||||||
|
|
||||||
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
|
fw_info = await probe_silabs_firmware_info(
|
||||||
|
device,
|
||||||
|
bootloader_reset_methods=bootloader_reset_methods,
|
||||||
|
application_probe_methods=application_probe_methods,
|
||||||
|
)
|
||||||
if fw_info is None:
|
if fw_info is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
|
|||||||
device: str,
|
device: str,
|
||||||
fw_data: bytes,
|
fw_data: bytes,
|
||||||
expected_installed_firmware_type: ApplicationType,
|
expected_installed_firmware_type: ApplicationType,
|
||||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
progress_callback: Callable[[int, int], None] | None = None,
|
progress_callback: Callable[[int, int], None] | None = None,
|
||||||
*,
|
*,
|
||||||
domain: str = DOMAIN,
|
domain: str = DOMAIN,
|
||||||
) -> FirmwareInfo:
|
) -> FirmwareInfo:
|
||||||
"""Flash firmware to the SiLabs device."""
|
"""Flash firmware to the SiLabs device."""
|
||||||
|
if not any(
|
||||||
|
method == expected_installed_firmware_type
|
||||||
|
for method, _ in application_probe_methods
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected installed firmware type {expected_installed_firmware_type!r}"
|
||||||
|
f" not in application probe methods {application_probe_methods!r}"
|
||||||
|
)
|
||||||
|
|
||||||
async with async_firmware_update_context(hass, device, domain):
|
async with async_firmware_update_context(hass, device, domain):
|
||||||
firmware_info = await guess_firmware_info(hass, device)
|
firmware_info = await guess_firmware_info(hass, device)
|
||||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||||
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
|
|||||||
|
|
||||||
flasher = Flasher(
|
flasher = Flasher(
|
||||||
device=device,
|
device=device,
|
||||||
probe_methods=(
|
probe_methods=tuple(
|
||||||
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
|
(m.as_flasher_application_type(), baudrate)
|
||||||
ApplicationType.EZSP.as_flasher_application_type(),
|
for m, baudrate in application_probe_methods
|
||||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
|
||||||
ApplicationType.CPC.as_flasher_application_type(),
|
|
||||||
),
|
),
|
||||||
bootloader_reset=tuple(
|
bootloader_reset=tuple(
|
||||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||||
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
|
|||||||
|
|
||||||
probed_firmware_info = await probe_silabs_firmware_info(
|
probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
device,
|
device,
|
||||||
probe_methods=(expected_installed_firmware_type,),
|
bootloader_reset_methods=bootloader_reset_methods,
|
||||||
|
# Only probe for the expected installed firmware type
|
||||||
|
application_probe_methods=[
|
||||||
|
(method, baudrate)
|
||||||
|
for method, baudrate in application_probe_methods
|
||||||
|
if method == expected_installed_firmware_type
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
if probed_firmware_info is None:
|
if probed_firmware_info is None:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
|
ResetTarget,
|
||||||
)
|
)
|
||||||
from homeassistant.components.usb import (
|
from homeassistant.components.usb import (
|
||||||
usb_service_info_from_device,
|
usb_service_info_from_device,
|
||||||
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
|
|
||||||
context: ConfigFlowContext
|
context: ConfigFlowContext
|
||||||
|
|
||||||
|
ZIGBEE_BAUDRATE = 115200
|
||||||
|
# There is no hardware bootloader trigger
|
||||||
|
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
# CPC baudrates can be removed once multiprotocol is removed
|
||||||
|
(ApplicationType.CPC, 115200),
|
||||||
|
(ApplicationType.CPC, 230400),
|
||||||
|
(ApplicationType.CPC, 460800),
|
||||||
|
(ApplicationType.ROUTER, 115200),
|
||||||
|
]
|
||||||
|
|
||||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||||
"""Shared translation placeholders."""
|
"""Shared translation placeholders."""
|
||||||
placeholders = {
|
placeholders = {
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
|
||||||
"integration_type": "hardware",
|
"integration_type": "hardware",
|
||||||
|
"loggers": [
|
||||||
|
"bellows",
|
||||||
|
"universal_silabs_flasher",
|
||||||
|
"zigpy.serial",
|
||||||
|
"serial_asyncio_fast"
|
||||||
|
],
|
||||||
"usb": [
|
"usb": [
|
||||||
{
|
{
|
||||||
"description": "*skyconnect v1.0*",
|
"description": "*skyconnect v1.0*",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantSkyConnectConfigEntry
|
from . import HomeAssistantSkyConnectConfigEntry
|
||||||
|
from .config_flow import SkyConnectFirmwareMixin
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
FIRMWARE,
|
FIRMWARE,
|
||||||
@@ -151,8 +152,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""SkyConnect firmware update entity."""
|
"""SkyConnect firmware update entity."""
|
||||||
|
|
||||||
# The ZBT-1 does not have a hardware bootloader trigger
|
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
bootloader_reset_methods = []
|
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -82,7 +82,18 @@ else:
|
|||||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||||
|
|
||||||
|
ZIGBEE_BAUDRATE = 115200
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
# CPC baudrates can be removed once multiprotocol is removed
|
||||||
|
(ApplicationType.CPC, 115200),
|
||||||
|
(ApplicationType.CPC, 230400),
|
||||||
|
(ApplicationType.CPC, 460800),
|
||||||
|
(ApplicationType.ROUTER, 115200),
|
||||||
|
]
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
|
|||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
|
|
||||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
|
self._device,
|
||||||
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -7,5 +7,11 @@
|
|||||||
"dependencies": ["hardware", "homeassistant_hardware"],
|
"dependencies": ["hardware", "homeassistant_hardware"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
|
||||||
"integration_type": "hardware",
|
"integration_type": "hardware",
|
||||||
|
"loggers": [
|
||||||
|
"bellows",
|
||||||
|
"universal_silabs_flasher",
|
||||||
|
"zigpy.serial",
|
||||||
|
"serial_asyncio_fast"
|
||||||
|
],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantYellowConfigEntry
|
from . import HomeAssistantYellowConfigEntry
|
||||||
|
from .config_flow import YellowFirmwareMixin
|
||||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -150,7 +150,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Yellow firmware update entity."""
|
"""Yellow firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
|
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -37,5 +37,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["pylamarzocco"],
|
"loggers": ["pylamarzocco"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["pylamarzocco==2.1.2"]
|
"requirements": ["pylamarzocco==2.1.3"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
|
|||||||
await self.coordinator.device.update_firmware()
|
await self.coordinator.device.update_firmware()
|
||||||
while (
|
while (
|
||||||
update_progress := await self.coordinator.device.get_firmware()
|
update_progress := await self.coordinator.device.get_firmware()
|
||||||
).command_status is UpdateStatus.IN_PROGRESS:
|
).command_status is not UpdateStatus.UPDATED:
|
||||||
if counter >= MAX_UPDATE_WAIT:
|
if counter >= MAX_UPDATE_WAIT:
|
||||||
_raise_timeout_error()
|
_raise_timeout_error()
|
||||||
self._attr_update_percentage = update_progress.progress_percentage
|
self._attr_update_percentage = update_progress.progress_percentage
|
||||||
|
|||||||
@@ -462,40 +462,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
"""Provides triggers for lights."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast, override
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
CONF_OPTIONS,
|
|
||||||
CONF_TARGET,
|
|
||||||
STATE_OFF,
|
|
||||||
STATE_ON,
|
|
||||||
STATE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.target import (
|
|
||||||
TargetStateChangedData,
|
|
||||||
async_track_target_selector_state_change_event,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
ATTR_BRIGHTNESS = "brightness"
|
|
||||||
CONF_LOWER = "lower"
|
|
||||||
CONF_UPPER = "upper"
|
|
||||||
CONF_ABOVE = "above"
|
|
||||||
CONF_BELOW = "below"
|
|
||||||
|
|
||||||
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
BRIGHTNESS_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Exclusive(CONF_LOWER, "brightness_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
|
||||||
),
|
|
||||||
vol.Exclusive(CONF_UPPER, "brightness_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
|
||||||
),
|
|
||||||
vol.Exclusive(CONF_ABOVE, "brightness_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
|
||||||
),
|
|
||||||
vol.Exclusive(CONF_BELOW, "brightness_range"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=0, max=255)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LightTurnsOnTrigger(Trigger):
|
|
||||||
"""Trigger for when a light turns on."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the light turns on trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when light turns on (from off to on)
|
|
||||||
if from_state and from_state.state == STATE_OFF and to_state.state == STATE_ON:
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"light turned on on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LightTurnsOffTrigger(Trigger):
|
|
||||||
"""Trigger for when a light turns off."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the light turns off trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when light turns off (from on to off)
|
|
||||||
if from_state and from_state.state == STATE_ON and to_state.state == STATE_OFF:
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"light turned off on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LightBrightnessChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a light's brightness changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, BRIGHTNESS_CHANGED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the light brightness changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
self._options = config.options or {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
lower_limit = self._options.get(CONF_LOWER)
|
|
||||||
upper_limit = self._options.get(CONF_UPPER)
|
|
||||||
above_limit = self._options.get(CONF_ABOVE)
|
|
||||||
below_limit = self._options.get(CONF_BELOW)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get brightness values
|
|
||||||
from_brightness = (
|
|
||||||
from_state.attributes.get(ATTR_BRIGHTNESS) if from_state else None
|
|
||||||
)
|
|
||||||
to_brightness = to_state.attributes.get(ATTR_BRIGHTNESS)
|
|
||||||
|
|
||||||
# Only trigger if brightness value exists and has changed
|
|
||||||
if to_brightness is None or from_brightness == to_brightness:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Apply threshold filters if configured
|
|
||||||
if lower_limit is not None and to_brightness < lower_limit:
|
|
||||||
return
|
|
||||||
if upper_limit is not None and to_brightness > upper_limit:
|
|
||||||
return
|
|
||||||
if above_limit is not None and to_brightness <= above_limit:
|
|
||||||
return
|
|
||||||
if below_limit is not None and to_brightness >= below_limit:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
"from_brightness": from_brightness,
|
|
||||||
"to_brightness": to_brightness,
|
|
||||||
},
|
|
||||||
f"brightness changed on {entity_id}",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
|
||||||
"turns_on": LightTurnsOnTrigger,
|
|
||||||
"turns_off": LightTurnsOffTrigger,
|
|
||||||
"brightness_changed": LightBrightnessChangedTrigger,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
|
||||||
"""Return the triggers for lights."""
|
|
||||||
return TRIGGERS
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
turns_on:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: light
|
|
||||||
|
|
||||||
turns_off:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: light
|
|
||||||
|
|
||||||
brightness_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: light
|
|
||||||
fields:
|
|
||||||
lower:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 255
|
|
||||||
mode: box
|
|
||||||
upper:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 255
|
|
||||||
mode: box
|
|
||||||
above:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 255
|
|
||||||
mode: box
|
|
||||||
below:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0
|
|
||||||
max: 255
|
|
||||||
mode: box
|
|
||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["ical"],
|
"loggers": ["ical"],
|
||||||
"requirements": ["ical==11.0.0"]
|
"requirements": ["ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["ical==11.0.0"]
|
"requirements": ["ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["lunatone-rest-api-client==0.5.3"]
|
"requirements": ["lunatone-rest-api-client==0.5.7"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,63 +367,5 @@
|
|||||||
"name": "Turn up volume"
|
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,676 +0,0 @@
|
|||||||
"""Provides triggers for media players."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast, override
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
CONF_OPTIONS,
|
|
||||||
CONF_TARGET,
|
|
||||||
STATE_IDLE,
|
|
||||||
STATE_OFF,
|
|
||||||
STATE_PAUSED,
|
|
||||||
STATE_PLAYING,
|
|
||||||
STATE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.target import (
|
|
||||||
TargetStateChangedData,
|
|
||||||
async_track_target_selector_state_change_event,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
ATTR_MEDIA_CONTENT_TYPE,
|
|
||||||
ATTR_MEDIA_VOLUME_LEVEL,
|
|
||||||
ATTR_MEDIA_VOLUME_MUTED,
|
|
||||||
DOMAIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
PLAYING_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional(ATTR_MEDIA_CONTENT_TYPE, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [cv.string]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
STOPPED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
MUTED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
UNMUTED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
VOLUME_CHANGED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {
|
|
||||||
vol.Optional("above"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
|
|
||||||
vol.Optional("below"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
|
|
||||||
},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PAUSED_TRIGGER_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_OPTIONS, default={}): {},
|
|
||||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerTurnsOnTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player turns on."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player turns on trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when turning on from off state
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.state == STATE_OFF
|
|
||||||
and to_state.state != STATE_OFF
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} turned on",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerTurnsOffTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player turns off."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player turns off trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when turning off
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.state != STATE_OFF
|
|
||||||
and to_state.state == STATE_OFF
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} turned off",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerPlayingTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player starts playing."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, PLAYING_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player playing trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
media_content_types_filter = self._options[ATTR_MEDIA_CONTENT_TYPE]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when starting to play
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.state != STATE_PLAYING
|
|
||||||
and to_state.state == STATE_PLAYING
|
|
||||||
):
|
|
||||||
# If media_content_type filter is specified, check if it matches
|
|
||||||
if media_content_types_filter:
|
|
||||||
media_content_type = to_state.attributes.get(ATTR_MEDIA_CONTENT_TYPE)
|
|
||||||
if media_content_type not in media_content_types_filter:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} started playing",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerPausedTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player pauses."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, PAUSED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player paused trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when pausing
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.state != STATE_PAUSED
|
|
||||||
and to_state.state == STATE_PAUSED
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} paused",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerStoppedTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player stops playing."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, STOPPED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player stopped trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when stopping (to idle or off from playing/paused states)
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.state in (STATE_PLAYING, STATE_PAUSED)
|
|
||||||
and to_state.state in (STATE_IDLE, STATE_OFF)
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} stopped",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerMutedTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player gets muted."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, MUTED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player muted trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when muting
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and not from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
|
||||||
and to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} muted",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerUnmutedTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player gets unmuted."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, UNMUTED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player unmuted trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.target is not None
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trigger when unmuting
|
|
||||||
if (
|
|
||||||
from_state is not None
|
|
||||||
and from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
|
||||||
and not to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
|
|
||||||
):
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} unmuted",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerVolumeChangedTrigger(Trigger):
|
|
||||||
"""Trigger for when a media player volume changes."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
return cast(ConfigType, VOLUME_CHANGED_TRIGGER_SCHEMA(config))
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
||||||
"""Initialize the media player volume changed trigger."""
|
|
||||||
super().__init__(hass, config)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert config.options is not None
|
|
||||||
assert config.target is not None
|
|
||||||
self._options = config.options
|
|
||||||
self._target = config.target
|
|
||||||
|
|
||||||
@override
|
|
||||||
async def async_attach_runner(
|
|
||||||
self, run_action: TriggerActionRunner
|
|
||||||
) -> CALLBACK_TYPE:
|
|
||||||
"""Attach the trigger to an action runner."""
|
|
||||||
above_threshold = self._options.get("above")
|
|
||||||
below_threshold = self._options.get("below")
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def state_change_listener(
|
|
||||||
target_state_change_data: TargetStateChangedData,
|
|
||||||
) -> None:
|
|
||||||
"""Listen for state changes and call action."""
|
|
||||||
event = target_state_change_data.state_change_event
|
|
||||||
entity_id = event.data["entity_id"]
|
|
||||||
from_state = event.data["old_state"]
|
|
||||||
to_state = event.data["new_state"]
|
|
||||||
|
|
||||||
# Ignore unavailable states
|
|
||||||
if to_state is None or to_state.state == STATE_UNAVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get volume levels
|
|
||||||
old_volume = (
|
|
||||||
from_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
|
|
||||||
if from_state is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
new_volume = to_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
|
|
||||||
|
|
||||||
# Volume must have changed
|
|
||||||
if old_volume == new_volume or new_volume is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check thresholds if specified
|
|
||||||
if above_threshold is not None and new_volume <= above_threshold:
|
|
||||||
return
|
|
||||||
|
|
||||||
if below_threshold is not None and new_volume >= below_threshold:
|
|
||||||
return
|
|
||||||
|
|
||||||
run_action(
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: entity_id,
|
|
||||||
"from_state": from_state,
|
|
||||||
"to_state": to_state,
|
|
||||||
},
|
|
||||||
f"media player {entity_id} volume changed",
|
|
||||||
event.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entity_filter(entities: set[str]) -> set[str]:
|
|
||||||
"""Filter entities of this domain."""
|
|
||||||
return {
|
|
||||||
entity_id
|
|
||||||
for entity_id in entities
|
|
||||||
if split_entity_id(entity_id)[0] == DOMAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return async_track_target_selector_state_change_event(
|
|
||||||
self._hass, self._target, state_change_listener, entity_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
|
||||||
"turns_on": MediaPlayerTurnsOnTrigger,
|
|
||||||
"turns_off": MediaPlayerTurnsOffTrigger,
|
|
||||||
"playing": MediaPlayerPlayingTrigger,
|
|
||||||
"paused": MediaPlayerPausedTrigger,
|
|
||||||
"stopped": MediaPlayerStoppedTrigger,
|
|
||||||
"muted": MediaPlayerMutedTrigger,
|
|
||||||
"unmuted": MediaPlayerUnmutedTrigger,
|
|
||||||
"volume_changed": MediaPlayerVolumeChangedTrigger,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
|
||||||
"""Return the triggers for media players."""
|
|
||||||
return TRIGGERS
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
turns_on:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
|
|
||||||
turns_off:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
|
|
||||||
playing:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
fields:
|
|
||||||
media_content_type:
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
multiple: true
|
|
||||||
custom_value: true
|
|
||||||
options: []
|
|
||||||
|
|
||||||
paused:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
|
|
||||||
stopped:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
|
|
||||||
muted:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
|
|
||||||
unmuted:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
|
|
||||||
volume_changed:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
fields:
|
|
||||||
above:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0.0
|
|
||||||
max: 1.0
|
|
||||||
step: 0.01
|
|
||||||
mode: slider
|
|
||||||
below:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 0.0
|
|
||||||
max: 1.0
|
|
||||||
step: 0.01
|
|
||||||
mode: slider
|
|
||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/mill",
|
"documentation": "https://www.home-assistant.io/integrations/mill",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["mill", "mill_local"],
|
"loggers": ["mill", "mill_local"],
|
||||||
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
|
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities([MobileAppBinarySensor(data, config_entry)])
|
async_add_entities([MobileAppBinarySensor(data, config_entry)])
|
||||||
|
|
||||||
async_dispatcher_connect(
|
config_entry.async_on_unload(
|
||||||
hass,
|
async_dispatcher_connect(
|
||||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
hass,
|
||||||
handle_sensor_registration,
|
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||||
|
handle_sensor_registration,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,10 +72,12 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities([MobileAppSensor(data, config_entry)])
|
async_add_entities([MobileAppSensor(data, config_entry)])
|
||||||
|
|
||||||
async_dispatcher_connect(
|
config_entry.async_on_unload(
|
||||||
hass,
|
async_dispatcher_connect(
|
||||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
hass,
|
||||||
handle_sensor_registration,
|
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||||
|
handle_sensor_registration,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||||
},
|
},
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
},
|
},
|
||||||
"update_failed": {
|
"update_failed": {
|
||||||
"message": "Failed to update drive state"
|
"message": "Failed to update drive state"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
|
||||||
from ..const import SupportedDialect
|
from ..const import SupportedDialect
|
||||||
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE
|
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE, MYSQL_COLLATE
|
||||||
from ..util import session_scope
|
from ..util import session_scope
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -105,12 +105,13 @@ def _validate_table_schema_has_correct_collation(
|
|||||||
or dialect_kwargs.get("mariadb_collate")
|
or dialect_kwargs.get("mariadb_collate")
|
||||||
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
|
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
|
||||||
)
|
)
|
||||||
if collate and collate != "utf8mb4_unicode_ci":
|
if collate and collate != MYSQL_COLLATE:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Database %s collation is not utf8mb4_unicode_ci",
|
"Database %s collation is not %s",
|
||||||
table,
|
table,
|
||||||
|
MYSQL_COLLATE,
|
||||||
)
|
)
|
||||||
schema_errors.add(f"{table}.utf8mb4_unicode_ci")
|
schema_errors.add(f"{table}.{MYSQL_COLLATE}")
|
||||||
return schema_errors
|
return schema_errors
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ def correct_db_schema_utf8(
|
|||||||
table_name = table_object.__tablename__
|
table_name = table_object.__tablename__
|
||||||
if (
|
if (
|
||||||
f"{table_name}.4-byte UTF-8" in schema_errors
|
f"{table_name}.4-byte UTF-8" in schema_errors
|
||||||
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
|
or f"{table_name}.{MYSQL_COLLATE}" in schema_errors
|
||||||
):
|
):
|
||||||
from ..migration import ( # noqa: PLC0415
|
from ..migration import ( # noqa: PLC0415
|
||||||
_correct_table_character_set_and_collation,
|
_correct_table_character_set_and_collation,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
|
|||||||
"""Base class for tables, used for schema migration."""
|
"""Base class for tables, used for schema migration."""
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_VERSION = 52
|
SCHEMA_VERSION = 53
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_update
|
|||||||
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
|
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
|
||||||
CONTEXT_ID_BIN_MAX_LENGTH = 16
|
CONTEXT_ID_BIN_MAX_LENGTH = 16
|
||||||
|
|
||||||
MYSQL_COLLATE = "utf8mb4_unicode_ci"
|
MYSQL_COLLATE = "utf8mb4_bin"
|
||||||
MYSQL_DEFAULT_CHARSET = "utf8mb4"
|
MYSQL_DEFAULT_CHARSET = "utf8mb4"
|
||||||
MYSQL_ENGINE = "InnoDB"
|
MYSQL_ENGINE = "InnoDB"
|
||||||
|
|
||||||
|
|||||||
@@ -1361,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
|
|||||||
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
|
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
|
||||||
def _apply_update(self) -> None:
|
def _apply_update(self) -> None:
|
||||||
"""Version specific update method."""
|
"""Version specific update method."""
|
||||||
# Try to change the character set of the statistic_meta table
|
# Try to change the character set of events, states and statistics_meta tables
|
||||||
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||||
for table in ("events", "states", "statistics_meta"):
|
for table in ("events", "states", "statistics_meta"):
|
||||||
_correct_table_character_set_and_collation(table, self.session_maker)
|
_correct_table_character_set_and_collation(table, self.session_maker)
|
||||||
@@ -2125,6 +2125,23 @@ class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaVersion53Migrator(_SchemaVersionMigrator, target_version=53):
|
||||||
|
def _apply_update(self) -> None:
|
||||||
|
"""Version specific update method."""
|
||||||
|
# Try to change the character set of events, states and statistics_meta tables
|
||||||
|
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||||
|
for table in (
|
||||||
|
"events",
|
||||||
|
"event_data",
|
||||||
|
"states",
|
||||||
|
"state_attributes",
|
||||||
|
"statistics",
|
||||||
|
"statistics_meta",
|
||||||
|
"statistics_short_term",
|
||||||
|
):
|
||||||
|
_correct_table_character_set_and_collation(table, self.session_maker)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
|
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
instance: Recorder,
|
instance: Recorder,
|
||||||
@@ -2167,8 +2184,10 @@ def _correct_table_character_set_and_collation(
|
|||||||
"""Correct issues detected by validate_db_schema."""
|
"""Correct issues detected by validate_db_schema."""
|
||||||
# Attempt to convert the table to utf8mb4
|
# Attempt to convert the table to utf8mb4
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Updating character set and collation of table %s to utf8mb4. %s",
|
"Updating table %s to character set %s and collation %s. %s",
|
||||||
table,
|
table,
|
||||||
|
MYSQL_DEFAULT_CHARSET,
|
||||||
|
MYSQL_COLLATE,
|
||||||
MIGRATION_NOTE_MINUTES,
|
MIGRATION_NOTE_MINUTES,
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["ical"],
|
"loggers": ["ical"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["ical==11.0.0"]
|
"requirements": ["ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Ruuvitag BLE device from a config entry."""
|
"""Set up Ruuvi BLE device from a config entry."""
|
||||||
address = entry.unique_id
|
address = entry.unique_id
|
||||||
assert address is not None
|
assert address is not None
|
||||||
data = RuuvitagBluetoothDeviceData()
|
data = RuuvitagBluetoothDeviceData()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"domain": "ruuvitag_ble",
|
"domain": "ruuvitag_ble",
|
||||||
"name": "RuuviTag BLE",
|
"name": "Ruuvi BLE",
|
||||||
"bluetooth": [
|
"bluetooth": [
|
||||||
{
|
{
|
||||||
"connectable": false,
|
"connectable": false,
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ async def async_setup_entry(
|
|||||||
entry: config_entries.ConfigEntry,
|
entry: config_entries.ConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Ruuvitag BLE sensors."""
|
"""Set up the Ruuvi BLE sensors."""
|
||||||
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||||
entry.entry_id
|
entry.entry_id
|
||||||
]
|
]
|
||||||
@@ -210,7 +210,7 @@ class RuuvitagBluetoothSensorEntity(
|
|||||||
],
|
],
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
):
|
):
|
||||||
"""Representation of a Ruuvitag BLE sensor."""
|
"""Representation of a Ruuvi BLE sensor."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> int | float | None:
|
def native_value(self) -> int | float | None:
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import OrderedDict
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from satel_integra.satel_integra import AlarmState
|
from satel_integra.satel_integra import AlarmState, AsyncSatel
|
||||||
|
|
||||||
from homeassistant.components.alarm_control_panel import (
|
from homeassistant.components.alarm_control_panel import (
|
||||||
AlarmControlPanelEntity,
|
AlarmControlPanelEntity,
|
||||||
@@ -14,7 +13,7 @@ from homeassistant.components.alarm_control_panel import (
|
|||||||
AlarmControlPanelState,
|
AlarmControlPanelState,
|
||||||
CodeFormat,
|
CodeFormat,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.config_entries import ConfigSubentry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
@@ -26,6 +25,19 @@ from .const import (
|
|||||||
SUBENTRY_TYPE_PARTITION,
|
SUBENTRY_TYPE_PARTITION,
|
||||||
SatelConfigEntry,
|
SatelConfigEntry,
|
||||||
)
|
)
|
||||||
|
from .entity import SatelIntegraEntity
|
||||||
|
|
||||||
|
ALARM_STATE_MAP = {
|
||||||
|
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
|
||||||
|
AlarmState.TRIGGERED_FIRE: AlarmControlPanelState.TRIGGERED,
|
||||||
|
AlarmState.ENTRY_TIME: AlarmControlPanelState.PENDING,
|
||||||
|
AlarmState.ARMED_MODE3: AlarmControlPanelState.ARMED_HOME,
|
||||||
|
AlarmState.ARMED_MODE2: AlarmControlPanelState.ARMED_HOME,
|
||||||
|
AlarmState.ARMED_MODE1: AlarmControlPanelState.ARMED_HOME,
|
||||||
|
AlarmState.ARMED_MODE0: AlarmControlPanelState.ARMED_AWAY,
|
||||||
|
AlarmState.EXIT_COUNTDOWN_OVER_10: AlarmControlPanelState.ARMING,
|
||||||
|
AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING,
|
||||||
|
}
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -45,48 +57,54 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for subentry in partition_subentries:
|
for subentry in partition_subentries:
|
||||||
partition_num = subentry.data[CONF_PARTITION_NUMBER]
|
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
|
||||||
zone_name = subentry.data[CONF_NAME]
|
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
|
||||||
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
|
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
SatelIntegraAlarmPanel(
|
SatelIntegraAlarmPanel(
|
||||||
controller,
|
controller,
|
||||||
zone_name,
|
|
||||||
arm_home_mode,
|
|
||||||
partition_num,
|
|
||||||
config_entry.entry_id,
|
config_entry.entry_id,
|
||||||
|
subentry,
|
||||||
|
partition_num,
|
||||||
|
arm_home_mode,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
config_subentry_id=subentry.subentry_id,
|
config_subentry_id=subentry.subentry_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
|
||||||
"""Representation of an AlarmDecoder-based alarm panel."""
|
"""Representation of an AlarmDecoder-based alarm panel."""
|
||||||
|
|
||||||
_attr_code_format = CodeFormat.NUMBER
|
_attr_code_format = CodeFormat.NUMBER
|
||||||
_attr_should_poll = False
|
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
AlarmControlPanelEntityFeature.ARM_HOME
|
AlarmControlPanelEntityFeature.ARM_HOME
|
||||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, controller, name, arm_home_mode, partition_id, config_entry_id
|
self,
|
||||||
|
controller: AsyncSatel,
|
||||||
|
config_entry_id: str,
|
||||||
|
subentry: ConfigSubentry,
|
||||||
|
device_number: int,
|
||||||
|
arm_home_mode: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the alarm panel."""
|
"""Initialize the alarm panel."""
|
||||||
self._attr_name = name
|
super().__init__(
|
||||||
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
|
controller,
|
||||||
|
config_entry_id,
|
||||||
|
subentry,
|
||||||
|
device_number,
|
||||||
|
)
|
||||||
|
|
||||||
self._arm_home_mode = arm_home_mode
|
self._arm_home_mode = arm_home_mode
|
||||||
self._partition_id = partition_id
|
|
||||||
self._satel = controller
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Update alarm status and register callbacks for future updates."""
|
"""Update alarm status and register callbacks for future updates."""
|
||||||
_LOGGER.debug("Starts listening for panel messages")
|
self._attr_alarm_state = self._read_alarm_state()
|
||||||
self._update_alarm_status()
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
|
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
|
||||||
@@ -94,55 +112,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _update_alarm_status(self):
|
def _update_alarm_status(self) -> None:
|
||||||
"""Handle alarm status update."""
|
"""Handle alarm status update."""
|
||||||
state = self._read_alarm_state()
|
state = self._read_alarm_state()
|
||||||
_LOGGER.debug("Got status update, current status: %s", state)
|
|
||||||
if state != self._attr_alarm_state:
|
if state != self._attr_alarm_state:
|
||||||
self._attr_alarm_state = state
|
self._attr_alarm_state = state
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
else:
|
|
||||||
_LOGGER.debug("Ignoring alarm status message, same state")
|
|
||||||
|
|
||||||
def _read_alarm_state(self):
|
def _read_alarm_state(self) -> AlarmControlPanelState | None:
|
||||||
"""Read current status of the alarm and translate it into HA status."""
|
"""Read current status of the alarm and translate it into HA status."""
|
||||||
|
|
||||||
# Default - disarmed:
|
|
||||||
hass_alarm_status = AlarmControlPanelState.DISARMED
|
|
||||||
|
|
||||||
if not self._satel.connected:
|
if not self._satel.connected:
|
||||||
|
_LOGGER.debug("Alarm panel not connected")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
state_map = OrderedDict(
|
for satel_state, ha_state in ALARM_STATE_MAP.items():
|
||||||
[
|
|
||||||
(AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED),
|
|
||||||
(AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED),
|
|
||||||
(AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING),
|
|
||||||
(AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME),
|
|
||||||
(AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME),
|
|
||||||
(AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME),
|
|
||||||
(AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY),
|
|
||||||
(
|
|
||||||
AlarmState.EXIT_COUNTDOWN_OVER_10,
|
|
||||||
AlarmControlPanelState.PENDING,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
AlarmState.EXIT_COUNTDOWN_UNDER_10,
|
|
||||||
AlarmControlPanelState.PENDING,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
_LOGGER.debug("State map of Satel: %s", self._satel.partition_states)
|
|
||||||
|
|
||||||
for satel_state, ha_state in state_map.items():
|
|
||||||
if (
|
if (
|
||||||
satel_state in self._satel.partition_states
|
satel_state in self._satel.partition_states
|
||||||
and self._partition_id in self._satel.partition_states[satel_state]
|
and self._device_number in self._satel.partition_states[satel_state]
|
||||||
):
|
):
|
||||||
hass_alarm_status = ha_state
|
return ha_state
|
||||||
break
|
|
||||||
|
|
||||||
return hass_alarm_status
|
return AlarmControlPanelState.DISARMED
|
||||||
|
|
||||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||||
"""Send disarm command."""
|
"""Send disarm command."""
|
||||||
@@ -154,25 +146,21 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
|||||||
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
|
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug("Disarming, self._attr_alarm_state: %s", self._attr_alarm_state)
|
await self._satel.disarm(code, [self._device_number])
|
||||||
|
|
||||||
await self._satel.disarm(code, [self._partition_id])
|
|
||||||
|
|
||||||
if clear_alarm_necessary:
|
if clear_alarm_necessary:
|
||||||
# Wait 1s before clearing the alarm
|
# Wait 1s before clearing the alarm
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
await self._satel.clear_alarm(code, [self._partition_id])
|
await self._satel.clear_alarm(code, [self._device_number])
|
||||||
|
|
||||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||||
"""Send arm away command."""
|
"""Send arm away command."""
|
||||||
_LOGGER.debug("Arming away")
|
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
await self._satel.arm(code, [self._partition_id])
|
await self._satel.arm(code, [self._device_number])
|
||||||
|
|
||||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||||
"""Send arm home command."""
|
"""Send arm home command."""
|
||||||
_LOGGER.debug("Arming home")
|
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
|
await self._satel.arm(code, [self._device_number], self._arm_home_mode)
|
||||||
|
|||||||
@@ -8,25 +8,22 @@ from homeassistant.components.binary_sensor import (
|
|||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.config_entries import ConfigSubentry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_OUTPUT_NUMBER,
|
CONF_OUTPUT_NUMBER,
|
||||||
CONF_OUTPUTS,
|
|
||||||
CONF_ZONE_NUMBER,
|
CONF_ZONE_NUMBER,
|
||||||
CONF_ZONE_TYPE,
|
CONF_ZONE_TYPE,
|
||||||
CONF_ZONES,
|
|
||||||
DOMAIN,
|
|
||||||
SIGNAL_OUTPUTS_UPDATED,
|
SIGNAL_OUTPUTS_UPDATED,
|
||||||
SIGNAL_ZONES_UPDATED,
|
SIGNAL_ZONES_UPDATED,
|
||||||
SUBENTRY_TYPE_OUTPUT,
|
SUBENTRY_TYPE_OUTPUT,
|
||||||
SUBENTRY_TYPE_ZONE,
|
SUBENTRY_TYPE_ZONE,
|
||||||
SatelConfigEntry,
|
SatelConfigEntry,
|
||||||
)
|
)
|
||||||
|
from .entity import SatelIntegraEntity
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -46,18 +43,16 @@ async def async_setup_entry(
|
|||||||
for subentry in zone_subentries:
|
for subentry in zone_subentries:
|
||||||
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
|
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
|
||||||
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||||
zone_name: str = subentry.data[CONF_NAME]
|
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
SatelIntegraBinarySensor(
|
SatelIntegraBinarySensor(
|
||||||
controller,
|
controller,
|
||||||
zone_num,
|
|
||||||
zone_name,
|
|
||||||
zone_type,
|
|
||||||
CONF_ZONES,
|
|
||||||
SIGNAL_ZONES_UPDATED,
|
|
||||||
config_entry.entry_id,
|
config_entry.entry_id,
|
||||||
|
subentry,
|
||||||
|
zone_num,
|
||||||
|
zone_type,
|
||||||
|
SIGNAL_ZONES_UPDATED,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
config_subentry_id=subentry.subentry_id,
|
config_subentry_id=subentry.subentry_id,
|
||||||
@@ -71,51 +66,44 @@ async def async_setup_entry(
|
|||||||
for subentry in output_subentries:
|
for subentry in output_subentries:
|
||||||
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
|
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
|
||||||
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||||
output_name: str = subentry.data[CONF_NAME]
|
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
SatelIntegraBinarySensor(
|
SatelIntegraBinarySensor(
|
||||||
controller,
|
controller,
|
||||||
output_num,
|
|
||||||
output_name,
|
|
||||||
ouput_type,
|
|
||||||
CONF_OUTPUTS,
|
|
||||||
SIGNAL_OUTPUTS_UPDATED,
|
|
||||||
config_entry.entry_id,
|
config_entry.entry_id,
|
||||||
|
subentry,
|
||||||
|
output_num,
|
||||||
|
ouput_type,
|
||||||
|
SIGNAL_OUTPUTS_UPDATED,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
config_subentry_id=subentry.subentry_id,
|
config_subentry_id=subentry.subentry_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SatelIntegraBinarySensor(BinarySensorEntity):
|
class SatelIntegraBinarySensor(SatelIntegraEntity, BinarySensorEntity):
|
||||||
"""Representation of an Satel Integra binary sensor."""
|
"""Representation of an Satel Integra binary sensor."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
_attr_name = None
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
controller: AsyncSatel,
|
controller: AsyncSatel,
|
||||||
device_number: int,
|
|
||||||
device_name: str,
|
|
||||||
device_class: BinarySensorDeviceClass,
|
|
||||||
sensor_type: str,
|
|
||||||
react_to_signal: str,
|
|
||||||
config_entry_id: str,
|
config_entry_id: str,
|
||||||
|
subentry: ConfigSubentry,
|
||||||
|
device_number: int,
|
||||||
|
device_class: BinarySensorDeviceClass,
|
||||||
|
react_to_signal: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the binary_sensor."""
|
"""Initialize the binary_sensor."""
|
||||||
self._device_number = device_number
|
super().__init__(
|
||||||
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
|
controller,
|
||||||
self._react_to_signal = react_to_signal
|
config_entry_id,
|
||||||
self._satel = controller
|
subentry,
|
||||||
|
device_number,
|
||||||
|
)
|
||||||
|
|
||||||
self._attr_device_class = device_class
|
self._attr_device_class = device_class
|
||||||
self._attr_device_info = DeviceInfo(
|
self._react_to_signal = react_to_signal
|
||||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
|
|||||||
58
homeassistant/components/satel_integra/entity.py
Normal file
58
homeassistant/components/satel_integra/entity.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Satel Integra base entity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from satel_integra.satel_integra import AsyncSatel
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigSubentry
|
||||||
|
from homeassistant.const import CONF_NAME
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
SUBENTRY_TYPE_OUTPUT,
|
||||||
|
SUBENTRY_TYPE_PARTITION,
|
||||||
|
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
||||||
|
SUBENTRY_TYPE_ZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
SubentryTypeToEntityType: dict[str, str] = {
|
||||||
|
SUBENTRY_TYPE_PARTITION: "alarm_panel",
|
||||||
|
SUBENTRY_TYPE_SWITCHABLE_OUTPUT: "switch",
|
||||||
|
SUBENTRY_TYPE_ZONE: "zones",
|
||||||
|
SUBENTRY_TYPE_OUTPUT: "outputs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SatelIntegraEntity(Entity):
|
||||||
|
"""Defines a base Satel Integra entity."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
controller: AsyncSatel,
|
||||||
|
config_entry_id: str,
|
||||||
|
subentry: ConfigSubentry,
|
||||||
|
device_number: int,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the Satel Integra entity."""
|
||||||
|
|
||||||
|
self._satel = controller
|
||||||
|
self._device_number = device_number
|
||||||
|
|
||||||
|
entity_type = SubentryTypeToEntityType[subentry.subentry_type]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert entity_type is not None
|
||||||
|
|
||||||
|
self._attr_unique_id = f"{config_entry_id}_{entity_type}_{device_number}"
|
||||||
|
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
name=subentry.data[CONF_NAME], identifiers={(DOMAIN, self._attr_unique_id)}
|
||||||
|
)
|
||||||
@@ -7,19 +7,19 @@ from typing import Any
|
|||||||
from satel_integra.satel_integra import AsyncSatel
|
from satel_integra.satel_integra import AsyncSatel
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity
|
from homeassistant.components.switch import SwitchEntity
|
||||||
from homeassistant.const import CONF_CODE, CONF_NAME
|
from homeassistant.config_entries import ConfigSubentry
|
||||||
|
from homeassistant.const import CONF_CODE
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_SWITCHABLE_OUTPUT_NUMBER,
|
CONF_SWITCHABLE_OUTPUT_NUMBER,
|
||||||
DOMAIN,
|
|
||||||
SIGNAL_OUTPUTS_UPDATED,
|
SIGNAL_OUTPUTS_UPDATED,
|
||||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
||||||
SatelConfigEntry,
|
SatelConfigEntry,
|
||||||
)
|
)
|
||||||
|
from .entity import SatelIntegraEntity
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -38,47 +38,42 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
for subentry in switchable_output_subentries:
|
for subentry in switchable_output_subentries:
|
||||||
switchable_output_num: int = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
|
switchable_output_num: int = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
|
||||||
switchable_output_name: str = subentry.data[CONF_NAME]
|
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
SatelIntegraSwitch(
|
SatelIntegraSwitch(
|
||||||
controller,
|
controller,
|
||||||
switchable_output_num,
|
|
||||||
switchable_output_name,
|
|
||||||
config_entry.options.get(CONF_CODE),
|
|
||||||
config_entry.entry_id,
|
config_entry.entry_id,
|
||||||
|
subentry,
|
||||||
|
switchable_output_num,
|
||||||
|
config_entry.options.get(CONF_CODE),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
config_subentry_id=subentry.subentry_id,
|
config_subentry_id=subentry.subentry_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SatelIntegraSwitch(SwitchEntity):
|
class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
|
||||||
"""Representation of an Satel switch."""
|
"""Representation of an Satel Integra switch."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
_attr_name = None
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
controller: AsyncSatel,
|
controller: AsyncSatel,
|
||||||
device_number: int,
|
|
||||||
device_name: str,
|
|
||||||
code: str | None,
|
|
||||||
config_entry_id: str,
|
config_entry_id: str,
|
||||||
|
subentry: ConfigSubentry,
|
||||||
|
device_number: int,
|
||||||
|
code: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the switch."""
|
"""Initialize the switch."""
|
||||||
self._device_number = device_number
|
super().__init__(
|
||||||
self._attr_unique_id = f"{config_entry_id}_switch_{device_number}"
|
controller,
|
||||||
self._code = code
|
config_entry_id,
|
||||||
self._satel = controller
|
subentry,
|
||||||
|
device_number,
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._code = code
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
self._attr_is_on = self._device_number in self._satel.violated_outputs
|
self._attr_is_on = self._device_number in self._satel.violated_outputs
|
||||||
|
|||||||
@@ -118,6 +118,9 @@
|
|||||||
"pm25": {
|
"pm25": {
|
||||||
"default": "mdi:molecule"
|
"default": "mdi:molecule"
|
||||||
},
|
},
|
||||||
|
"pm4": {
|
||||||
|
"default": "mdi:molecule"
|
||||||
|
},
|
||||||
"power": {
|
"power": {
|
||||||
"default": "mdi:flash"
|
"default": "mdi:flash"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiosenz import SENZAPI, Thermostat
|
from aiosenz import SENZAPI, Thermostat
|
||||||
from httpx import RequestError
|
from httpx import HTTPStatusError, RequestError
|
||||||
|
import jwt
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import config_validation as cv, httpx_client
|
from homeassistant.helpers import config_validation as cv, httpx_client
|
||||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||||
ImplementationUnavailableError,
|
ImplementationUnavailableError,
|
||||||
@@ -32,9 +34,10 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|||||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||||
|
|
||||||
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
|
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
|
||||||
|
type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
|
||||||
"""Set up SENZ from a config entry."""
|
"""Set up SENZ from a config entry."""
|
||||||
try:
|
try:
|
||||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||||
@@ -57,8 +60,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
account = await senz_api.get_account()
|
account = await senz_api.get_account()
|
||||||
|
except HTTPStatusError as err:
|
||||||
|
if err.response.status_code == HTTPStatus.UNAUTHORIZED:
|
||||||
|
raise ConfigEntryAuthFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="config_entry_auth_failed",
|
||||||
|
) from err
|
||||||
|
raise ConfigEntryNotReady(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="config_entry_not_ready",
|
||||||
|
) from err
|
||||||
except RequestError as err:
|
except RequestError as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="config_entry_not_ready",
|
||||||
|
) from err
|
||||||
|
|
||||||
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
|
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
@@ -71,16 +87,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
|
||||||
|
|
||||||
return unload_ok
|
|
||||||
|
async def async_migrate_entry(
|
||||||
|
hass: HomeAssistant, config_entry: SENZConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
|
||||||
|
# Use sub(ject) from access_token as unique_id
|
||||||
|
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||||
|
token = jwt.decode(
|
||||||
|
config_entry.data["token"]["access_token"],
|
||||||
|
options={"verify_signature": False},
|
||||||
|
)
|
||||||
|
uid = token["sub"]
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, unique_id=uid, minor_version=2
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Migration to version %s.%s successful",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
@@ -12,24 +12,23 @@ from homeassistant.components.climate import (
|
|||||||
HVACAction,
|
HVACAction,
|
||||||
HVACMode,
|
HVACMode,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import SENZDataUpdateCoordinator
|
from . import SENZConfigEntry, SENZDataUpdateCoordinator
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: SENZConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the SENZ climate entities from a config entry."""
|
"""Set up the SENZ climate entities from a config entry."""
|
||||||
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = entry.runtime_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
|
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
"""Config flow for nVent RAYCHEM SENZ."""
|
"""Config flow for nVent RAYCHEM SENZ."""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from homeassistant.config_entries import (
|
||||||
|
SOURCE_REAUTH,
|
||||||
|
SOURCE_RECONFIGURE,
|
||||||
|
ConfigFlowResult,
|
||||||
|
)
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@@ -12,6 +21,8 @@ class OAuth2FlowHandler(
|
|||||||
):
|
):
|
||||||
"""Config flow to handle SENZ OAuth2 authentication."""
|
"""Config flow to handle SENZ OAuth2 authentication."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
MINOR_VERSION = 2
|
||||||
DOMAIN = DOMAIN
|
DOMAIN = DOMAIN
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -23,3 +34,49 @@ class OAuth2FlowHandler(
|
|||||||
def extra_authorize_data(self) -> dict:
|
def extra_authorize_data(self) -> dict:
|
||||||
"""Extra data that needs to be appended to the authorize url."""
|
"""Extra data that needs to be appended to the authorize url."""
|
||||||
return {"scope": "restapi offline_access"}
|
return {"scope": "restapi offline_access"}
|
||||||
|
|
||||||
|
async def async_step_reauth(
|
||||||
|
self, entry_data: Mapping[str, Any]
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Perform reauth upon an API authentication error."""
|
||||||
|
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Dialog that informs the user that reauth is required."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(step_id="reauth_confirm")
|
||||||
|
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
|
async def async_step_reconfigure(
|
||||||
|
self, user_input: Mapping[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""User initiated reconfiguration."""
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
|
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||||
|
"""Create or update the config entry."""
|
||||||
|
|
||||||
|
token = jwt.decode(
|
||||||
|
data["token"]["access_token"], options={"verify_signature": False}
|
||||||
|
)
|
||||||
|
uid = token["sub"]
|
||||||
|
await self.async_set_unique_id(uid)
|
||||||
|
|
||||||
|
if self.source == SOURCE_REAUTH:
|
||||||
|
self._abort_if_unique_id_mismatch(reason="account_mismatch")
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
self._get_reauth_entry(), data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
|
self._abort_if_unique_id_mismatch(reason="account_mismatch")
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
self._get_reconfigure_entry(), data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return await super().async_oauth_create_entry(data)
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.diagnostics import async_redact_data
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from . import SENZConfigEntry
|
||||||
|
|
||||||
TO_REDACT = [
|
TO_REDACT = [
|
||||||
"access_token",
|
"access_token",
|
||||||
@@ -15,13 +14,11 @@ TO_REDACT = [
|
|||||||
|
|
||||||
|
|
||||||
async def async_get_config_entry_diagnostics(
|
async def async_get_config_entry_diagnostics(
|
||||||
hass: HomeAssistant, entry: ConfigEntry
|
hass: HomeAssistant, entry: SENZConfigEntry
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return diagnostics for a config entry."""
|
"""Return diagnostics for a config entry."""
|
||||||
|
|
||||||
raw_data = (
|
raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],)
|
||||||
[device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ from homeassistant.components.sensor import (
|
|||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import UnitOfTemperature
|
from homeassistant.const import UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import SENZDataUpdateCoordinator
|
from . import SENZConfigEntry, SENZDataUpdateCoordinator
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
@@ -45,11 +44,11 @@ SENSORS: tuple[SenzSensorDescription, ...] = (
|
|||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: SENZConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the SENZ sensor entities from a config entry."""
|
"""Set up the SENZ sensor entities from a config entry."""
|
||||||
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = entry.runtime_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
SENZSensor(thermostat, coordinator, description)
|
SENZSensor(thermostat, coordinator, description)
|
||||||
for description in SENSORS
|
for description in SENSORS
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"abort": {
|
"abort": {
|
||||||
|
"account_mismatch": "The used account does not match the original account",
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||||
@@ -9,7 +10,9 @@
|
|||||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]"
|
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||||
},
|
},
|
||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||||
@@ -23,10 +26,20 @@
|
|||||||
"implementation": "[%key:common::config_flow::description::implementation%]"
|
"implementation": "[%key:common::config_flow::description::implementation%]"
|
||||||
},
|
},
|
||||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"description": "The SENZ integration needs to re-authenticate your account",
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
|
"config_entry_auth_failed": {
|
||||||
|
"message": "Authentication failed. Please log in again."
|
||||||
|
},
|
||||||
|
"config_entry_not_ready": {
|
||||||
|
"message": "Error while loading the integration."
|
||||||
|
},
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,5 +30,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["pysmartthings"],
|
"loggers": ["pysmartthings"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["pysmartthings==3.3.2"]
|
"requirements": ["pysmartthings==3.3.3"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -663,7 +663,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"system_health": {
|
"system_health": {
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ class AbstractTemplateAlarmControlPanel(
|
|||||||
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
|
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
|
||||||
self._attr_code_format = config[CONF_CODE_FORMAT].value
|
self._attr_code_format = config[CONF_CODE_FORMAT].value
|
||||||
|
|
||||||
self._state: AlarmControlPanelState | None = None
|
|
||||||
self._attr_supported_features: AlarmControlPanelEntityFeature = (
|
self._attr_supported_features: AlarmControlPanelEntityFeature = (
|
||||||
AlarmControlPanelEntityFeature(0)
|
AlarmControlPanelEntityFeature(0)
|
||||||
)
|
)
|
||||||
@@ -244,11 +243,6 @@ class AbstractTemplateAlarmControlPanel(
|
|||||||
if (action_config := config.get(action_id)) is not None:
|
if (action_config := config.get(action_id)) is not None:
|
||||||
yield (action_id, action_config, supported_feature)
|
yield (action_id, action_config, supported_feature)
|
||||||
|
|
||||||
@property
|
|
||||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
|
||||||
"""Return the state of the device."""
|
|
||||||
return self._state
|
|
||||||
|
|
||||||
async def _async_handle_restored_state(self) -> None:
|
async def _async_handle_restored_state(self) -> None:
|
||||||
if (
|
if (
|
||||||
(last_state := await self.async_get_last_state()) is not None
|
(last_state := await self.async_get_last_state()) is not None
|
||||||
@@ -256,14 +250,14 @@ class AbstractTemplateAlarmControlPanel(
|
|||||||
and last_state.state in _VALID_STATES
|
and last_state.state in _VALID_STATES
|
||||||
# The trigger might have fired already while we waited for stored data,
|
# The trigger might have fired already while we waited for stored data,
|
||||||
# then we should not restore state
|
# then we should not restore state
|
||||||
and self._state is None
|
and self._attr_alarm_state is None
|
||||||
):
|
):
|
||||||
self._state = AlarmControlPanelState(last_state.state)
|
self._attr_alarm_state = AlarmControlPanelState(last_state.state)
|
||||||
|
|
||||||
def _handle_state(self, result: Any) -> None:
|
def _handle_state(self, result: Any) -> None:
|
||||||
# Validate state
|
# Validate state
|
||||||
if result in _VALID_STATES:
|
if result in _VALID_STATES:
|
||||||
self._state = result
|
self._attr_alarm_state = result
|
||||||
_LOGGER.debug("Valid state - %s", result)
|
_LOGGER.debug("Valid state - %s", result)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -273,7 +267,7 @@ class AbstractTemplateAlarmControlPanel(
|
|||||||
self.entity_id,
|
self.entity_id,
|
||||||
", ".join(_VALID_STATES),
|
", ".join(_VALID_STATES),
|
||||||
)
|
)
|
||||||
self._state = None
|
self._attr_alarm_state = None
|
||||||
|
|
||||||
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
|
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
|
||||||
"""Arm the panel to specified state with supplied script."""
|
"""Arm the panel to specified state with supplied script."""
|
||||||
@@ -284,7 +278,7 @@ class AbstractTemplateAlarmControlPanel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self._attr_assumed_state:
|
if self._attr_assumed_state:
|
||||||
self._state = state
|
self._attr_alarm_state = state
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||||
@@ -376,7 +370,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
|
|||||||
@callback
|
@callback
|
||||||
def _update_state(self, result):
|
def _update_state(self, result):
|
||||||
if isinstance(result, TemplateError):
|
if isinstance(result, TemplateError):
|
||||||
self._state = None
|
self._attr_alarm_state = None
|
||||||
return
|
return
|
||||||
|
|
||||||
self._handle_state(result)
|
self._handle_state(result)
|
||||||
@@ -386,7 +380,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
|
|||||||
"""Set up templates."""
|
"""Set up templates."""
|
||||||
if self._template:
|
if self._template:
|
||||||
self.add_template_attribute(
|
self.add_template_attribute(
|
||||||
"_state", self._template, None, self._update_state
|
"_attr_alarm_state", self._template, None, self._update_state
|
||||||
)
|
)
|
||||||
super()._async_setup_templates()
|
super()._async_setup_templates()
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector",
|
"documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["tesla_wall_connector"],
|
"loggers": ["tesla_wall_connector"],
|
||||||
"requirements": ["tesla-wall-connector==1.0.2"]
|
"requirements": ["tesla-wall-connector==1.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ class TeslemetryStreamingUpdateEntity(
|
|||||||
if self._download_percentage > 1 and self._download_percentage < 100:
|
if self._download_percentage > 1 and self._download_percentage < 100:
|
||||||
self._attr_in_progress = True
|
self._attr_in_progress = True
|
||||||
self._attr_update_percentage = self._download_percentage
|
self._attr_update_percentage = self._download_percentage
|
||||||
elif self._install_percentage > 1:
|
elif self._install_percentage > 10:
|
||||||
self._attr_in_progress = True
|
self._attr_in_progress = True
|
||||||
self._attr_update_percentage = self._install_percentage
|
self._attr_update_percentage = self._install_percentage
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -709,6 +709,7 @@ class DPCode(StrEnum):
|
|||||||
DEW_POINT_TEMP = "dew_point_temp"
|
DEW_POINT_TEMP = "dew_point_temp"
|
||||||
DISINFECTION = "disinfection"
|
DISINFECTION = "disinfection"
|
||||||
DO_NOT_DISTURB = "do_not_disturb"
|
DO_NOT_DISTURB = "do_not_disturb"
|
||||||
|
DOORBELL_PIC = "doorbell_pic"
|
||||||
DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor
|
DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor
|
||||||
DOORCONTACT_STATE_2 = "doorcontact_state_2"
|
DOORCONTACT_STATE_2 = "doorcontact_state_2"
|
||||||
DOORCONTACT_STATE_3 = "doorcontact_state_3"
|
DOORCONTACT_STATE_3 = "doorcontact_state_3"
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import suppress
|
from typing import Any
|
||||||
import json
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from tuya_sharing import CustomerDevice
|
from tuya_sharing import CustomerDevice
|
||||||
|
|
||||||
@@ -17,6 +15,13 @@ from homeassistant.util import dt as dt_util
|
|||||||
from . import TuyaConfigEntry
|
from . import TuyaConfigEntry
|
||||||
from .const import DOMAIN, DPCode
|
from .const import DOMAIN, DPCode
|
||||||
|
|
||||||
|
_REDACTED_DPCODES = {
|
||||||
|
DPCode.ALARM_MESSAGE,
|
||||||
|
DPCode.ALARM_MSG,
|
||||||
|
DPCode.DOORBELL_PIC,
|
||||||
|
DPCode.MOVEMENT_DETECT_PIC,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def async_get_config_entry_diagnostics(
|
async def async_get_config_entry_diagnostics(
|
||||||
hass: HomeAssistant, entry: TuyaConfigEntry
|
hass: HomeAssistant, entry: TuyaConfigEntry
|
||||||
@@ -97,34 +102,24 @@ def _async_device_as_dict(
|
|||||||
# Gather Tuya states
|
# Gather Tuya states
|
||||||
for dpcode, value in device.status.items():
|
for dpcode, value in device.status.items():
|
||||||
# These statuses may contain sensitive information, redact these..
|
# These statuses may contain sensitive information, redact these..
|
||||||
if dpcode in {DPCode.ALARM_MESSAGE, DPCode.MOVEMENT_DETECT_PIC}:
|
if dpcode in _REDACTED_DPCODES:
|
||||||
data["status"][dpcode] = REDACTED
|
data["status"][dpcode] = REDACTED
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with suppress(ValueError, TypeError):
|
|
||||||
value = json.loads(value)
|
|
||||||
data["status"][dpcode] = value
|
data["status"][dpcode] = value
|
||||||
|
|
||||||
# Gather Tuya functions
|
# Gather Tuya functions
|
||||||
for function in device.function.values():
|
for function in device.function.values():
|
||||||
value = function.values
|
|
||||||
with suppress(ValueError, TypeError, AttributeError):
|
|
||||||
value = json.loads(cast(str, function.values))
|
|
||||||
|
|
||||||
data["function"][function.code] = {
|
data["function"][function.code] = {
|
||||||
"type": function.type,
|
"type": function.type,
|
||||||
"value": value,
|
"value": function.values,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Gather Tuya status ranges
|
# Gather Tuya status ranges
|
||||||
for status_range in device.status_range.values():
|
for status_range in device.status_range.values():
|
||||||
value = status_range.values
|
|
||||||
with suppress(ValueError, TypeError, AttributeError):
|
|
||||||
value = json.loads(status_range.values)
|
|
||||||
|
|
||||||
data["status_range"][status_range.code] = {
|
data["status_range"][status_range.code] = {
|
||||||
"type": status_range.type,
|
"type": status_range.type,
|
||||||
"value": value,
|
"value": status_range.values,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Gather information how this Tuya device is represented in Home Assistant
|
# Gather information how this Tuya device is represented in Home Assistant
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.util import color as color_util
|
from homeassistant.util import color as color_util
|
||||||
|
from homeassistant.util.json import json_loads_object
|
||||||
|
|
||||||
from . import TuyaConfigEntry
|
from . import TuyaConfigEntry
|
||||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
|
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
|
||||||
@@ -499,11 +500,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
|||||||
values = self.device.status_range[dpcode].values
|
values = self.device.status_range[dpcode].values
|
||||||
|
|
||||||
# Fetch color data type information
|
# Fetch color data type information
|
||||||
if function_data := json.loads(values):
|
if function_data := json_loads_object(values):
|
||||||
self._color_data_type = ColorTypeData(
|
self._color_data_type = ColorTypeData(
|
||||||
h_type=IntegerTypeData(dpcode, **function_data["h"]),
|
h_type=IntegerTypeData(dpcode, **cast(dict, function_data["h"])),
|
||||||
s_type=IntegerTypeData(dpcode, **function_data["s"]),
|
s_type=IntegerTypeData(dpcode, **cast(dict, function_data["s"])),
|
||||||
v_type=IntegerTypeData(dpcode, **function_data["v"]),
|
v_type=IntegerTypeData(dpcode, **cast(dict, function_data["v"])),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# If no type is found, use a default one
|
# If no type is found, use a default one
|
||||||
@@ -770,12 +771,12 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
|||||||
if not (status_data := self.device.status[self._color_data_dpcode]):
|
if not (status_data := self.device.status[self._color_data_dpcode]):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not (status := json.loads(status_data)):
|
if not (status := json_loads_object(status_data)):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return ColorData(
|
return ColorData(
|
||||||
type_data=self._color_data_type,
|
type_data=self._color_data_type,
|
||||||
h_value=status["h"],
|
h_value=cast(int, status["h"]),
|
||||||
s_value=status["s"],
|
s_value=cast(int, status["s"]),
|
||||||
v_value=status["v"],
|
v_value=cast(int, status["v"]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ from __future__ import annotations
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import base64
|
import base64
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import json
|
from typing import Any, Literal, Self, cast, overload
|
||||||
from typing import Any, Literal, Self, overload
|
|
||||||
|
|
||||||
from tuya_sharing import CustomerDevice
|
from tuya_sharing import CustomerDevice
|
||||||
|
|
||||||
from homeassistant.util.json import json_loads
|
from homeassistant.util.json import json_loads, json_loads_object
|
||||||
|
|
||||||
from .const import DPCode, DPType
|
from .const import DPCode, DPType
|
||||||
from .util import parse_dptype, remap_value
|
from .util import parse_dptype, remap_value
|
||||||
@@ -88,7 +87,7 @@ class IntegerTypeData(TypeInformation):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||||
"""Load JSON string and return a IntegerTypeData object."""
|
"""Load JSON string and return a IntegerTypeData object."""
|
||||||
if not (parsed := json.loads(data)):
|
if not (parsed := cast(dict[str, Any] | None, json_loads_object(data))):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
@@ -111,9 +110,9 @@ class BitmapTypeInformation(TypeInformation):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||||
if not (parsed := json.loads(data)):
|
if not (parsed := json_loads_object(data)):
|
||||||
return None
|
return None
|
||||||
return cls(dpcode, **parsed)
|
return cls(dpcode, **cast(dict[str, list[str]], parsed))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -125,9 +124,9 @@ class EnumTypeData(TypeInformation):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||||
"""Load JSON string and return a EnumTypeData object."""
|
"""Load JSON string and return a EnumTypeData object."""
|
||||||
if not (parsed := json.loads(data)):
|
if not (parsed := json_loads_object(data)):
|
||||||
return None
|
return None
|
||||||
return cls(dpcode, **parsed)
|
return cls(dpcode, **cast(dict[str, list[str]], parsed))
|
||||||
|
|
||||||
|
|
||||||
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from __future__ import annotations
|
|||||||
from pyvlx import PyVLX, PyVLXException
|
from pyvlx import PyVLX, PyVLXException
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_MAC,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||||
|
|
||||||
@@ -30,6 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
|
|||||||
|
|
||||||
entry.runtime_data = pyvlx
|
entry.runtime_data = pyvlx
|
||||||
|
|
||||||
|
connections = None
|
||||||
|
if (mac := entry.data.get(CONF_MAC)) is not None:
|
||||||
|
connections = {(dr.CONNECTION_NETWORK_MAC, mac)}
|
||||||
|
|
||||||
device_registry = dr.async_get(hass)
|
device_registry = dr.async_get(hass)
|
||||||
device_registry.async_get_or_create(
|
device_registry.async_get_or_create(
|
||||||
config_entry_id=entry.entry_id,
|
config_entry_id=entry.entry_id,
|
||||||
@@ -43,6 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
|
|||||||
sw_version=(
|
sw_version=(
|
||||||
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
|
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
|
||||||
),
|
),
|
||||||
|
connections=connections,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_hass_stop(event):
|
async def on_hass_stop(event):
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
from . import VeluxConfigEntry
|
from . import VeluxConfigEntry
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|||||||
@@ -56,37 +56,32 @@ class VeluxCover(VeluxEntity, CoverEntity):
|
|||||||
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
|
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
|
||||||
"""Initialize VeluxCover."""
|
"""Initialize VeluxCover."""
|
||||||
super().__init__(node, config_entry_id)
|
super().__init__(node, config_entry_id)
|
||||||
|
# Features common to all covers
|
||||||
|
self._attr_supported_features = (
|
||||||
|
CoverEntityFeature.OPEN
|
||||||
|
| CoverEntityFeature.CLOSE
|
||||||
|
| CoverEntityFeature.SET_POSITION
|
||||||
|
| CoverEntityFeature.STOP
|
||||||
|
)
|
||||||
# Window is the default device class for covers
|
# Window is the default device class for covers
|
||||||
self._attr_device_class = CoverDeviceClass.WINDOW
|
self._attr_device_class = CoverDeviceClass.WINDOW
|
||||||
if isinstance(node, Awning):
|
if isinstance(node, Awning):
|
||||||
self._attr_device_class = CoverDeviceClass.AWNING
|
self._attr_device_class = CoverDeviceClass.AWNING
|
||||||
if isinstance(node, Blind):
|
|
||||||
self._attr_device_class = CoverDeviceClass.BLIND
|
|
||||||
self._is_blind = True
|
|
||||||
if isinstance(node, GarageDoor):
|
if isinstance(node, GarageDoor):
|
||||||
self._attr_device_class = CoverDeviceClass.GARAGE
|
self._attr_device_class = CoverDeviceClass.GARAGE
|
||||||
if isinstance(node, Gate):
|
if isinstance(node, Gate):
|
||||||
self._attr_device_class = CoverDeviceClass.GATE
|
self._attr_device_class = CoverDeviceClass.GATE
|
||||||
if isinstance(node, RollerShutter):
|
if isinstance(node, RollerShutter):
|
||||||
self._attr_device_class = CoverDeviceClass.SHUTTER
|
self._attr_device_class = CoverDeviceClass.SHUTTER
|
||||||
|
if isinstance(node, Blind):
|
||||||
@property
|
self._attr_device_class = CoverDeviceClass.BLIND
|
||||||
def supported_features(self) -> CoverEntityFeature:
|
self._is_blind = True
|
||||||
"""Flag supported features."""
|
self._attr_supported_features |= (
|
||||||
supported_features = (
|
|
||||||
CoverEntityFeature.OPEN
|
|
||||||
| CoverEntityFeature.CLOSE
|
|
||||||
| CoverEntityFeature.SET_POSITION
|
|
||||||
| CoverEntityFeature.STOP
|
|
||||||
)
|
|
||||||
if self.current_cover_tilt_position is not None:
|
|
||||||
supported_features |= (
|
|
||||||
CoverEntityFeature.OPEN_TILT
|
CoverEntityFeature.OPEN_TILT
|
||||||
| CoverEntityFeature.CLOSE_TILT
|
| CoverEntityFeature.CLOSE_TILT
|
||||||
| CoverEntityFeature.SET_TILT_POSITION
|
| CoverEntityFeature.SET_TILT_POSITION
|
||||||
| CoverEntityFeature.STOP_TILT
|
| CoverEntityFeature.STOP_TILT
|
||||||
)
|
)
|
||||||
return supported_features
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_cover_position(self) -> int:
|
def current_cover_position(self) -> int:
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ rules:
|
|||||||
entity-unavailable: todo
|
entity-unavailable: todo
|
||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: todo
|
log-when-unavailable: todo
|
||||||
parallel-updates:
|
parallel-updates: done
|
||||||
status: todo
|
|
||||||
comment: button still needs it
|
|
||||||
reauthentication-flow: todo
|
reauthentication-flow: todo
|
||||||
test-coverage:
|
test-coverage:
|
||||||
status: todo
|
status: todo
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/vesync",
|
"documentation": "https://www.home-assistant.io/integrations/vesync",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyvesync"],
|
"loggers": ["pyvesync"],
|
||||||
"requirements": ["pyvesync==3.2.1"]
|
"requirements": ["pyvesync==3.2.2"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = (
|
|||||||
device_class=BinarySensorDeviceClass.DOOR,
|
device_class=BinarySensorDeviceClass.DOOR,
|
||||||
value_getter=lambda api: api.isValveOpen(),
|
value_getter=lambda api: api.isValveOpen(),
|
||||||
),
|
),
|
||||||
|
ViCareBinarySensorEntityDescription(
|
||||||
|
key="ventilation_frost_protection",
|
||||||
|
translation_key="ventilation_frost_protection",
|
||||||
|
value_getter=lambda api: api.getHeatExchangerFrostProtectionActive(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ CONF_HEATING_TYPE = "heating_type"
|
|||||||
DEFAULT_CACHE_DURATION = 60
|
DEFAULT_CACHE_DURATION = 60
|
||||||
|
|
||||||
VICARE_BAR = "bar"
|
VICARE_BAR = "bar"
|
||||||
|
VICARE_CELSIUS = "celsius"
|
||||||
VICARE_CUBIC_METER = "cubicMeter"
|
VICARE_CUBIC_METER = "cubicMeter"
|
||||||
VICARE_KW = "kilowatt"
|
VICARE_KW = "kilowatt"
|
||||||
VICARE_KWH = "kilowattHour"
|
VICARE_KWH = "kilowattHour"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user