Compare commits

...

106 Commits

Author SHA1 Message Date
Claude
0f3c3e5b0d Add media_player.muted trigger 2025-11-11 22:47:54 +00:00
Claude
b46640a1c2 Add media_player.paused trigger 2025-11-11 22:45:33 +00:00
Claude
f7727e8192 Add cover.position_changed trigger
Implements a trigger that fires when a cover's position changes. Supports:
- Monitoring current_position attribute (0-100 range)
- Optional threshold filters: lower, upper, above, below
- Device class filtering (curtain, shutter, blind)
- Provides from_position and to_position in trigger data
2025-11-11 22:44:58 +00:00
Claude
296c41c46c Add climate trigger tests 2025-11-11 22:44:08 +00:00
Claude
a7d5140f80 Add cover.stops trigger
Implements a trigger that fires when a cover stops moving. Triggers
when a cover transitions from "opening" or "closing" state to "open"
or "closed" state. Supports device class filtering (curtain, shutter, blind).
2025-11-11 22:43:23 +00:00
Claude
e8cd2ad1e6 Add climate trigger metadata and translations 2025-11-11 22:42:56 +00:00
Claude
10bd2ffc5f Add cover.closes trigger
Implements a trigger that fires when a cover closes. Supports:
- Triggering on "closing" or "closed" state changes
- Optional fully_closed flag to only trigger at 0% position
- Device class filtering (curtain, shutter, blind)
2025-11-11 22:42:12 +00:00
Claude
b9dac02e8e Add climate.current_humidity_changed trigger 2025-11-11 22:42:03 +00:00
Claude
8605eb046a Add climate.target_humidity_changed trigger 2025-11-11 22:41:28 +00:00
Claude
26b4fa5d39 Add media_player.turns_off trigger 2025-11-11 22:41:16 +00:00
Claude
be23d3d43d Add climate.current_temperature_changed trigger 2025-11-11 22:40:54 +00:00
Claude
a4fbb597f4 Add cover.opens trigger
Implements a trigger that fires when a cover opens. Supports:
- Triggering on "opening" or "open" state changes
- Optional fully_opened flag to only trigger at 100% position
- Device class filtering (curtain, shutter, blind)
2025-11-11 22:40:52 +00:00
Claude
ee11fc37d5 Add media_player.turns_on trigger 2025-11-11 22:40:28 +00:00
Claude
9900e49bcc Add climate.target_temperature_changed trigger 2025-11-11 22:40:21 +00:00
Claude
3feb3fefef Add climate.drying trigger 2025-11-11 22:39:24 +00:00
Claude
b3e4f6dc43 Add climate.heating trigger 2025-11-11 22:38:53 +00:00
Claude
a9ba0bea8f Add climate.cooling trigger 2025-11-11 22:38:24 +00:00
Claude
bafa1e250d Add climate.mode_changed trigger 2025-11-11 22:37:48 +00:00
Claude
734c6f27c6 Add climate.turns_off trigger 2025-11-11 22:37:10 +00:00
Claude
37eed7fae8 Add climate.turns_on trigger 2025-11-11 22:36:42 +00:00
Claude
37aa4c68d9 Add light.brightness_changed trigger 2025-11-11 22:31:16 +00:00
Claude
80f8d94db4 Add light.turns_off trigger 2025-11-11 22:29:17 +00:00
Claude
7c0a7f4f9d Add assist_satellite.idle trigger 2025-11-11 22:28:20 +00:00
Claude
5d5d7f7acf Add light.turns_on trigger 2025-11-11 22:28:02 +00:00
Claude
f0feb93fe1 Add assist_satellite.responding trigger 2025-11-11 22:27:53 +00:00
Claude
9361ccbfc2 Add assist_satellite.processing trigger 2025-11-11 22:27:30 +00:00
Claude
e9c76f1053 Add assist_satellite.listening trigger 2025-11-11 22:27:08 +00:00
Claude
36b100c40a Add alarm_control_panel.disarmed trigger 2025-11-11 22:26:45 +00:00
Claude
60107a1492 Add alarm_control_panel.armed trigger 2025-11-11 22:25:27 +00:00
Will Moss
aedd48c298 Improved error handling for oauth2 configuration in toon integration (#156218) 2025-11-11 22:38:46 +01:00
Will Moss
febbb85532 Improved error handling for oauth2 configuration in netatmo integration (#156207) 2025-11-11 22:37:56 +01:00
Franck Nijhof
af67a35b75 Extend base jinja2 extension with hass requirement and tests (#156403) 2025-11-11 22:34:08 +01:00
Will Moss
dd34d458f5 Improved error handling for oauth2 configuration in tesla_fleet integration (#156219) 2025-11-11 22:33:20 +01:00
Will Moss
603d4bcf87 Improved error handling for oauth2 configuration in weheat integration (#156217) 2025-11-11 22:23:56 +01:00
Erik Montnemery
2dadc1f2b3 Fix iskra tests opening sockets (#156374) 2025-11-11 21:18:14 +01:00
epenet
936151fae5 Use dpcode_wrapper in tuya sensor platform (#156277) 2025-11-11 21:16:41 +01:00
wollew
9760eb7f2b Deprecate velux reboot action (#155549)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 20:23:07 +01:00
Erik Montnemery
7851bed00c Fix zimi tests opening sockets (#156382) 2025-11-11 11:48:30 -06:00
wollew
6aba0b20c6 Add Velux initial quality scale assessment (#154615) 2025-11-11 18:46:24 +01:00
Åke Strandberg
cadfed2348 Add diagnostics to SENZ (#156383) 2025-11-11 18:19:37 +01:00
Åke Strandberg
44e2fa6996 Improve handling of OAuth2 implementation unavailable in SENZ (#156381) 2025-11-11 17:42:19 +01:00
Andrew Jackson
d0ff617e17 Transmission Service validation and fixes (#155554) 2025-11-11 17:29:42 +01:00
wollew
8e499569a4 Add reboot button to velux gateway device (#155547)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 17:10:20 +01:00
Åke Strandberg
5e0ebddd6f Add temperature sensor to SENZ integration (#156181) 2025-11-11 16:33:49 +01:00
Artur Pragacz
c0f61f6c2b Improve code quality of music assistant config flow (#156263) 2025-11-11 16:06:54 +01:00
Manu
df60de38b0 Add In party sensor to Xbox integration (#155967) 2025-11-11 16:05:38 +01:00
Manu
cb086bb8e9 Refactor media source platform in Xbox integration (#155925) 2025-11-11 15:01:53 +01:00
Erik Montnemery
ee2e9dc7d6 Fix homewizard tests opening sockets (#156370) 2025-11-11 15:00:18 +01:00
TheJulianJES
85cd3c68b7 Remove redundant Z-Wave binary sensor entity_description arg (#156323) 2025-11-11 15:00:07 +01:00
Erik Montnemery
1b0b6e63f2 Fix squeezebox tests opening sockets (#156373) 2025-11-11 15:50:56 +02:00
Erik Montnemery
12fc79e8d3 Fix google_generative_ai_conversation tests opening sockets (#156371) 2025-11-11 14:33:36 +01:00
Ludovic BOUÉ
ca2e7b9509 Add Matter Eve Shutter device with corresponding fixtures and snapshots (#156296) 2025-11-11 14:07:08 +01:00
Teemu R.
8e8becc43e tplink: handle repeated, unknown thermostat modes gracefully (#156310) 2025-11-11 14:06:29 +01:00
Paul Annekov
dcec6c3dc8 Forbid to choose state in Ukraine Alarm integration (#156183) 2025-11-11 14:05:14 +01:00
Retha Runolfsson
c0e59c4508 Add support for switchbot s20 (#156368) 2025-11-11 13:55:50 +01:00
Erik Montnemery
cd379aadbf Use pytest.mark.freeze_time in sensibo tests (#156348) 2025-11-11 13:52:19 +01:00
antoniocifu
ccdd54b187 Fix support for Hyperion 2.1.1 (#156343)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 13:18:35 +01:00
Marc Mueller
3f22dbaa2e Update pytest to 9.0.0 (#156365) 2025-11-11 13:18:09 +01:00
Retha Runolfsson
c18dc0a9ab Add support for Switchbot Smart thermostat radiator (#155123) 2025-11-11 13:12:39 +01:00
Erik Montnemery
f0e4296d93 Use pytest.mark.freeze_time in sensor tests (#156349) 2025-11-11 13:05:52 +01:00
Erik Montnemery
b3750109c6 Use pytest.mark.freeze_time in playstation_network tests (#156347) 2025-11-11 13:05:38 +01:00
Erik Montnemery
93025c9845 Use pytest.mark.freeze_time in pglab tests (#156346) 2025-11-11 13:05:17 +01:00
Erik Montnemery
df348644b1 Use pytest.mark.freeze_time in openai_conversation tests (#156345) 2025-11-11 13:05:02 +01:00
Erik Montnemery
8749b0d750 Use pytest.mark.freeze_time in smhi tests (#156352) 2025-11-11 13:02:21 +01:00
Erik Montnemery
a6a1519c06 Use pytest.mark.freeze_time in snoo tests (#156353) 2025-11-11 13:02:01 +01:00
Erik Montnemery
3068e19843 Use pytest.mark.freeze_time in telegram_bot tests (#156354) 2025-11-11 13:01:34 +01:00
Erik Montnemery
55feb1e735 Use pytest.mark.freeze_time in tomorrowio tests (#156355) 2025-11-11 13:01:29 +01:00
Erik Montnemery
bb7dc69131 Use pytest.mark.freeze_time in yale_smart_alarm tests (#156359) 2025-11-11 12:06:22 +01:00
Erik Montnemery
aa9003a524 Use pytest.mark.freeze_time in wake_word tests (#156360) 2025-11-11 12:06:12 +01:00
Erik Montnemery
4e9da5249d Use pytest.mark.freeze_time in utility_meter tests (#156361) 2025-11-11 12:05:58 +01:00
Erik Montnemery
f502739df2 Use pytest.mark.freeze_time in zha tests (#156358) 2025-11-11 12:04:59 +01:00
Erik Montnemery
0f2ff29378 Use pytest.mark.freeze_time in sleep_as_android tests (#156351) 2025-11-11 12:04:40 +01:00
Erik Montnemery
2921e7ed3c Use pytest.mark.freeze_time in plaato tests (#156362) 2025-11-11 12:04:31 +01:00
Christopher Fenner
25d44e8d37 Enhance compressor phase with state translations in ViCare integration (#156238) 2025-11-11 11:20:27 +01:00
Will Moss
0a480a26a3 Remove import of config_entry_oauth2_flow in scaffold in favor of direct imports (#156302) 2025-11-11 11:17:31 +01:00
Khole
d5da64dd8d Bump pyhive to 1.0.7 (#156309) 2025-11-11 11:16:11 +01:00
wollew
92adcd8635 add the velux KLF 200 gateway as device (#155434)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 11:13:18 +01:00
Joost Lekkerkerker
ee0c4b15c2 Make certain fields required for subentry flows (#156251) 2025-11-11 09:42:51 +01:00
Erik Montnemery
507f54198e Use pytest.mark.freeze_time in habitica tests (#156332) 2025-11-11 09:37:17 +01:00
epenet
0ed342b433 Use dpcode_wrapper in tuya alarm control panel platform (#156306) 2025-11-11 09:36:09 +01:00
cdnninja
363c86faf3 Add remove entity to vesync (#156213) 2025-11-11 09:35:19 +01:00
dependabot[bot]
095a7ad060 Bump actions/dependency-review-action from 4.8.1 to 4.8.2 (#156322)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 09:34:38 +01:00
Åke Strandberg
ab5981bbbd Use common string for OAuth2 implementation error in myuplink (#156338) 2025-11-11 09:33:59 +01:00
Erik Montnemery
ac2fb53dfd Fix typo in recorder statistics_meta table manager (#156326) 2025-11-11 09:33:30 +01:00
Erik Montnemery
02ff5de1ff Use pytest.mark.freeze_time in ntfy tests (#156336) 2025-11-11 09:33:21 +01:00
Erik Montnemery
5cd5d480d9 Check collation of statistics_meta DB table (#156327) 2025-11-11 09:31:43 +01:00
Erik Montnemery
a3c7d772fc Use pytest.mark.freeze_time in conversation tests (#156329) 2025-11-11 09:29:46 +01:00
micha91
fe0c69dba7 Update aiomusiccast to 0.15 (#156325) 2025-11-11 09:26:16 +01:00
Artur Pragacz
e5365234c3 Add myself as codeowner to music assistant (#156324) 2025-11-11 09:24:09 +01:00
Erik Montnemery
1531175bd3 Use pytest.mark.freeze_time in google tests (#156330) 2025-11-11 09:22:48 +01:00
Erik Montnemery
62add59ff4 Use pytest.mark.freeze_time in google_generative_ai_conversation tests (#156331) 2025-11-11 09:21:52 +01:00
Erik Montnemery
d8daca657b Use pytest.mark.freeze_time in intellifire tests (#156333) 2025-11-11 10:17:58 +02:00
Erik Montnemery
1891da46ea Use pytest.mark.freeze_time in knx tests (#156335) 2025-11-11 08:52:39 +01:00
Marc Mueller
22ae894745 Update pytest-asyncio to 1.3.0 (#156315) 2025-11-10 22:07:02 -08:00
Will Moss
160810c69d Move oauth2_implementation_unavailable string to top level (#156299) 2025-11-11 06:58:24 +01:00
epenet
2ae23b920a Use dpcode_wrapper in tuya siren platform (#156284) 2025-11-10 23:06:14 +01:00
Artur Pragacz
a7edfb082f Move config intents to manager (#154903) 2025-11-10 16:04:25 -06:00
Ludovic BOUÉ
3ac203b05f Add Matter Aqara W100 fixture (#156305)
- Adds JSON fixture file containing Matter node data for the Aqara W100 sensor
- Updates test configuration to include the new fixture in parametrized tests
- Adds snapshot test data for sensor and button entities created by this device
2025-11-10 21:58:18 +01:00
Jan Bouwhuis
7c3eb19fc4 Fix issues() template method returns non active issues (#156274) 2025-11-10 21:56:57 +01:00
kingy444
70c6fac743 Move hunterdouglas_powerview data class to upstream library (#156228)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-10 14:49:00 -06:00
Åke Strandberg
e19d7250d5 Adjust user-facing string for miele (#156280) 2025-11-10 20:42:42 +01:00
Maikel Punie
a850d5dba7 Bump velbusaio to 2025.11.0 (#156293) 2025-11-10 21:25:00 +02:00
Erik Montnemery
0cf0f10654 Correct migration to recorder schema 51 (#156267)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-10 20:14:25 +01:00
Ludovic BOUÉ
8429f154ca Fix status checks in Matter binary sensors (#156276)
This PR fixes bitmap bit checking logic in Matter binary sensors by replacing equality comparisons with bitwise AND operations. The changes correct how the integration checks if specific bits are set in bitmap fields.

Key changes:

Changed equality checks (==) to bitwise AND operations (&) for checking bitmap bits
Wrapped bitwise operations with bool() to ensure boolean return values
Applied fixes consistently across PumpStatus, DishwasherAlarm, and RefrigeratorAlarm bitmaps
2025-11-10 19:45:17 +01:00
Assaf Inbal
7b4f5ad362 Ituran: Don't cache properties (#156281) 2025-11-10 19:24:58 +02:00
David Rapan
583b439557 Add Shelly number translation (#156156)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-10 19:15:16 +02:00
224 changed files with 16834 additions and 1492 deletions

View File

@@ -622,7 +622,7 @@ jobs:
steps:
- *checkout
- name: Dependency review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
license-check: false # We use our own license audit checks

4
CODEOWNERS generated
View File

@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant
/tests/components/music_assistant/ @music-assistant
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
/tests/components/music_assistant/ @music-assistant @arturpragacz
/homeassistant/components/mutesync/ @currentoor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core

View File

@@ -143,5 +143,28 @@
"name": "Trigger"
}
},
"title": "Alarm control panel"
"title": "Alarm control panel",
"triggers": {
"armed": {
"description": "Triggers when an alarm is armed.",
"description_configured": "Triggers when an alarm is armed",
"fields": {
"mode": {
"description": "The arm modes to trigger on. If empty, triggers on all arm modes.",
"name": "Arm modes"
}
},
"name": "When an alarm is armed"
},
"disarmed": {
"description": "Triggers when an alarm is disarmed.",
"description_configured": "Triggers when an alarm is disarmed",
"name": "When an alarm is disarmed"
},
"triggered": {
"description": "Triggers when an alarm is triggered.",
"description_configured": "Triggers when an alarm is triggered",
"name": "When an alarm is triggered"
}
}
}

View File

@@ -0,0 +1,283 @@
"""Provides triggers for alarm control panels."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, AlarmControlPanelState
CONF_MODE = "mode"
ARMED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_MODE, default=[]): vol.All(
cv.ensure_list,
[
vol.In(
[
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
]
)
],
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
DISARMED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TRIGGERED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class AlarmArmedTrigger(Trigger):
"""Trigger for when an alarm control panel is armed."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, ARMED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm armed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
mode_filter = self._options[CONF_MODE]
# All armed states
armed_states = {
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
}
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is an armed state
if to_state.state not in armed_states:
return
# If mode filter is specified, check if the mode matches
if mode_filter and to_state.state not in mode_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm armed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class AlarmDisarmedTrigger(Trigger):
"""Trigger for when an alarm control panel is disarmed."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, DISARMED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm disarmed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is disarmed
if to_state.state != AlarmControlPanelState.DISARMED:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm disarmed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class AlarmTriggeredTrigger(Trigger):
"""Trigger for when an alarm control panel is triggered."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TRIGGERED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm triggered trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is triggered
if to_state.state != AlarmControlPanelState.TRIGGERED:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm triggered on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"armed": AlarmArmedTrigger,
"disarmed": AlarmDisarmedTrigger,
"triggered": AlarmTriggeredTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for alarm control panels."""
return TRIGGERS

View File

@@ -0,0 +1,30 @@
armed:
target:
entity:
domain: alarm_control_panel
fields:
mode:
required: false
default: []
selector:
select:
multiple: true
options:
- value: armed_home
label: Home
- value: armed_away
label: Away
- value: armed_night
label: Night
- value: armed_vacation
label: Vacation
- value: armed_custom_bypass
label: Custom bypass
disarmed:
target:
entity:
domain: alarm_control_panel
triggered:
target:
entity:
domain: alarm_control_panel

View File

@@ -98,5 +98,27 @@
"name": "Start conversation"
}
},
"title": "Assist satellite"
"title": "Assist satellite",
"triggers": {
"listening": {
"description": "Triggers when a satellite starts listening for a command.",
"description_configured": "Triggers when a satellite starts listening for a command",
"name": "When a satellite starts listening"
},
"processing": {
"description": "Triggers when a satellite starts processing a command.",
"description_configured": "Triggers when a satellite starts processing a command",
"name": "When a satellite starts processing"
},
"responding": {
"description": "Triggers when a satellite starts responding to a command.",
"description_configured": "Triggers when a satellite starts responding to a command",
"name": "When a satellite starts responding"
},
"idle": {
"description": "Triggers when a satellite goes back to idle.",
"description_configured": "Triggers when a satellite goes back to idle",
"name": "When a satellite goes back to idle"
}
}
}

View File

@@ -0,0 +1,140 @@
"""Provides triggers for assist satellites."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import process_state_match
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
STATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class StateTriggerBase(Trigger):
"""Trigger for assist satellite state changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig, state: str) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._state = state
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
match_config_state = process_state_match(self._state)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state matches the trigger state
if not match_config_state(to_state.state):
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"{entity_id} {self._state}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ListeningTrigger(StateTriggerBase):
"""Trigger for when a satellite starts listening."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the listening trigger."""
super().__init__(hass, config, "listening")
class ProcessingTrigger(StateTriggerBase):
"""Trigger for when a satellite starts processing."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the processing trigger."""
super().__init__(hass, config, "processing")
class RespondingTrigger(StateTriggerBase):
"""Trigger for when a satellite starts responding."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the responding trigger."""
super().__init__(hass, config, "responding")
class IdleTrigger(StateTriggerBase):
"""Trigger for when a satellite goes back to idle."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the idle trigger."""
super().__init__(hass, config, "idle")
TRIGGERS: dict[str, type[Trigger]] = {
"listening": ListeningTrigger,
"processing": ProcessingTrigger,
"responding": RespondingTrigger,
"idle": IdleTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for assist satellites."""
return TRIGGERS

View File

@@ -0,0 +1,19 @@
listening:
target:
entity:
domain: assist_satellite
processing:
target:
entity:
domain: assist_satellite
responding:
target:
entity:
domain: assist_satellite
idle:
target:
entity:
domain: assist_satellite

View File

@@ -285,5 +285,93 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Climate"
"title": "Climate",
"triggers": {
"cooling": {
"description": "Triggers when a climate starts cooling.",
"name": "When a climate starts cooling"
},
"current_humidity_changed": {
"description": "Triggers when the current humidity of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the current humidity goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the current humidity goes below this value.",
"name": "Below"
}
},
"name": "When current humidity changes"
},
"current_temperature_changed": {
"description": "Triggers when the current temperature of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the current temperature goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the current temperature goes below this value.",
"name": "Below"
}
},
"name": "When current temperature changes"
},
"drying": {
"description": "Triggers when a climate starts drying.",
"name": "When a climate starts drying"
},
"heating": {
"description": "Triggers when a climate starts heating.",
"name": "When a climate starts heating"
},
"mode_changed": {
"description": "Triggers when the HVAC mode of a climate changes.",
"fields": {
"hvac_mode": {
"description": "The HVAC modes to trigger on. If empty, triggers on all mode changes.",
"name": "HVAC modes"
}
},
"name": "When HVAC mode changes"
},
"target_humidity_changed": {
"description": "Triggers when the target humidity of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the target humidity goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the target humidity goes below this value.",
"name": "Below"
}
},
"name": "When target humidity changes"
},
"target_temperature_changed": {
"description": "Triggers when the target temperature of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the target temperature goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the target temperature goes below this value.",
"name": "Below"
}
},
"name": "When target temperature changes"
},
"turns_off": {
"description": "Triggers when a climate turns off.",
"name": "When a climate turns off"
},
"turns_on": {
"description": "Triggers when a climate turns on.",
"name": "When a climate turns on"
}
}
}

View File

@@ -0,0 +1,817 @@
"""Provides triggers for climate."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
CONF_ABOVE,
CONF_BELOW,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
DOMAIN,
HVAC_MODES,
HVACMode,
)
CLIMATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
MODE_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(ATTR_HVAC_MODE, default=[]): vol.All(
cv.ensure_list, [vol.In(HVAC_MODES)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
THRESHOLD_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_ABOVE): vol.Coerce(float),
vol.Optional(CONF_BELOW): vol.Coerce(float),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class ClimateTurnsOnTrigger(Trigger):
"""Trigger for when a climate turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate turned on (from off to any other mode)
if (
from_state is not None
and from_state.state == HVACMode.OFF
and to_state.state != HVACMode.OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} turned on",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTurnsOffTrigger(Trigger):
"""Trigger for when a climate turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate turned off (from any mode to off)
if (
from_state is not None
and from_state.state != HVACMode.OFF
and to_state.state == HVACMode.OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} turned off",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateModeChangedTrigger(Trigger):
"""Trigger for when a climate mode changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, MODE_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate mode changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
hvac_modes_filter = self._options[ATTR_HVAC_MODE]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if hvac_mode changed
if from_state is not None and from_state.state != to_state.state:
# If hvac_modes filter is specified, check if the new mode matches
if hvac_modes_filter and to_state.state not in hvac_modes_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} mode changed to {to_state.state}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCoolingTrigger(Trigger):
"""Trigger for when a climate starts cooling."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate cooling trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started cooling
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "cooling" and to_action == "cooling":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started cooling",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateHeatingTrigger(Trigger):
"""Trigger for when a climate starts heating."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate heating trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started heating
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "heating" and to_action == "heating":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started heating",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateDryingTrigger(Trigger):
"""Trigger for when a climate starts drying."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate drying trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started drying
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "drying" and to_action == "drying":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started drying",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTargetTemperatureChangedTrigger(Trigger):
"""Trigger for when a climate target temperature changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate target temperature changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if target temperature changed
from_temp = (
from_state.attributes.get(ATTR_TEMPERATURE) if from_state else None
)
to_temp = to_state.attributes.get(ATTR_TEMPERATURE)
if to_temp is None or from_temp == to_temp:
return
# Apply threshold filters if specified
if above is not None and to_temp <= above:
return
if below is not None and to_temp >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} target temperature changed to {to_temp}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCurrentTemperatureChangedTrigger(Trigger):
"""Trigger for when a climate current temperature changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate current temperature changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if current temperature changed
from_temp = (
from_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
if from_state
else None
)
to_temp = to_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
if to_temp is None or from_temp == to_temp:
return
# Apply threshold filters if specified
if above is not None and to_temp <= above:
return
if below is not None and to_temp >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} current temperature changed to {to_temp}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTargetHumidityChangedTrigger(Trigger):
"""Trigger for when a climate target humidity changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate target humidity changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if target humidity changed
from_humidity = (
from_state.attributes.get(ATTR_HUMIDITY) if from_state else None
)
to_humidity = to_state.attributes.get(ATTR_HUMIDITY)
if to_humidity is None or from_humidity == to_humidity:
return
# Apply threshold filters if specified
if above is not None and to_humidity <= above:
return
if below is not None and to_humidity >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} target humidity changed to {to_humidity}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCurrentHumidityChangedTrigger(Trigger):
"""Trigger for when a climate current humidity changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate current humidity changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if current humidity changed
from_humidity = (
from_state.attributes.get(ATTR_CURRENT_HUMIDITY)
if from_state
else None
)
to_humidity = to_state.attributes.get(ATTR_CURRENT_HUMIDITY)
if to_humidity is None or from_humidity == to_humidity:
return
# Apply threshold filters if specified
if above is not None and to_humidity <= above:
return
if below is not None and to_humidity >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} current humidity changed to {to_humidity}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": ClimateTurnsOnTrigger,
"turns_off": ClimateTurnsOffTrigger,
"mode_changed": ClimateModeChangedTrigger,
"cooling": ClimateCoolingTrigger,
"heating": ClimateHeatingTrigger,
"drying": ClimateDryingTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"current_temperature_changed": ClimateCurrentTemperatureChangedTrigger,
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"current_humidity_changed": ClimateCurrentHumidityChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for climate."""
return TRIGGERS

View File

@@ -0,0 +1,128 @@
turns_on:
target:
entity:
domain: climate
turns_off:
target:
entity:
domain: climate
mode_changed:
target:
entity:
domain: climate
fields:
hvac_mode:
required: false
default: []
selector:
select:
multiple: true
mode: dropdown
options:
- label: "Off"
value: "off"
- label: "Heat"
value: "heat"
- label: "Cool"
value: "cool"
- label: "Heat/Cool"
value: "heat_cool"
- label: "Auto"
value: "auto"
- label: "Dry"
value: "dry"
- label: "Fan only"
value: "fan_only"
cooling:
target:
entity:
domain: climate
heating:
target:
entity:
domain: climate
drying:
target:
entity:
domain: climate
target_temperature_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
step: 0.1
below:
required: false
selector:
number:
mode: box
step: 0.1
current_temperature_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
step: 0.1
below:
required: false
selector:
number:
mode: box
step: 0.1
target_humidity_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
min: 0
max: 100
below:
required: false
selector:
number:
mode: box
min: 0
max: 100
current_humidity_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
min: 0
max: 100
below:
required: false
selector:
number:
mode: box
min: 0
max: 100

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Literal
from typing import Any, Literal
from hassil.recognize import RecognizeResult
import voluptuous as vol
@@ -21,6 +21,7 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -52,6 +53,8 @@ from .const import (
DATA_COMPONENT,
DOMAIN,
HOME_ASSISTANT_AGENT,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component
agent_config = config.get(DOMAIN, {})
await async_setup_default_agent(
hass, entity_component, config_intents=agent_config.get("intents", {})
)
manager = get_agent_manager(hass)
hass_config_path = hass.config.path()
config_intents = _get_config_intents(config, hass_config_path)
manager.update_config_intents(config_intents)
await async_setup_default_agent(hass, entity_component)
async def handle_process(service: ServiceCall) -> ServiceResponse:
"""Parse text into commands."""
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None:
"""Reload intents."""
agent = get_agent_manager(hass).default_agent
language = service.data.get(ATTR_LANGUAGE)
if language is None:
conf = await async_integration_yaml_config(hass, DOMAIN)
if conf is not None:
config_intents = _get_config_intents(conf, hass_config_path)
manager.update_config_intents(config_intents)
agent = manager.default_agent
if agent is not None:
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
await agent.async_reload(language=language)
hass.services.async_register(
DOMAIN,
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
"""Return config intents."""
intents = config.get(DOMAIN, {}).get("intents", {})
return {
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in intents.items()
}
}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)

View File

@@ -147,6 +147,7 @@ class AgentManager:
self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None
self.config_intents: dict[str, Any] = {}
self.triggers_details: list[TriggerDetails] = []
@callback
@@ -199,9 +200,16 @@ class AgentManager:
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent."""
agent.update_config_intents(self.config_intents)
agent.update_triggers(self.triggers_details)
self.default_agent = agent
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self.config_intents = intents
if self.default_agent is not None:
self.default_agent.update_config_intents(intents)
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
"""Register a trigger."""
self.triggers_details.append(trigger_details)

View File

@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity."""
CONTROL = 1
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"

View File

@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog
from .const import DOMAIN, ConversationEntityFeature
from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
ConversationEntityFeature,
)
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object()
@@ -202,10 +205,9 @@ class IntentCache:
async def async_setup_default_agent(
hass: HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
config_intents: dict[str, Any],
) -> None:
"""Set up entity registry listener for the default agent."""
agent = DefaultAgent(hass, config_intents)
agent = DefaultAgent(hass)
await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent)
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock()
# intent -> [sentences]
self._config_intents: dict[str, Any] = config_intents
# Intents from common conversation config
self._config_intents: dict[str, Any] = {}
# Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = []
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
@callback
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self._config_intents = intents
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language."""
if language is None:
@@ -1159,33 +1169,10 @@ class DefaultAgent(ConversationEntity):
custom_sentences_path,
)
# Load sentences from HA config for default language only
if self._config_intents and (
self.hass.config.language in (language, language_variant)
):
hass_config_path = self.hass.config.path()
merge_dict(
intents_dict,
{
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in self._config_intents.items()
}
},
)
_LOGGER.debug(
"Loaded intents from configuration.yaml",
)
merge_dict(
intents_dict,
self._config_intents,
)
if not intents_dict:
return None

View File

@@ -136,5 +136,75 @@
"name": "Toggle tilt"
}
},
"title": "Cover"
"title": "Cover",
"triggers": {
"opens": {
"description": "Triggers when a cover opens.",
"description_configured": "Triggers when a cover opens",
"fields": {
"fully_opened": {
"description": "Only trigger when the cover is fully opened (position at 100%).",
"name": "Fully opened"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover opens"
},
"closes": {
"description": "Triggers when a cover closes.",
"description_configured": "Triggers when a cover closes",
"fields": {
"fully_closed": {
"description": "Only trigger when the cover is fully closed (position at 0%).",
"name": "Fully closed"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover closes"
},
"stops": {
"description": "Triggers when a cover stops moving.",
"description_configured": "Triggers when a cover stops moving",
"fields": {
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover stops moving"
},
"position_changed": {
"description": "Triggers when the position of a cover changes.",
"description_configured": "Triggers when the position of a cover changes",
"fields": {
"lower": {
"description": "The minimum position value to trigger on. Only triggers when position is at or above this value.",
"name": "Lower limit"
},
"upper": {
"description": "The maximum position value to trigger on. Only triggers when position is at or below this value.",
"name": "Upper limit"
},
"above": {
"description": "Only trigger when position is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when position is below this value.",
"name": "Below"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When the position of a cover changes"
}
}
}

View File

@@ -0,0 +1,453 @@
"""Provides triggers for covers."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE_CLASS,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
from .const import DOMAIN
CONF_LOWER = "lower"
CONF_UPPER = "upper"
CONF_ABOVE = "above"
CONF_BELOW = "below"
CONF_FULLY_OPENED = "fully_opened"
CONF_FULLY_CLOSED = "fully_closed"
OPENS_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_FULLY_OPENED, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
CLOSES_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_FULLY_CLOSED, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
STOPS_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
POSITION_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Exclusive(CONF_LOWER, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_UPPER, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_ABOVE, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_BELOW, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class CoverOpensTrigger(Trigger):
"""Trigger for when a cover opens."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, OPENS_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover opens trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
fully_opened = self._options[CONF_FULLY_OPENED]
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover opens or is opening
if to_state.state in (CoverState.OPEN, CoverState.OPENING):
# If fully_opened is True, only trigger when position reaches 100
if fully_opened:
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if current_position != 100:
return
# Only trigger on state change, not if already in that state
if from_state and from_state.state == to_state.state:
# For fully_opened, allow triggering when position changes to 100
if fully_opened:
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if from_position == to_position:
return
else:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover opened on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverClosesTrigger(Trigger):
"""Trigger for when a cover closes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLOSES_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover closes trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
fully_closed = self._options[CONF_FULLY_CLOSED]
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover closes or is closing
if to_state.state in (CoverState.CLOSED, CoverState.CLOSING):
# If fully_closed is True, only trigger when position reaches 0
if fully_closed:
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if current_position != 0:
return
# Only trigger on state change, not if already in that state
if from_state and from_state.state == to_state.state:
# For fully_closed, allow triggering when position changes to 0
if fully_closed:
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if from_position == to_position:
return
else:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover closed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverStopsTrigger(Trigger):
"""Trigger for when a cover stops moving."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STOPS_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover stops trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover stops (from opening/closing to open/closed)
if from_state and from_state.state in (
CoverState.OPENING,
CoverState.CLOSING,
):
if to_state.state in (CoverState.OPEN, CoverState.CLOSED):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover stopped on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverPositionChangedTrigger(Trigger):
"""Trigger for when a cover's position changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, POSITION_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover position changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._options = config.options or {}
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
lower_limit = self._options.get(CONF_LOWER)
upper_limit = self._options.get(CONF_UPPER)
above_limit = self._options.get(CONF_ABOVE)
below_limit = self._options.get(CONF_BELOW)
device_classes_filter = self._options.get(CONF_DEVICE_CLASS, [])
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Get position values
from_position = (
from_state.attributes.get(ATTR_CURRENT_POSITION) if from_state else None
)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
# Only trigger if position value exists and has changed
if to_position is None or from_position == to_position:
return
# Apply threshold filters if configured
if lower_limit is not None and to_position < lower_limit:
return
if upper_limit is not None and to_position > upper_limit:
return
if above_limit is not None and to_position <= above_limit:
return
if below_limit is not None and to_position >= below_limit:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
"from_position": from_position,
"to_position": to_position,
},
f"position changed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"opens": CoverOpensTrigger,
"closes": CoverClosesTrigger,
"stops": CoverStopsTrigger,
"position_changed": CoverPositionChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS

View File

@@ -0,0 +1,101 @@
opens:
target:
entity:
domain: cover
fields:
fully_opened:
required: false
default: false
selector:
boolean:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
closes:
target:
entity:
domain: cover
fields:
fully_closed:
required: false
default: false
selector:
boolean:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
stops:
target:
entity:
domain: cover
fields:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
position_changed:
target:
entity:
domain: cover
fields:
lower:
required: false
selector:
number:
min: 0
max: 100
mode: box
upper:
required: false
selector:
number:
min: 0
max: 100
mode: box
above:
required: false
selector:
number:
min: 0
max: 100
mode: box
below:
required: false
selector:
number:
min: 0
max: 100
mode: box
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind

View File

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

View File

@@ -1237,7 +1237,7 @@
"message": "Error obtaining data from the API: {error}"
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"pause_program": {
"message": "Error pausing program: {error}"

View File

@@ -4,6 +4,7 @@ import logging
from typing import TYPE_CHECKING
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades
@@ -16,7 +17,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewConfigEntry, PowerviewEntryData
from .shade_data import PowerviewShadeData
from .util import async_connect_hub
PARALLEL_UPDATES = 1

View File

@@ -8,6 +8,7 @@ import logging
from aiopvapi.helpers.aiorequest import PvApiMaintenance
from aiopvapi.hub import Hub
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.shades import Shades
from homeassistant.config_entries import ConfigEntry
@@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import HUB_EXCEPTIONS
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__)

View File

@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
async def _async_execute_move(self, move: ShadePosition) -> None:
"""Execute a move that can affect multiple positions."""
_LOGGER.debug("Move request %s: %s", self.name, move)
# Store the requested positions so subsequent move
# requests contain the secondary shade positions
self.data.update_shade_position(self._shade.id, move)
async with self.coordinator.radio_operation_lock:
response = await self._shade.move(move)
_LOGGER.debug("Move response %s: %s", self.name, response)
# Process the response from the hub (including new positions)
self.data.update_shade_position(self._shade.id, response)
async def _async_set_cover_position(self, target_hass_position: int) -> None:
"""Move the shade to a position."""
target_hass_position = self._clamp_cover_limit(target_hass_position)

View File

@@ -3,6 +3,7 @@
import logging
from aiopvapi.resources.shade import BaseShade, ShadePosition
from aiopvapi.resources.shade_data import PowerviewShadeData
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__)

View File

@@ -1,80 +0,0 @@
"""Shade data for the Hunter Douglas PowerView integration."""
from __future__ import annotations
from dataclasses import fields
from typing import Any
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade import BaseShade, ShadePosition
from .util import async_map_data_by_id
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
"""Copy position data from source to target for None values only."""
for field in POSITION_FIELDS:
if (value := getattr(source, field.name)) is not None:
setattr(target, field.name, value)
class PowerviewShadeData:
"""Coordinate shade data between multiple api calls."""
def __init__(self) -> None:
"""Init the shade data."""
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
self._shade_group_data_by_id: dict[int, BaseShade] = {}
self.positions: dict[int, ShadePosition] = {}
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
"""Get data for the shade."""
return self._raw_data_by_id[shade_id]
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
"""Get data for all shades."""
return self._raw_data_by_id
def get_shade(self, shade_id: int) -> BaseShade:
"""Get specific shade from the coordinator."""
return self._shade_group_data_by_id[shade_id]
def get_shade_position(self, shade_id: int) -> ShadePosition:
"""Get positions for a shade."""
if shade_id not in self.positions:
shade_position = ShadePosition()
# If we have the group data, use it to populate the initial position
if shade := self._shade_group_data_by_id.get(shade_id):
copy_position_data(shade.current_position, shade_position)
self.positions[shade_id] = shade_position
return self.positions[shade_id]
def update_from_group_data(self, shade_id: int) -> None:
"""Process an update from the group data."""
data = self._shade_group_data_by_id[shade_id]
copy_position_data(data.current_position, self.get_shade_position(data.id))
def store_group_data(self, shade_data: PowerviewData) -> None:
"""Store data from the all shades endpoint.
This does not update the shades or positions (self.positions)
as the data may be stale. update_from_group_data
with a shade_id will update a specific shade
from the group data.
"""
self._shade_group_data_by_id = shade_data.processed
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
"""Update a single shades position."""
copy_position_data(new_position, self.get_shade_position(shade_id))
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shades velocity."""
# the hub will always return a velocity of 0 on initial connect,
# separate definition to store consistent value in HA
# this value is purely driven from HA
if shade_data.velocity is not None:
self.get_shade_position(shade_id).velocity = shade_data.velocity

View File

@@ -2,25 +2,15 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.constants import ATTR_ID
from aiopvapi.hub import Hub
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .model import PowerviewAPI, PowerviewDeviceInfo
@callback
def async_map_data_by_id(data: Iterable[dict[str | int, Any]]):
"""Return a dict with the key being the id for a list of entries."""
return {entry[ATTR_ID]: entry for entry in data}
async def async_connect_hub(
hass: HomeAssistant, address: str, api_version: int | None = None
) -> PowerviewAPI:

View File

@@ -13,6 +13,7 @@ from typing import Any
from aiohttp import web
from hyperion import client
from hyperion.const import (
KEY_DATA,
KEY_IMAGE,
KEY_IMAGE_STREAM,
KEY_LEDCOLORS,
@@ -155,7 +156,8 @@ class HyperionCamera(Camera):
"""Update Hyperion components."""
if not img:
return
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
# Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions
img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE)
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
return
async with self._image_cond:

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from propcache.api import cached_property
from pyituran import Vehicle
from homeassistant.components.binary_sensor import (
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@cached_property
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.vehicle)

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from propcache.api import cached_property
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
"""Initialize the device tracker."""
super().__init__(coordinator, license_plate, "device_tracker")
@cached_property
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self.vehicle.gps_coordinates[0]
@cached_property
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self.vehicle.gps_coordinates[1]

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from propcache.api import cached_property
from pyituran import Vehicle
from homeassistant.components.sensor import (
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@cached_property
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the device."""
return self.entity_description.value_fn(self.vehicle)

View File

@@ -462,5 +462,40 @@
}
}
},
"title": "Light"
"title": "Light",
"triggers": {
"turns_on": {
"description": "Triggers when a light turns on.",
"description_configured": "Triggers when a light turns on",
"name": "When a light turns on"
},
"turns_off": {
"description": "Triggers when a light turns off.",
"description_configured": "Triggers when a light turns off",
"name": "When a light turns off"
},
"brightness_changed": {
"description": "Triggers when the brightness of a light changes.",
"description_configured": "Triggers when the brightness of a light changes",
"fields": {
"lower": {
"description": "The minimum brightness value to trigger on. Only triggers when brightness is at or above this value.",
"name": "Lower limit"
},
"upper": {
"description": "The maximum brightness value to trigger on. Only triggers when brightness is at or below this value.",
"name": "Upper limit"
},
"above": {
"description": "Only trigger when brightness is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when brightness is below this value.",
"name": "Below"
}
},
"name": "When the brightness of a light changes"
}
}
}

View File

@@ -0,0 +1,288 @@
"""Provides triggers for lights."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
ATTR_BRIGHTNESS = "brightness"
CONF_LOWER = "lower"
CONF_UPPER = "upper"
CONF_ABOVE = "above"
CONF_BELOW = "below"
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
BRIGHTNESS_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Exclusive(CONF_LOWER, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_UPPER, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_ABOVE, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_BELOW, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class LightTurnsOnTrigger(Trigger):
"""Trigger for when a light turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when light turns on (from off to on)
if from_state and from_state.state == STATE_OFF and to_state.state == STATE_ON:
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"light turned on on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class LightTurnsOffTrigger(Trigger):
"""Trigger for when a light turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when light turns off (from on to off)
if from_state and from_state.state == STATE_ON and to_state.state == STATE_OFF:
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"light turned off on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class LightBrightnessChangedTrigger(Trigger):
"""Trigger for when a light's brightness changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, BRIGHTNESS_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light brightness changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._options = config.options or {}
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
lower_limit = self._options.get(CONF_LOWER)
upper_limit = self._options.get(CONF_UPPER)
above_limit = self._options.get(CONF_ABOVE)
below_limit = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Get brightness values
from_brightness = (
from_state.attributes.get(ATTR_BRIGHTNESS) if from_state else None
)
to_brightness = to_state.attributes.get(ATTR_BRIGHTNESS)
# Only trigger if brightness value exists and has changed
if to_brightness is None or from_brightness == to_brightness:
return
# Apply threshold filters if configured
if lower_limit is not None and to_brightness < lower_limit:
return
if upper_limit is not None and to_brightness > upper_limit:
return
if above_limit is not None and to_brightness <= above_limit:
return
if below_limit is not None and to_brightness >= below_limit:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
"from_brightness": from_brightness,
"to_brightness": to_brightness,
},
f"brightness changed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": LightTurnsOnTrigger,
"turns_off": LightTurnsOffTrigger,
"brightness_changed": LightBrightnessChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for lights."""
return TRIGGERS

View File

@@ -0,0 +1,43 @@
turns_on:
target:
entity:
domain: light
turns_off:
target:
entity:
domain: light
brightness_changed:
target:
entity:
domain: light
fields:
lower:
required: false
selector:
number:
min: 0
max: 255
mode: box
upper:
required: false
selector:
number:
min: 0
max: 255
mode: box
above:
required: false
selector:
number:
min: 0
max: 255
mode: box
below:
required: false
selector:
number:
min: 0
max: 255
mode: box

View File

@@ -353,17 +353,13 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled
device_to_ha={
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kLocalOverride: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemotePressure: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteFlow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteTemperature: False,
}.get,
device_to_ha=lambda x: bool(
x
& (
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
| clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
)
),
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -377,9 +373,9 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning",
translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: (
device_to_ha=lambda x: bool(
x
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
& clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
),
),
entity_class=MatterBinarySensor,
@@ -395,8 +391,8 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_inflow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
),
),
entity_class=MatterBinarySensor,
@@ -410,8 +406,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
),
),
entity_class=MatterBinarySensor,
@@ -481,8 +477,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
device_to_ha=lambda x: bool(
x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
),
),
entity_class=MatterBinarySensor,

View File

@@ -367,5 +367,63 @@
"name": "Turn up volume"
}
},
"title": "Media player"
"title": "Media player",
"triggers": {
"turns_on": {
"description": "Triggers when a media player turns on.",
"description_configured": "Triggers when a media player turns on",
"name": "When a media player turns on"
},
"turns_off": {
"description": "Triggers when a media player turns off.",
"description_configured": "Triggers when a media player turns off",
"name": "When a media player turns off"
},
"playing": {
"description": "Triggers when a media player starts playing.",
"description_configured": "Triggers when a media player starts playing",
"fields": {
"media_content_type": {
"description": "The media content types to trigger on. If empty, triggers on all content types.",
"name": "Media content types"
}
},
"name": "When a media player starts playing"
},
"paused": {
"description": "Triggers when a media player pauses.",
"description_configured": "Triggers when a media player pauses",
"name": "When a media player pauses"
},
"stopped": {
"description": "Triggers when a media player stops playing.",
"description_configured": "Triggers when a media player stops playing",
"name": "When a media player stops playing"
},
"muted": {
"description": "Triggers when a media player gets muted.",
"description_configured": "Triggers when a media player gets muted",
"name": "When a media player gets muted"
},
"unmuted": {
"description": "Triggers when a media player gets unmuted.",
"description_configured": "Triggers when a media player gets unmuted",
"name": "When a media player gets unmuted"
},
"volume_changed": {
"description": "Triggers when a media player volume changes.",
"description_configured": "Triggers when a media player volume changes",
"fields": {
"above": {
"description": "Only trigger when volume is above this level (0.0-1.0).",
"name": "Above"
},
"below": {
"description": "Only trigger when volume is below this level (0.0-1.0).",
"name": "Below"
}
},
"name": "When a media player volume changes"
}
}
}

View File

@@ -0,0 +1,676 @@
"""Provides triggers for media players."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_IDLE,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN,
)
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
PLAYING_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(ATTR_MEDIA_CONTENT_TYPE, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
STOPPED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
MUTED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
UNMUTED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
VOLUME_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional("above"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
vol.Optional("below"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
PAUSED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class MediaPlayerTurnsOnTrigger(Trigger):
"""Trigger for when a media player turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when turning on from off state
if (
from_state is not None
and from_state.state == STATE_OFF
and to_state.state != STATE_OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} turned on",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerTurnsOffTrigger(Trigger):
"""Trigger for when a media player turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when turning off
if (
from_state is not None
and from_state.state != STATE_OFF
and to_state.state == STATE_OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} turned off",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerPlayingTrigger(Trigger):
"""Trigger for when a media player starts playing."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, PLAYING_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player playing trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
media_content_types_filter = self._options[ATTR_MEDIA_CONTENT_TYPE]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when starting to play
if (
from_state is not None
and from_state.state != STATE_PLAYING
and to_state.state == STATE_PLAYING
):
# If media_content_type filter is specified, check if it matches
if media_content_types_filter:
media_content_type = to_state.attributes.get(ATTR_MEDIA_CONTENT_TYPE)
if media_content_type not in media_content_types_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} started playing",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerPausedTrigger(Trigger):
"""Trigger for when a media player pauses."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, PAUSED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player paused trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when pausing
if (
from_state is not None
and from_state.state != STATE_PAUSED
and to_state.state == STATE_PAUSED
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} paused",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerStoppedTrigger(Trigger):
"""Trigger for when a media player stops playing."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STOPPED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player stopped trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when stopping (to idle or off from playing/paused states)
if (
from_state is not None
and from_state.state in (STATE_PLAYING, STATE_PAUSED)
and to_state.state in (STATE_IDLE, STATE_OFF)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} stopped",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerMutedTrigger(Trigger):
"""Trigger for when a media player gets muted."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, MUTED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player muted trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when muting
if (
from_state is not None
and not from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
and to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} muted",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerUnmutedTrigger(Trigger):
"""Trigger for when a media player gets unmuted."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, UNMUTED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player unmuted trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when unmuting
if (
from_state is not None
and from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
and not to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} unmuted",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerVolumeChangedTrigger(Trigger):
"""Trigger for when a media player volume changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, VOLUME_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player volume changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above_threshold = self._options.get("above")
below_threshold = self._options.get("below")
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Get volume levels
old_volume = (
from_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
if from_state is not None
else None
)
new_volume = to_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
# Volume must have changed
if old_volume == new_volume or new_volume is None:
return
# Check thresholds if specified
if above_threshold is not None and new_volume <= above_threshold:
return
if below_threshold is not None and new_volume >= below_threshold:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} volume changed",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": MediaPlayerTurnsOnTrigger,
"turns_off": MediaPlayerTurnsOffTrigger,
"playing": MediaPlayerPlayingTrigger,
"paused": MediaPlayerPausedTrigger,
"stopped": MediaPlayerStoppedTrigger,
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": MediaPlayerVolumeChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for media players."""
return TRIGGERS

View File

@@ -0,0 +1,65 @@
turns_on:
target:
entity:
domain: media_player
turns_off:
target:
entity:
domain: media_player
playing:
target:
entity:
domain: media_player
fields:
media_content_type:
required: false
default: []
selector:
select:
multiple: true
custom_value: true
options: []
paused:
target:
entity:
domain: media_player
stopped:
target:
entity:
domain: media_player
muted:
target:
entity:
domain: media_player
unmuted:
target:
entity:
domain: media_player
volume_changed:
target:
entity:
domain: media_player
fields:
above:
required: false
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider
below:
required: false
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider

View File

@@ -1009,7 +1009,7 @@
"cleaning_care_program": "Cleaning/care program",
"maintenance_program": "Maintenance program",
"normal_operation_mode": "Normal operation mode",
"own_program": "Own program"
"own_program": "Program"
}
},
"remaining_time": {
@@ -1089,7 +1089,7 @@
"message": "Invalid device targeted."
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"set_program_error": {
"message": "'Set program' action failed: {status} / {message}"

View File

@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
from music_assistant_models.api import ServerInfoMessage
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
@@ -21,21 +21,14 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
DEFAULT_URL = "http://mass.local:8095"
DEFAULT_TITLE = "Music Assistant"
DEFAULT_URL = "http://mass.local:8095"
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
"""Return a schema for the manual step."""
default_url = user_input.get(CONF_URL, DEFAULT_URL)
return vol.Schema(
{
vol.Required(CONF_URL, default=default_url): str,
}
)
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_URL): str})
async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
"""Validate the user input allows us to connect."""
async with MusicAssistantClient(
url, aiohttp_client.async_get_clientsession(hass)
@@ -52,25 +45,17 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Set up flow instance."""
self.server_info: ServerInfoMessage | None = None
self.url: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a manual configuration."""
errors: dict[str, str] = {}
if user_input is not None:
try:
self.server_info = await get_server_info(
self.hass, user_input[CONF_URL]
)
await self.async_set_unique_id(
self.server_info.server_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_URL: user_input[CONF_URL]},
reload_on_update=True,
)
server_info = await _get_server_info(self.hass, user_input[CONF_URL])
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidServerVersion:
@@ -79,68 +64,49 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=DEFAULT_TITLE,
data={
CONF_URL: user_input[CONF_URL],
},
await self.async_set_unique_id(
server_info.server_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_URL: user_input[CONF_URL]}
)
return self.async_show_form(
step_id="user", data_schema=get_manual_schema(user_input), errors=errors
)
return self.async_create_entry(
title=DEFAULT_TITLE,
data={CONF_URL: user_input[CONF_URL]},
)
return self.async_show_form(step_id="user", data_schema=get_manual_schema({}))
suggested_values = user_input
if suggested_values is None:
suggested_values = {CONF_URL: DEFAULT_URL}
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_SCHEMA, suggested_values
),
errors=errors,
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Mass server.
This flow is triggered by the Zeroconf component. It will check if the
host is already configured and delegate to the import step if not.
"""
# abort if discovery info is not what we expect
if "server_id" not in discovery_info.properties:
return self.async_abort(reason="missing_server_id")
self.server_info = ServerInfoMessage.from_dict(discovery_info.properties)
await self.async_set_unique_id(self.server_info.server_id)
# Check if we already have a config entry for this server_id
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, self.server_info.server_id
)
if existing_entry:
# If the entry was ignored or disabled, don't make any changes
if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by:
return self.async_abort(reason="already_configured")
# Test connectivity to the current URL first
current_url = existing_entry.data[CONF_URL]
try:
await get_server_info(self.hass, current_url)
# Current URL is working, no need to update
return self.async_abort(reason="already_configured")
except CannotConnect:
# Current URL is not working, update to the discovered URL
# and continue to discovery confirm
self.hass.config_entries.async_update_entry(
existing_entry,
data={**existing_entry.data, CONF_URL: self.server_info.base_url},
)
# Schedule reload since URL changed
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
else:
# No existing entry, proceed with normal flow
self._abort_if_unique_id_configured()
# Test connectivity to the discovered URL
"""Handle a zeroconf discovery for a Music Assistant server."""
try:
await get_server_info(self.hass, self.server_info.base_url)
server_info = ServerInfoMessage.from_dict(discovery_info.properties)
except LookupError:
return self.async_abort(reason="invalid_discovery_info")
self.url = server_info.base_url
await self.async_set_unique_id(server_info.server_id)
self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
try:
await _get_server_info(self.hass, self.url)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
@@ -148,16 +114,16 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle user-confirmation of discovered server."""
if TYPE_CHECKING:
assert self.server_info is not None
assert self.url is not None
if user_input is not None:
return self.async_create_entry(
title=DEFAULT_TITLE,
data={
CONF_URL: self.server_info.base_url,
},
data={CONF_URL: self.url},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"url": self.server_info.base_url},
description_placeholders={"url": self.url},
)

View File

@@ -2,7 +2,7 @@
"domain": "music_assistant",
"name": "Music Assistant",
"after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant"],
"codeowners": ["@music-assistant", "@arturpragacz"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",

View File

@@ -57,7 +57,7 @@
"message": "Error while loading the integration."
},
"implementation_unavailable": {
"message": "OAuth2 implementation is not available, will retry."
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"incorrect_oauth2_scope": {
"message": "Stored permissions are invalid. Please login again to update permissions."

View File

@@ -20,10 +20,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -73,17 +74,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Netatmo from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
# Set unique id if non was set (migration)
if not entry.unique_id:
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as ex:

View File

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

View File

@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
schema_errors |= validate_table_schema_supports_utf8(
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
)
schema_errors |= validate_table_schema_has_correct_collation(
instance, StatisticsMeta
)
for table in (Statistics, StatisticsShortTerm):
schema_errors |= validate_db_schema_precision(instance, table)
schema_errors |= validate_table_schema_has_correct_collation(instance, table)

View File

@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38
CIRCULAR_MEAN_SCHEMA_VERSION = 49
UNIT_CLASS_SCHEMA_VERSION = 51
UNIT_CLASS_SCHEMA_VERSION = 52
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 51
SCHEMA_VERSION = 52
_LOGGER = logging.getLogger(__name__)

View File

@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID
import sqlalchemy
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
from sqlalchemy import (
ForeignKeyConstraint,
MetaData,
Table,
cast as cast_,
func,
text,
update,
)
from sqlalchemy.engine import CursorResult, Engine
from sqlalchemy.exc import (
DatabaseError,
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.session import Session
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
from sqlalchemy.sql.expression import true
from sqlalchemy.sql.expression import and_, true
from sqlalchemy.sql.lambdas import StatementLambdaElement
from sqlalchemy.types import BINARY
from homeassistant.core import HomeAssistant
from homeassistant.util.enum import try_parse_enum
@@ -2044,14 +2053,74 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
def _apply_update(self) -> None:
"""Version specific update method."""
# Add unit class column to StatisticsMeta
# Replaced with version 52 which corrects issues with MySQL string comparisons.
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
def _apply_update(self) -> None:
"""Version specific update method."""
if self.engine.dialect.name == SupportedDialect.MYSQL:
self._apply_update_mysql()
else:
self._apply_update_postgresql_sqlite()
def _apply_update_mysql(self) -> None:
"""Version specific update method for mysql."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
case_sensitive_units = {
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
}
# Reset unit_class to None for entries that do not match
# the valid units (case sensitive) but matched before due to
# case insensitive comparisons.
connection.execute(
update(StatisticsMeta)
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS))
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
case_sensitive_units
),
)
)
.values(unit_class=None)
)
# Do an explicitly case sensitive match (actually binary) to set the
# correct unit_class. This is needed because we use the case sensitive
# utf8mb4_unicode_ci collation.
connection.execute(
update(StatisticsMeta)
.where(
and_(
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
case_sensitive_units
),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)
def _apply_update_postgresql_sqlite(self) -> None:
"""Version specific update method for postgresql and sqlite."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
# have case sensitive string comparisons by default, so we
# can directly match on the valid units.
connection.execute(
update(StatisticsMeta)
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)

View File

@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
_LOGGER = logging.getLogger(__name__)
QUERY_STATISTIC_META = (
QUERY_STATISTICS_META = (
StatisticsMeta.id,
StatisticsMeta.statistic_id,
StatisticsMeta.source,
@@ -55,7 +55,7 @@ def _generate_get_metadata_stmt(
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
"""
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
columns.append(StatisticsMeta.mean_type)
else:

View File

@@ -12,10 +12,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
httpx_client,
from homeassistant.helpers import config_validation as cv, httpx_client
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -28,19 +29,21 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SENZ from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
senz_api = SENZAPI(auth)

View File

@@ -35,7 +35,7 @@ async def async_setup_entry(
)
class SENZClimate(CoordinatorEntity, ClimateEntity):
class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
"""Representation of a SENZ climate entity."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS

View File

@@ -0,0 +1,29 @@
"""Diagnostics platform for Senz integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
TO_REDACT = [
"access_token",
"refresh_token",
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
raw_data = (
[device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()],
)
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"thermostats": raw_data,
}

View File

@@ -0,0 +1,93 @@
"""nVent RAYCHEM SENZ sensor platform."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from aiosenz import Thermostat
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from .const import DOMAIN
@dataclass(kw_only=True, frozen=True)
class SenzSensorDescription(SensorEntityDescription):
"""Describes SENZ sensor entity."""
value_fn: Callable[[Thermostat], str | int | float | None]
SENSORS: tuple[SenzSensorDescription, ...] = (
SenzSensorDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=lambda data: data.current_temperatue,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ sensor entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
SENZSensor(thermostat, coordinator, description)
for description in SENSORS
for thermostat in coordinator.data.values()
)
class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
"""Representation of a SENZ sensor entity."""
entity_description: SenzSensorDescription
_attr_has_entity_name = True
def __init__(
self,
thermostat: Thermostat,
coordinator: SENZDataUpdateCoordinator,
description: SenzSensorDescription,
) -> None:
"""Init SENZ sensor."""
super().__init__(coordinator)
self.entity_description = description
self._thermostat = thermostat
self._attr_unique_id = f"{thermostat.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, thermostat.serial_number)},
manufacturer="nVent Raychem",
model="SENZ WIFI",
name=thermostat.name,
serial_number=thermostat.serial_number,
)
@property
def available(self) -> bool:
"""Return True if the thermostat is available."""
return super().available and self._thermostat.online
@property
def native_value(self) -> str | float | int | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._thermostat)

View File

@@ -25,5 +25,10 @@
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -12,6 +12,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.number import (
DOMAIN as NUMBER_PLATFORM,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberExtraStoredData,
@@ -107,6 +108,9 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
if description.mode_fn is not None:
self._attr_mode = description.mode_fn(coordinator.device.config[key])
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
delattr(self, "_attr_name")
@property
def native_value(self) -> float | None:
"""Return value of number."""
@@ -181,7 +185,6 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
("device", "valvePos"): BlockNumberDescription(
key="device|valvepos",
translation_key="valve_position",
name="Valve position",
native_unit_of_measurement=PERCENTAGE,
available=lambda block: cast(int, block.valveError) != 1,
entity_category=EntityCategory.CONFIG,
@@ -200,12 +203,12 @@ RPC_NUMBERS: Final = {
key="blutrv",
sub_key="current_C",
translation_key="external_temperature",
name="External temperature",
native_min_value=-50,
native_max_value=50,
native_step=0.1,
mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
method="blu_trv_set_external_temperature",
entity_class=RpcBluTrvExtTempNumber,
@@ -213,7 +216,7 @@ RPC_NUMBERS: Final = {
"number_generic": RpcNumberDescription(
key="number",
sub_key="value",
removal_condition=lambda config, _status, key: not is_view_for_platform(
removal_condition=lambda config, _, key: not is_view_for_platform(
config, key, NUMBER_PLATFORM
),
max_fn=lambda config: config["max"],
@@ -229,9 +232,11 @@ RPC_NUMBERS: Final = {
"number_current_limit": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="current_limit",
device_class=NumberDeviceClass.CURRENT,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -241,10 +246,11 @@ RPC_NUMBERS: Final = {
"number_position": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="valve_position",
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -254,10 +260,12 @@ RPC_NUMBERS: Final = {
"number_target_humidity": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="target_humidity",
device_class=NumberDeviceClass.HUMIDITY,
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -267,10 +275,12 @@ RPC_NUMBERS: Final = {
"number_target_temperature": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="target_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -281,21 +291,20 @@ RPC_NUMBERS: Final = {
key="blutrv",
sub_key="pos",
translation_key="valve_position",
name="Valve position",
native_min_value=0,
native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_unit_of_measurement=PERCENTAGE,
method="blu_trv_set_valve_position",
removal_condition=lambda config, _status, key: config[key].get("enable", True)
removal_condition=lambda config, _, key: config[key].get("enable", True)
is True,
entity_class=RpcBluTrvNumber,
),
"left_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
name="Left slot intensity",
translation_key="left_slot_intensity",
value=lambda status, _: status["left"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -311,7 +320,7 @@ RPC_NUMBERS: Final = {
"right_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
name="Right slot intensity",
translation_key="right_slot_intensity",
value=lambda status, _: status["right"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -402,6 +411,9 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
self.restored_data: NumberExtraStoredData | None = None
super().__init__(coordinator, block, attribute, description, entry)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()

View File

@@ -188,6 +188,29 @@
}
}
},
"number": {
"current_limit": {
"name": "Current limit"
},
"external_temperature": {
"name": "External temperature"
},
"left_slot_intensity": {
"name": "Left slot intensity"
},
"right_slot_intensity": {
"name": "Right slot intensity"
},
"target_humidity": {
"name": "Target humidity"
},
"target_temperature": {
"name": "Target temperature"
},
"valve_position": {
"name": "Valve position"
}
},
"select": {
"cury_mode": {
"name": "Mode",

View File

@@ -75,6 +75,7 @@ PLATFORMS_BY_TYPE = {
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
@@ -102,6 +103,10 @@ PLATFORMS_BY_TYPE = {
SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR],
SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: [
Platform.CLIMATE,
Platform.SENSOR,
],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -119,6 +124,7 @@ CLASS_BY_DEVICE = {
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum,
@@ -136,6 +142,7 @@ CLASS_BY_DEVICE = {
SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM,
SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener,
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: switchbot.SwitchbotSmartThermostatRadiator,
}

View File

@@ -0,0 +1,140 @@
"""Support for Switchbot Climate devices."""
from __future__ import annotations
import logging
from typing import Any
import switchbot
from switchbot import (
ClimateAction as SwitchBotClimateAction,
ClimateMode as SwitchBotClimateMode,
)
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry
from .entity import SwitchbotEntity, exception_handler
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE = {
SwitchBotClimateMode.HEAT: HVACMode.HEAT,
SwitchBotClimateMode.OFF: HVACMode.OFF,
}
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE = {
HVACMode.HEAT: SwitchBotClimateMode.HEAT,
HVACMode.OFF: SwitchBotClimateMode.OFF,
}
SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION = {
SwitchBotClimateAction.HEATING: HVACAction.HEATING,
SwitchBotClimateAction.IDLE: HVACAction.IDLE,
SwitchBotClimateAction.OFF: HVACAction.OFF,
}
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot climate based on a config entry."""
coordinator = entry.runtime_data
async_add_entities([SwitchBotClimateEntity(coordinator)])
class SwitchBotClimateEntity(SwitchbotEntity, ClimateEntity):
"""Representation of a Switchbot Climate device."""
_device: switchbot.SwitchbotDevice
_attr_supported_features = (
ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_target_temperature_step = 0.5
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "climate"
_attr_name = None
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
return self._device.min_temperature
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
return self._device.max_temperature
@property
def preset_modes(self) -> list[str] | None:
"""Return the list of available preset modes."""
return self._device.preset_modes
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self._device.preset_mode
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
return SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE.get(
self._device.hvac_mode, HVACMode.OFF
)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available HVAC modes."""
return [
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE[mode]
for mode in self._device.hvac_modes
]
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current HVAC action."""
return SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION.get(
self._device.hvac_action, HVACAction.OFF
)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._device.current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self._device.target_temperature
@exception_handler
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
return await self._device.set_hvac_mode(
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE[hvac_mode]
)
@exception_handler
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
return await self._device.set_preset_mode(preset_mode)
@exception_handler
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
return await self._device.set_target_temperature(temperature)

View File

@@ -58,6 +58,8 @@ class SupportedModels(StrEnum):
K11_PLUS_VACUUM = "k11+_vacuum"
GARAGE_DOOR_OPENER = "garage_door_opener"
CLIMATE_PANEL = "climate_panel"
SMART_THERMOSTAT_RADIATOR = "smart_thermostat_radiator"
S20_VACUUM = "s20_vacuum"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -78,6 +80,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN,
SwitchbotModel.K20_VACUUM: SupportedModels.K20_VACUUM,
SwitchbotModel.S10_VACUUM: SupportedModels.S10_VACUUM,
SwitchbotModel.S20_VACUUM: SupportedModels.S20_VACUUM,
SwitchbotModel.K10_VACUUM: SupportedModels.K10_VACUUM,
SwitchbotModel.K10_PRO_VACUUM: SupportedModels.K10_PRO_VACUUM,
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
@@ -95,6 +98,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM,
SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER,
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: SupportedModels.SMART_THERMOSTAT_RADIATOR,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -132,6 +136,7 @@ ENCRYPTED_MODELS = {
SwitchbotModel.PLUG_MINI_EU,
SwitchbotModel.RELAY_SWITCH_2PM,
SwitchbotModel.GARAGE_DOOR_OPENER,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
}
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@@ -153,6 +158,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM,
SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator,
}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {

View File

@@ -1,5 +1,18 @@
{
"entity": {
"climate": {
"climate": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "mdi:hand-back-right",
"off": "mdi:hvac-off",
"schedule": "mdi:calendar-clock"
}
}
}
}
},
"fan": {
"air_purifier": {
"default": "mdi:air-purifier",

View File

@@ -100,6 +100,19 @@
"name": "Unlocked alarm"
}
},
"climate": {
"climate": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "[%key:common::state::manual%]",
"off": "[%key:common::state::off%]",
"schedule": "Schedule"
}
}
}
}
},
"cover": {
"cover": {
"state_attributes": {

View File

@@ -84,6 +84,7 @@
"abort": {
"already_configured": "Chat already configured"
},
"entry_type": "Allowed chat ID",
"error": {
"chat_not_found": "Chat not found"
},

View File

@@ -23,6 +23,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -61,6 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
except ValueError as e:
# Remove invalid implementation from config entry then raise AuthFailed
hass.config_entries.async_update_entry(

View File

@@ -609,6 +609,9 @@
"no_cable": {
"message": "Charge cable will lock automatically when connected"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_failed": {
"message": "{endpoint} data request failed: {message}"
}

View File

@@ -11,8 +11,10 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -86,7 +88,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Toon from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
coordinator = ToonDataUpdateCoordinator(hass, entry, session)

View File

@@ -32,6 +32,11 @@
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"services": {
"update": {
"description": "Updates all entities with fresh data from Toon.",

View File

@@ -181,15 +181,14 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF
)
if (
self._thermostat_module.mode not in STATE_TO_ACTION
and self._attr_hvac_action is not HVACAction.OFF
):
_LOGGER.warning(
"Unknown thermostat state, defaulting to OFF: %s",
self._thermostat_module.mode,
)
self._attr_hvac_action = HVACAction.OFF
if self._thermostat_module.mode not in STATE_TO_ACTION:
# Report a warning on the first non-default unknown mode
if self._attr_hvac_action is not HVACAction.OFF:
_LOGGER.warning(
"Unknown thermostat state, defaulting to OFF: %s",
self._thermostat_module.mode,
)
self._attr_hvac_action = HVACAction.OFF
return True
self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode]

View File

@@ -2,13 +2,14 @@
from functools import partial
import logging
from typing import cast
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, selector
from .const import (
@@ -23,7 +24,7 @@ from .const import (
SERVICE_START_TORRENT,
SERVICE_STOP_TORRENT,
)
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
from .coordinator import TransmissionDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -67,45 +68,52 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All(
def _get_coordinator_from_service_data(
hass: HomeAssistant, entry_id: str
call: ServiceCall,
) -> TransmissionDataUpdateCoordinator:
"""Return coordinator for entry id."""
entry: TransmissionConfigEntry | None = hass.config_entries.async_get_entry(
entry_id
)
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(f"Config entry {entry_id} is not found or not loaded")
return entry.runtime_data
config_entry_id: str = call.data[CONF_ENTRY_ID]
if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return cast(TransmissionDataUpdateCoordinator, entry.runtime_data)
async def _async_add_torrent(service: ServiceCall) -> None:
"""Add new torrent to download."""
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
coordinator = _get_coordinator_from_service_data(service)
torrent: str = service.data[ATTR_TORRENT]
download_path: str | None = service.data.get(ATTR_DOWNLOAD_PATH)
if torrent.startswith(
("http", "ftp:", "magnet:")
) or service.hass.config.is_allowed_path(torrent):
if download_path:
await service.hass.async_add_executor_job(
partial(
coordinator.api.add_torrent, torrent, download_dir=download_path
)
)
else:
await service.hass.async_add_executor_job(
coordinator.api.add_torrent, torrent
)
await coordinator.async_request_refresh()
if not (
torrent.startswith(("http", "ftp:", "magnet:"))
or service.hass.config.is_allowed_path(torrent)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="could_not_add_torrent",
)
if download_path:
await service.hass.async_add_executor_job(
partial(coordinator.api.add_torrent, torrent, download_dir=download_path)
)
else:
_LOGGER.warning("Could not add torrent: unsupported type or no permission")
await service.hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
await coordinator.async_request_refresh()
async def _async_start_torrent(service: ServiceCall) -> None:
"""Start torrent."""
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
coordinator = _get_coordinator_from_service_data(service)
torrent_id = service.data[CONF_ID]
await service.hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id)
await coordinator.async_request_refresh()
@@ -113,8 +121,7 @@ async def _async_start_torrent(service: ServiceCall) -> None:
async def _async_stop_torrent(service: ServiceCall) -> None:
"""Stop torrent."""
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
coordinator = _get_coordinator_from_service_data(service)
torrent_id = service.data[CONF_ID]
await service.hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id)
await coordinator.async_request_refresh()
@@ -122,8 +129,7 @@ async def _async_stop_torrent(service: ServiceCall) -> None:
async def _async_remove_torrent(service: ServiceCall) -> None:
"""Remove torrent."""
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
coordinator = _get_coordinator_from_service_data(service)
torrent_id = service.data[CONF_ID]
delete_data = service.data[ATTR_DELETE_DATA]
await service.hass.async_add_executor_job(

View File

@@ -1,6 +1,7 @@
add_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission
@@ -18,6 +19,7 @@ add_torrent:
remove_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission
@@ -27,6 +29,7 @@ remove_torrent:
selector:
text:
delete_data:
required: true
default: false
selector:
boolean:
@@ -34,17 +37,20 @@ remove_torrent:
start_torrent:
fields:
entry_id:
selector:
config_entry:
integration: transmission
id:
example: 123
selector:
text:
stop_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission
id:
required: true
example: 123
selector:
text:
stop_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission

View File

@@ -87,6 +87,17 @@
}
}
},
"exceptions": {
"could_not_add_torrent": {
"message": "Could not add torrent: unsupported type or no permission."
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"not_loaded": {
"message": "{target} is not loaded."
}
},
"options": {
"step": {
"init": {

View File

@@ -19,9 +19,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import EnumTypeData, find_dpcode
from .models import DPCodeEnumWrapper
from .util import get_dpcode
@@ -85,9 +85,21 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := ALARM.get(device.category):
entities.extend(
TuyaAlarmEntity(device, manager, description)
TuyaAlarmEntity(
device,
manager,
description,
action_dpcode_wrapper=action_dpcode_wrapper,
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.master_state
),
)
for description in descriptions
if description.key in device.status
if (
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
)
async_add_entities(entities)
@@ -103,7 +115,6 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
_attr_name = None
_attr_code_arm_required = False
_master_state: EnumTypeData | None = None
_alarm_msg_dpcode: DPCode | None = None
def __init__(
@@ -111,33 +122,24 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaAlarmControlPanelEntityDescription,
*,
action_dpcode_wrapper: DPCodeEnumWrapper,
state_dpcode_wrapper: DPCodeEnumWrapper | None,
) -> None:
"""Init Tuya Alarm."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._action_dpcode_wrapper = action_dpcode_wrapper
self._state_dpcode_wrapper = state_dpcode_wrapper
# Determine supported modes
if supported_modes := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
):
if Mode.HOME in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine master state
if enum_type := find_dpcode(
self.device,
description.master_state,
dptype=DPType.ENUM,
prefer_function=True,
):
self._master_state = enum_type
if Mode.HOME in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine alarm message
if dp_code := get_dpcode(self.device, description.alarm_msg):
@@ -149,8 +151,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
if (
self._master_state is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM
self._state_dpcode_wrapper is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
):
# Only report as triggered if NOT a battery warning
if (
@@ -166,28 +168,26 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
def changed_by(self) -> str | None:
"""Last change triggered by."""
if (
self._master_state is not None
self._state_dpcode_wrapper is not None
and self._alarm_msg_dpcode is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
):
return b64decode(encoded_msg).decode("utf-16be")
return None
def alarm_disarm(self, code: str | None = None) -> None:
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
self._send_command(
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
)
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
def alarm_arm_home(self, code: str | None = None) -> None:
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}])
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
def alarm_arm_away(self, code: str | None = None) -> None:
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}])
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
def alarm_trigger(self, code: str | None = None) -> None:
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}])
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)

View File

@@ -6,13 +6,14 @@ from abc import ABC, abstractmethod
import base64
from dataclasses import dataclass
import json
import struct
from typing import Any, Literal, Self, overload
from tuya_sharing import CustomerDevice
from homeassistant.util.json import json_loads
from .const import DPCode, DPType
from .util import remap_value
from .util import parse_dptype, remap_value
@dataclass
@@ -134,6 +135,8 @@ _TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
DPType.BOOLEAN: TypeInformation,
DPType.ENUM: EnumTypeData,
DPType.INTEGER: IntegerTypeData,
DPType.JSON: TypeInformation,
DPType.RAW: TypeInformation,
}
@@ -144,6 +147,9 @@ class DPCodeWrapper(ABC):
access read conversion routines.
"""
native_unit: str | None = None
suggested_unit: str | None = None
def __init__(self, dpcode: str) -> None:
"""Init DPCodeWrapper."""
self.dpcode = dpcode
@@ -196,7 +202,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...],
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
) -> Self | None:
@@ -210,6 +216,20 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
return None
class DPCodeBase64Wrapper(DPCodeTypeInformationWrapper[TypeInformation]):
"""Wrapper to extract information from a RAW/binary value."""
DPTYPE = DPType.RAW
def read_bytes(self, device: CustomerDevice) -> bytes | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) is None or (
len(decoded := base64.b64decode(raw_value)) == 0
):
return None
return decoded
class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
"""Simple wrapper for boolean values.
@@ -235,6 +255,18 @@ class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
raise ValueError(f"Invalid boolean value `{value}`")
class DPCodeJsonWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
"""Wrapper to extract information from a JSON value."""
DPTYPE = DPType.JSON
def read_json(self, device: CustomerDevice) -> Any | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) is None:
return None
return json_loads(raw_value)
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
"""Simple wrapper for EnumTypeData values."""
@@ -268,6 +300,11 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
DPTYPE = DPType.INTEGER
def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None:
"""Init DPCodeIntegerWrapper."""
super().__init__(dpcode, type_information)
self.native_unit = type_information.unit
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode.
@@ -352,6 +389,16 @@ def find_dpcode(
) -> IntegerTypeData | None: ...
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
) -> TypeInformation | None: ...
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
@@ -381,7 +428,7 @@ def find_dpcode(
for device_specs in lookup_tuple:
if (
(current_definition := device_specs.get(dpcode))
and current_definition.type == dptype
and parse_dptype(current_definition.type) is dptype
and (
type_information := type_information_cls.from_json(
dpcode, current_definition.values
@@ -391,44 +438,3 @@ def find_dpcode(
return type_information
return None
class ComplexValue:
"""Complex value (for JSON/RAW parsing)."""
@classmethod
def from_json(cls, data: str) -> Self:
"""Load JSON string and return a ComplexValue object."""
raise NotImplementedError("from_json is not implemented for this type")
@classmethod
def from_raw(cls, data: str) -> Self | None:
"""Decode base64 string and return a ComplexValue object."""
raise NotImplementedError("from_raw is not implemented for this type")
@dataclass
class ElectricityValue(ComplexValue):
"""Electricity complex value."""
electriccurrent: str | None = None
power: str | None = None
voltage: str | None = None
@classmethod
def from_json(cls, data: str) -> Self:
"""Load JSON string and return a ElectricityValue object."""
return cls(**json.loads(data.lower()))
@classmethod
def from_raw(cls, data: str) -> Self | None:
"""Decode base64 string and return a ElectricityValue object."""
raw = base64.b64decode(data)
if len(raw) == 0:
return None
voltage = struct.unpack(">H", raw[0:2])[0] / 10.0
electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0
power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0
return cls(
electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage)
)

View File

@@ -502,14 +502,19 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
self._attr_native_min_value = dpcode_wrapper.type_information.min_scaled
self._attr_native_step = dpcode_wrapper.type_information.step_scaled
if description.native_unit_of_measurement is None:
self._attr_native_unit_of_measurement = dpcode_wrapper.type_information.unit
self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit
self._validate_device_class_unit()
def _validate_device_class_unit(self) -> None:
"""Validate device class unit compatibility."""
# Logic to ensure the set device class and API received Unit Of Measurement
# match Home Assistants requirements.
if (
self.device_class is not None
and not self.device_class.startswith(DOMAIN)
and description.native_unit_of_measurement is None
and self.entity_description.native_unit_of_measurement is None
# we do not need to check mappings if the API UOM is allowed
and self.native_unit_of_measurement
not in NUMBER_DEVICE_CLASS_UNITS[self.device_class]

View File

@@ -2,9 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import struct
from tuya_sharing import CustomerDevice, Manager
@@ -42,41 +41,134 @@ from .const import (
)
from .entity import TuyaEntity
from .models import (
ComplexValue,
ElectricityValue,
DPCodeBase64Wrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
DPCodeJsonWrapper,
DPCodeTypeInformationWrapper,
DPCodeWrapper,
EnumTypeData,
IntegerTypeData,
find_dpcode,
)
from .util import get_dptype
_WIND_DIRECTIONS = {
"north": 0.0,
"north_north_east": 22.5,
"north_east": 45.0,
"east_north_east": 67.5,
"east": 90.0,
"east_south_east": 112.5,
"south_east": 135.0,
"south_south_east": 157.5,
"south": 180.0,
"south_south_west": 202.5,
"south_west": 225.0,
"west_south_west": 247.5,
"west": 270.0,
"west_north_west": 292.5,
"north_west": 315.0,
"north_north_west": 337.5,
}
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
"""Custom DPCode Wrapper for converting enum to wind direction."""
DPTYPE = DPType.ENUM
_WIND_DIRECTIONS = {
"north": 0.0,
"north_north_east": 22.5,
"north_east": 45.0,
"east_north_east": 67.5,
"east": 90.0,
"east_south_east": 112.5,
"south_east": 135.0,
"south_south_east": 157.5,
"south": 180.0,
"south_south_west": 202.5,
"south_west": 225.0,
"west_south_west": 247.5,
"west": 270.0,
"west_north_west": 292.5,
"north_west": 315.0,
"north_north_west": 337.5,
}
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (
raw_value := self._read_device_status_raw(device)
) in self.type_information.range:
return self._WIND_DIRECTIONS.get(raw_value)
return None
class _JsonElectricityCurrentWrapper(DPCodeJsonWrapper):
"""Custom DPCode Wrapper for extracting electricity current from JSON."""
native_unit = UnitOfElectricCurrent.AMPERE
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_json(device)) is None:
return None
return raw_value.get("electricCurrent")
class _JsonElectricityPowerWrapper(DPCodeJsonWrapper):
"""Custom DPCode Wrapper for extracting electricity power from JSON."""
native_unit = UnitOfPower.KILO_WATT
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_json(device)) is None:
return None
return raw_value.get("power")
class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper):
"""Custom DPCode Wrapper for extracting electricity voltage from JSON."""
native_unit = UnitOfElectricPotential.VOLT
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_json(device)) is None:
return None
return raw_value.get("voltage")
class _RawElectricityCurrentWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting electricity current from base64."""
native_unit = UnitOfElectricCurrent.MILLIAMPERE
suggested_unit = UnitOfElectricCurrent.AMPERE
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">L", b"\x00" + raw_value[2:5])[0]
class _RawElectricityPowerWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting electricity power from base64."""
native_unit = UnitOfPower.WATT
suggested_unit = UnitOfPower.KILO_WATT
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">L", b"\x00" + raw_value[5:8])[0]
class _RawElectricityVoltageWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting electricity voltage from base64."""
native_unit = UnitOfElectricPotential.VOLT
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">H", raw_value[0:2])[0] / 10.0
CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper)
POWER_WRAPPER = (_RawElectricityPowerWrapper, _JsonElectricityPowerWrapper)
VOLTAGE_WRAPPER = (_RawElectricityVoltageWrapper, _JsonElectricityVoltageWrapper)
@dataclass(frozen=True)
class TuyaSensorEntityDescription(SensorEntityDescription):
"""Describes Tuya sensor entity."""
complex_type: type[ComplexValue] | None = None
subkey: str | None = None
state_conversion: Callable[[Any], StateType] | None = None
dpcode: DPCode | None = None
wrapper_class: tuple[type[DPCodeTypeInformationWrapper], ...] | None = None
# Commonly used battery sensors, that are reused in the sensors down below.
@@ -394,85 +486,76 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_A,
key=f"{DPCode.PHASE_A}electriccurrent",
dpcode=DPCode.PHASE_A,
translation_key="phase_a_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
complex_type=ElectricityValue,
subkey="electriccurrent",
wrapper_class=CURRENT_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_A,
key=f"{DPCode.PHASE_A}power",
dpcode=DPCode.PHASE_A,
translation_key="phase_a_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
complex_type=ElectricityValue,
subkey="power",
wrapper_class=POWER_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_A,
key=f"{DPCode.PHASE_A}voltage",
dpcode=DPCode.PHASE_A,
translation_key="phase_a_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
complex_type=ElectricityValue,
subkey="voltage",
wrapper_class=VOLTAGE_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_B,
key=f"{DPCode.PHASE_B}electriccurrent",
dpcode=DPCode.PHASE_B,
translation_key="phase_b_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
complex_type=ElectricityValue,
subkey="electriccurrent",
wrapper_class=CURRENT_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_B,
key=f"{DPCode.PHASE_B}power",
dpcode=DPCode.PHASE_B,
translation_key="phase_b_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
complex_type=ElectricityValue,
subkey="power",
wrapper_class=POWER_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_B,
key=f"{DPCode.PHASE_B}voltage",
dpcode=DPCode.PHASE_B,
translation_key="phase_b_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
complex_type=ElectricityValue,
subkey="voltage",
wrapper_class=VOLTAGE_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_C,
key=f"{DPCode.PHASE_C}electriccurrent",
dpcode=DPCode.PHASE_C,
translation_key="phase_c_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
complex_type=ElectricityValue,
subkey="electriccurrent",
wrapper_class=CURRENT_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_C,
key=f"{DPCode.PHASE_C}power",
dpcode=DPCode.PHASE_C,
translation_key="phase_c_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
complex_type=ElectricityValue,
subkey="power",
wrapper_class=POWER_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_C,
key=f"{DPCode.PHASE_C}voltage",
dpcode=DPCode.PHASE_C,
translation_key="phase_c_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
complex_type=ElectricityValue,
subkey="voltage",
wrapper_class=VOLTAGE_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.CUR_CURRENT,
@@ -972,7 +1055,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="wind_direction",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT,
state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)),
wrapper_class=(_WindDirectionWrapper,),
),
TuyaSensorEntityDescription(
key=DPCode.DEW_POINT_TEMP,
@@ -1485,12 +1568,11 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TOTAL_POWER,
key=f"{DPCode.TOTAL_POWER}power",
dpcode=DPCode.TOTAL_POWER,
translation_key="total_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
complex_type=ElectricityValue,
subkey="power",
),
TuyaSensorEntityDescription(
key=DPCode.SUPPLY_FREQUENCY,
@@ -1500,85 +1582,76 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_A,
key=f"{DPCode.PHASE_A}electriccurrent",
dpcode=DPCode.PHASE_A,
translation_key="phase_a_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
complex_type=ElectricityValue,
subkey="electriccurrent",
wrapper_class=CURRENT_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_A,
key=f"{DPCode.PHASE_A}power",
dpcode=DPCode.PHASE_A,
translation_key="phase_a_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
complex_type=ElectricityValue,
subkey="power",
wrapper_class=POWER_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_A,
key=f"{DPCode.PHASE_A}voltage",
dpcode=DPCode.PHASE_A,
translation_key="phase_a_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
complex_type=ElectricityValue,
subkey="voltage",
wrapper_class=VOLTAGE_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_B,
key=f"{DPCode.PHASE_B}electriccurrent",
dpcode=DPCode.PHASE_B,
translation_key="phase_b_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
complex_type=ElectricityValue,
subkey="electriccurrent",
wrapper_class=CURRENT_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_B,
key=f"{DPCode.PHASE_B}power",
dpcode=DPCode.PHASE_B,
translation_key="phase_b_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
complex_type=ElectricityValue,
subkey="power",
wrapper_class=POWER_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_B,
key=f"{DPCode.PHASE_B}voltage",
dpcode=DPCode.PHASE_B,
translation_key="phase_b_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
complex_type=ElectricityValue,
subkey="voltage",
wrapper_class=VOLTAGE_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_C,
key=f"{DPCode.PHASE_C}electriccurrent",
dpcode=DPCode.PHASE_C,
translation_key="phase_c_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
complex_type=ElectricityValue,
subkey="electriccurrent",
wrapper_class=CURRENT_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_C,
key=f"{DPCode.PHASE_C}power",
dpcode=DPCode.PHASE_C,
translation_key="phase_c_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
complex_type=ElectricityValue,
subkey="power",
wrapper_class=POWER_WRAPPER,
),
TuyaSensorEntityDescription(
key=DPCode.PHASE_C,
key=f"{DPCode.PHASE_C}voltage",
dpcode=DPCode.PHASE_C,
translation_key="phase_c_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
complex_type=ElectricityValue,
subkey="voltage",
wrapper_class=VOLTAGE_WRAPPER,
),
),
DeviceCategory.ZNNBQ: (
@@ -1639,6 +1712,27 @@ SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP]
SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG]
def _get_dpcode_wrapper(
device: CustomerDevice,
description: TuyaSensorEntityDescription,
) -> DPCodeWrapper | None:
"""Get DPCode wrapper for an entity description."""
dpcode = description.dpcode or description.key
wrapper: DPCodeWrapper | None
if description.wrapper_class:
for cls in description.wrapper_class:
if wrapper := cls.find_dpcode(device, dpcode):
return wrapper
return None
for cls in (DPCodeIntegerWrapper, DPCodeEnumWrapper):
if wrapper := cls.find_dpcode(device, dpcode):
return wrapper
return None
async def async_setup_entry(
hass: HomeAssistant,
entry: TuyaConfigEntry,
@@ -1655,9 +1749,9 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := SENSORS.get(device.category):
entities.extend(
TuyaSensorEntity(device, manager, description)
TuyaSensorEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if description.key in device.status
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
)
async_add_entities(entities)
@@ -1673,35 +1767,25 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
"""Tuya Sensor Entity."""
entity_description: TuyaSensorEntityDescription
_type: DPType | None = None
_type_data: IntegerTypeData | EnumTypeData | None = None
_dpcode_wrapper: DPCodeWrapper
def __init__(
self,
device: CustomerDevice,
device_manager: Manager,
description: TuyaSensorEntityDescription,
dpcode_wrapper: DPCodeWrapper,
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = (
f"{super().unique_id}{description.key}{description.subkey or ''}"
)
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
if int_type := find_dpcode(self.device, description.key, dptype=DPType.INTEGER):
self._type_data = int_type
self._type = DPType.INTEGER
if description.native_unit_of_measurement is None:
self._attr_native_unit_of_measurement = int_type.unit
elif enum_type := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
):
self._type_data = enum_type
self._type = DPType.ENUM
else:
self._type = get_dptype(self.device, DPCode(description.key))
if description.native_unit_of_measurement is None:
self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit
if description.suggested_unit_of_measurement is None:
self._attr_suggested_unit_of_measurement = dpcode_wrapper.suggested_unit
self._validate_device_class_unit()
@@ -1752,55 +1836,4 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
# Only continue if data type is known
if self._type not in (
DPType.INTEGER,
DPType.STRING,
DPType.ENUM,
DPType.JSON,
DPType.RAW,
):
return None
# Raw value
value = self.device.status.get(self.entity_description.key)
if value is None:
return None
# Convert value, if required
if (convert := self.entity_description.state_conversion) is not None:
return convert(value)
# Scale integer/float value
if isinstance(self._type_data, IntegerTypeData):
return self._type_data.scale_value(value)
# Unexpected enum value
if (
isinstance(self._type_data, EnumTypeData)
and value not in self._type_data.range
):
return None
# Get subkey value from Json string.
if self._type is DPType.JSON:
if (
self.entity_description.complex_type is None
or self.entity_description.subkey is None
):
return None
values = self.entity_description.complex_type.from_json(value)
return getattr(values, self.entity_description.subkey)
if self._type is DPType.RAW:
if (
self.entity_description.complex_type is None
or self.entity_description.subkey is None
or (raw_values := self.entity_description.complex_type.from_raw(value))
is None
):
return None
return getattr(raw_values, self.entity_description.subkey)
# Valid string or enum value
return value
return self._dpcode_wrapper.read_device_status(self.device)

View File

@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper
SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
DeviceCategory.CO2BJ: (
@@ -64,9 +65,13 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := SIRENS.get(device.category):
entities.extend(
TuyaSirenEntity(device, manager, description)
TuyaSirenEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if description.key in device.status
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
)
async_add_entities(entities)
@@ -89,21 +94,23 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
device: CustomerDevice,
device_manager: Manager,
description: SirenEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
) -> None:
"""Init Tuya Siren."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return true if siren is on."""
return self.device.status.get(self.entity_description.key, False)
return self._dpcode_wrapper.read_device_status(self.device)
def turn_on(self, **kwargs: Any) -> None:
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
self._send_command([{"code": self.entity_description.key, "value": True}])
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
def turn_off(self, **kwargs: Any) -> None:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off."""
self._send_command([{"code": self.entity_description.key, "value": False}])
await self._async_send_dpcode_update(self._dpcode_wrapper, False)

View File

@@ -42,6 +42,16 @@ def get_dpcode(
return None
def parse_dptype(dptype: str) -> DPType | None:
"""Parse DPType from device DPCode information."""
try:
return DPType(dptype)
except ValueError:
# Sometimes, we get ill-formed DPTypes from the cloud,
# this fixes them and maps them to the correct DPType.
return _DPTYPE_MAPPING.get(dptype)
def get_dptype(
device: CustomerDevice, dpcode: DPCode | None, *, prefer_function: bool = False
) -> DPType | None:
@@ -57,13 +67,7 @@ def get_dptype(
for device_specs in lookup_tuple:
if current_definition := device_specs.get(dpcode):
current_type = current_definition.type
try:
return DPType(current_type)
except ValueError:
# Sometimes, we get ill-formed DPTypes from the cloud,
# this fixes them and maps them to the correct DPType.
return _DPTYPE_MAPPING.get(current_type)
return parse_dptype(current_definition.type)
return None

View File

@@ -2,13 +2,23 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import aiohttp
from uasiren.client import Client
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_REGION
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, PLATFORMS
from .coordinator import UkraineAlarmDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ukraine Alarm as config entry."""
@@ -30,3 +40,56 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
# Version 1 had states as first-class selections
# Version 2 only allows states w/o districts, districts and communities
region_id = config_entry.data[CONF_REGION]
websession = async_get_clientsession(hass)
try:
regions_data = await Client(websession).get_regions()
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.warning(
"Could not migrate config entry %s: failed to fetch current regions: %s",
config_entry.entry_id,
err,
)
return False
if TYPE_CHECKING:
assert isinstance(regions_data, dict)
state_with_districts = None
for state in regions_data["states"]:
if state["regionId"] == region_id and state.get("regionChildIds"):
state_with_districts = state
break
if state_with_districts:
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_state_region_{config_entry.entry_id}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_state_region",
translation_placeholders={
"region_name": config_entry.data.get(CONF_NAME, region_id),
},
)
return False
hass.config_entries.async_update_entry(config_entry, version=2)
_LOGGER.info("Migration to version %s successful", 2)
return True
_LOGGER.error("Unknown version %s", config_entry.version)
return False

View File

@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Ukraine Alarm."""
VERSION = 1
VERSION = 2
def __init__(self) -> None:
"""Initialize a new UkraineAlarmConfigFlow."""
@@ -112,7 +112,7 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_finish_flow()
regions = {}
if self.selected_region:
if self.selected_region and step_id != "district":
regions[self.selected_region["regionId"]] = self.selected_region[
"regionName"
]

View File

@@ -13,19 +13,19 @@
"data": {
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
},
"description": "If you want to monitor not only state and district, choose its specific community"
"description": "Choose the district you selected above or select a specific community within that district"
},
"district": {
"data": {
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
},
"description": "If you want to monitor not only state, choose its specific district"
"description": "Choose a district to monitor within the selected state"
},
"user": {
"data": {
"region": "Region"
},
"description": "Choose state to monitor"
"description": "Choose a state"
}
}
},
@@ -50,5 +50,11 @@
"name": "Urban fights"
}
}
},
"issues": {
"deprecated_state_region": {
"description": "The region `{region_name}` is a state-level region, which is no longer supported. Please remove this integration entry and add it again, selecting a district or community instead of the entire state.",
"title": "State-level region monitoring is no longer supported"
}
}
}

View File

@@ -14,7 +14,7 @@
"velbus-protocol"
],
"quality_scale": "bronze",
"requirements": ["velbus-aio==2025.8.0"],
"requirements": ["velbus-aio==2025.11.0"],
"usb": [
{
"pid": "0B1B",

View File

@@ -1,17 +1,20 @@
"""Support for VELUX KLF 200 devices."""
from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from .const import DOMAIN, LOGGER, PLATFORMS
type VeluxConfigEntry = ConfigEntry[PyVLX]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Set up the velux component."""
host = entry.data[CONF_HOST]
password = entry.data[CONF_PASSWORD]
@@ -27,12 +30,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.runtime_data = pyvlx
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"gateway_{entry.entry_id}")},
name="KLF 200 Gateway",
manufacturer="Velux",
model="KLF 200",
hw_version=(
str(pyvlx.klf200.version.hardwareversion) if pyvlx.klf200.version else None
),
sw_version=(
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
),
)
async def on_hass_stop(event):
"""Close connection when hass stops."""
LOGGER.debug("Velux interface terminated")
await pyvlx.disconnect()
async def async_reboot_gateway(service_call: ServiceCall) -> None:
"""Reboot the gateway (deprecated - use button entity instead)."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_reboot_service",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_reboot_service",
breaks_in_ha_version="2026.6.0",
)
await pyvlx.reboot_gateway()
entry.async_on_unload(
@@ -46,6 +76,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -24,14 +24,14 @@ SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling
async def async_setup_entry(
hass: HomeAssistant,
config: VeluxConfigEntry,
config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up rain sensor(s) for Velux platform."""
pyvlx = config.runtime_data
pyvlx = config_entry.runtime_data
async_add_entities(
VeluxRainSensor(node, config.entry_id)
VeluxRainSensor(node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, Window) and node.rain_sensor
)

View File

@@ -0,0 +1,52 @@
"""Support for VELUX KLF 200 gateway button."""
from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button entities for the Velux integration."""
async_add_entities(
[VeluxGatewayRebootButton(config_entry.entry_id, config_entry.runtime_data)]
)
class VeluxGatewayRebootButton(ButtonEntity):
"""Representation of the Velux Gateway reboot button."""
_attr_has_entity_name = True
_attr_device_class = ButtonDeviceClass.RESTART
_attr_entity_category = EntityCategory.CONFIG
def __init__(self, config_entry_id: str, pyvlx: PyVLX) -> None:
"""Initialize the gateway reboot button."""
self.pyvlx = pyvlx
self._attr_unique_id = f"{config_entry_id}_reboot-gateway"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"gateway_{config_entry_id}")},
)
async def async_press(self) -> None:
"""Handle the button press - reboot the gateway."""
try:
await self.pyvlx.reboot_gateway()
except PyVLXException as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="reboot_failed",
) from ex

View File

@@ -85,7 +85,7 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
updates={CONF_HOST: self.discovery_data[CONF_HOST]}
)
# Abort if config_entry already exists without unigue_id configured.
# Abort if config_entry already exists without unique_id configured.
for entry in self.hass.config_entries.async_entries(DOMAIN):
if (
entry.data[CONF_HOST] == self.discovery_data[CONF_HOST]

View File

@@ -5,5 +5,11 @@ from logging import getLogger
from homeassistant.const import Platform
DOMAIN = "velux"
PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
]
LOGGER = getLogger(__package__)

View File

@@ -32,13 +32,13 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config: VeluxConfigEntry,
config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover(s) for Velux platform."""
pyvlx = config.runtime_data
pyvlx = config_entry.runtime_data
async_add_entities(
VeluxCover(node, config.entry_id)
VeluxCover(node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, OpeningDevice)
)

View File

@@ -18,22 +18,23 @@ class VeluxEntity(Entity):
def __init__(self, node: Node, config_entry_id: str) -> None:
"""Initialize the Velux device."""
self.node = node
self._attr_unique_id = (
unique_id = (
node.serial_number
if node.serial_number
else f"{config_entry_id}_{node.node_id}"
)
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
node.serial_number
if node.serial_number
else f"{config_entry_id}_{node.node_id}",
unique_id,
)
},
name=node.name if node.name else f"#{node.node_id}",
serial_number=node.serial_number,
via_device=(DOMAIN, f"gateway_{config_entry_id}"),
)
@callback

View File

@@ -18,13 +18,13 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config: VeluxConfigEntry,
config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light(s) for Velux platform."""
pyvlx = config.runtime_data
pyvlx = config_entry.runtime_data
async_add_entities(
VeluxLight(node, config.entry_id)
VeluxLight(node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, LighteningDevice)
)

View File

@@ -0,0 +1,76 @@
rules:
# Bronze
action-setup:
status: todo
comment: needs to move to async_setup
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency:
status: todo
comment: release-builds need CI
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: todo
comment: subscribe is ok, unsubscribe needs to be added
entity-unique-id: done
has-entity-name:
status: todo
comment: scenes need fixing
runtime-data: done
test-before-configure: done
test-before-setup:
status: todo
comment: needs rework, failure to setup currently only returns false
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: todo
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: todo
comment: button still needs it
reauthentication-flow: todo
test-coverage:
status: todo
comment: cleanup mock_config_entry vs mock_user_config_entry, cleanup mock_pyvlx vs mock_velux_client, remove unused freezer in test_cover_closed, add tests where missing
# Gold
devices:
status: todo
comment: scenes need devices
diagnostics: todo
discovery-update-info: todo
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -15,11 +15,11 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config: VeluxConfigEntry,
config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the scenes for Velux platform."""
pyvlx = config.runtime_data
pyvlx = config_entry.runtime_data
entities = [VeluxScene(scene) for scene in pyvlx.scenes]
async_add_entities(entities)

View File

@@ -36,9 +36,20 @@
}
}
},
"exceptions": {
"reboot_failed": {
"message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually"
}
},
"issues": {
"deprecated_reboot_service": {
"description": "The `velux.reboot_gateway` service is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.",
"title": "Velux reboot service is deprecated"
}
},
"services": {
"reboot_gateway": {
"description": "Reboots the KLF200 Gateway.",
"description": "Reboots the KLF200 Gateway",
"name": "Reboot gateway"
}
}

View File

@@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER
@@ -121,3 +122,21 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.config_entries.async_update_entry(config_entry, minor_version=2)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
manager = hass.data[DOMAIN][VS_MANAGER]
await manager.get_devices()
for dev in manager.devices:
if isinstance(dev.sub_device_no, int):
device_id = f"{dev.cid}{dev.sub_device_no!s}"
else:
device_id = dev.cid
identifier = next(iter(device_entry.identifiers), None)
if identifier and device_id == identifier[1]:
return False
return True

View File

@@ -58,6 +58,7 @@ from .utils import (
get_compressors,
get_device_serial,
is_supported,
normalize_state,
)
_LOGGER = logging.getLogger(__name__)
@@ -1086,7 +1087,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
key="compressor_phase",
translation_key="compressor_phase",
value_getter=lambda api: api.getPhase(),
value_getter=lambda api: normalize_state(api.getPhase()),
entity_category=EntityCategory.DIAGNOSTIC,
),
)

View File

@@ -213,7 +213,18 @@
"name": "Compressor hours load class 5"
},
"compressor_phase": {
"name": "Compressor phase"
"name": "Compressor phase",
"state": {
"cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]",
"defrost": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::defrosting%]",
"heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]",
"off": "[%key:common::state::off%]",
"passive_defrost": "Passive defrosting",
"pause": "[%key:common::state::idle%]",
"preparing": "Preparing",
"preparing_defrost": "Preparing defrost",
"ready": "[%key:common::state::idle%]"
}
},
"compressor_starts": {
"name": "Compressor starts"

View File

@@ -133,3 +133,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
def filter_state(state: str) -> str | None:
"""Return the state if not 'nothing' or 'unknown'."""
return None if state in ("nothing", "unknown") else state
def normalize_state(state: str) -> str:
"""Return the state with underscores instead of hyphens."""
return state.replace("-", "_")

View File

@@ -14,11 +14,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .const import API_URL, LOGGER
from .const import API_URL, DOMAIN, LOGGER
from .coordinator import (
HeatPumpInfo,
WeheatConfigEntry,
@@ -32,7 +33,13 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool:
"""Set up Weheat from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)

View File

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

View File

@@ -152,7 +152,7 @@ def check_deprecated_entity(
return False
def profile_pic(person: Person, _: Title | None) -> str | None:
def profile_pic(person: Person, _: Title | None = None) -> str | None:
"""Return the gamer pic."""
# Xbox sometimes returns a domain that uses a wrong certificate which

View File

@@ -29,6 +29,19 @@
"gamer_score": {
"default": "mdi:alpha-g-circle"
},
"in_party": {
"default": "mdi:headset",
"state": {
"0": "mdi:headset-off"
}
},
"join_restrictions": {
"default": "mdi:account-voice-off",
"state": {
"invite_only": "mdi:email-newsletter",
"joinable": "mdi:account-multiple-plus-outline"
}
},
"last_online": {
"default": "mdi:account-clock"
},

View File

@@ -2,67 +2,85 @@
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from pydantic import ValidationError
from pythonxbox.api.client import XboxLiveClient
from pythonxbox.api.provider.catalog.models import FieldsTemplate, Image
from pythonxbox.api.provider.gameclips.models import GameclipsResponse
from pythonxbox.api.provider.screenshots.models import ScreenshotResponse
from pythonxbox.api.provider.smartglass.models import InstalledPackage
from httpx import HTTPStatusError, RequestError, TimeoutException
from pythonxbox.api.provider.titlehub.models import Image, Title, TitleFields
from homeassistant.components.media_player import MediaClass
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_source import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
Unresolvable,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .browse_media import _find_media_image
from .binary_sensor import profile_pic
from .const import DOMAIN
from .coordinator import XboxConfigEntry
_LOGGER = logging.getLogger(__name__)
ATTR_GAMECLIPS = "gameclips"
ATTR_SCREENSHOTS = "screenshots"
ATTR_GAME_MEDIA = "game_media"
ATTR_COMMUNITY_GAMECLIPS = "community_gameclips"
ATTR_COMMUNITY_SCREENSHOTS = "community_screenshots"
MAP_TITLE = {
ATTR_GAMECLIPS: "Gameclips",
ATTR_SCREENSHOTS: "Screenshots",
ATTR_GAME_MEDIA: "Game media",
ATTR_COMMUNITY_GAMECLIPS: "Community gameclips",
ATTR_COMMUNITY_SCREENSHOTS: "Community screenshots",
}
MIME_TYPE_MAP = {
"gameclips": "video/mp4",
"screenshots": "image/png",
ATTR_GAMECLIPS: "video/mp4",
ATTR_COMMUNITY_GAMECLIPS: "video/mp4",
ATTR_SCREENSHOTS: "image/png",
ATTR_COMMUNITY_SCREENSHOTS: "image/png",
}
MEDIA_CLASS_MAP = {
"gameclips": MediaClass.VIDEO,
"screenshots": MediaClass.IMAGE,
ATTR_GAMECLIPS: MediaClass.VIDEO,
ATTR_COMMUNITY_GAMECLIPS: MediaClass.VIDEO,
ATTR_SCREENSHOTS: MediaClass.IMAGE,
ATTR_COMMUNITY_SCREENSHOTS: MediaClass.IMAGE,
ATTR_GAME_MEDIA: MediaClass.IMAGE,
}
SEPARATOR = "/"
async def async_get_media_source(hass: HomeAssistant):
async def async_get_media_source(hass: HomeAssistant) -> XboxSource:
"""Set up Xbox media source."""
entry: XboxConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
client = entry.runtime_data.client
return XboxSource(hass, client)
return XboxSource(hass)
@callback
def async_parse_identifier(
item: MediaSourceItem,
) -> tuple[str, str, str]:
"""Parse identifier."""
identifier = item.identifier or ""
start = ["", "", ""]
items = identifier.lstrip("/").split("~~", 2)
return tuple(items + start[len(items) :]) # type: ignore[return-value]
class XboxMediaSourceIdentifier:
"""Media item identifier."""
xuid = title_id = media_type = media_id = ""
@dataclass
class XboxMediaItem:
"""Represents gameclip/screenshot media."""
def __init__(self, item: MediaSourceItem) -> None:
"""Initialize identifier."""
if item.identifier is not None:
self.xuid, _, self.title_id = (item.identifier).partition(SEPARATOR)
self.title_id, _, self.media_type = (self.title_id).partition(SEPARATOR)
self.media_type, _, self.media_id = (self.media_type).partition(SEPARATOR)
caption: str
thumbnail: str
uri: str
media_class: str
def __str__(self) -> str:
"""Build identifier."""
return SEPARATOR.join(
[i for i in (self.xuid, self.title_id, self.media_type, self.media_id) if i]
)
class XboxSource(MediaSource):
@@ -70,202 +88,573 @@ class XboxSource(MediaSource):
name: str = "Xbox Game Media"
def __init__(self, hass: HomeAssistant, client: XboxLiveClient) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize Xbox source."""
super().__init__(DOMAIN)
self.hass: HomeAssistant = hass
self.client: XboxLiveClient = client
self.hass = hass
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
_, category, url = async_parse_identifier(item)
kind = category.split("#", 1)[1]
return PlayMedia(url, MIME_TYPE_MAP[kind])
identifier = XboxMediaSourceIdentifier(item)
if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)):
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="xbox_not_configured",
)
try:
entry: XboxConfigEntry = next(
e for e in entries if e.unique_id == identifier.xuid
)
except StopIteration as e:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_configured",
) from e
client = entry.runtime_data.client
if identifier.media_type in (ATTR_GAMECLIPS, ATTR_COMMUNITY_GAMECLIPS):
try:
if identifier.media_type == ATTR_GAMECLIPS:
gameclips_response = (
await client.gameclips.get_recent_clips_by_xuid(
identifier.xuid, identifier.title_id, max_items=999
)
)
else:
gameclips_response = (
await client.gameclips.get_recent_community_clips_by_title_id(
identifier.title_id
)
)
except TimeoutException as e:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
gameclips = gameclips_response.game_clips
try:
clip = next(
g for g in gameclips if g.game_clip_id == identifier.media_id
)
except StopIteration as e:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="media_not_found",
) from e
return PlayMedia(clip.game_clip_uris[0].uri, MIME_TYPE_MAP[ATTR_GAMECLIPS])
if identifier.media_type in (ATTR_SCREENSHOTS, ATTR_COMMUNITY_SCREENSHOTS):
try:
if identifier.media_type == ATTR_SCREENSHOTS:
screenshot_response = (
await client.screenshots.get_recent_screenshots_by_xuid(
identifier.xuid, identifier.title_id, max_items=999
)
)
else:
screenshot_response = await client.screenshots.get_recent_community_screenshots_by_title_id(
identifier.title_id
)
except TimeoutException as e:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
screenshots = screenshot_response.screenshots
try:
img = next(
s for s in screenshots if s.screenshot_id == identifier.media_id
)
except StopIteration as e:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="media_not_found",
) from e
return PlayMedia(
img.screenshot_uris[0].uri, MIME_TYPE_MAP[identifier.media_type]
)
if identifier.media_type == ATTR_GAME_MEDIA:
try:
images = (
(await client.titlehub.get_title_info(identifier.title_id))
.titles[0]
.images
)
except TimeoutException as e:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
if images is not None:
try:
return PlayMedia(
images[int(identifier.media_id)].url,
MIME_TYPE_MAP[ATTR_SCREENSHOTS],
)
except (ValueError, IndexError):
pass
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="media_not_found",
)
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Return media."""
title, category, _ = async_parse_identifier(item)
if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)):
raise BrowseError(
translation_domain=DOMAIN,
translation_key="xbox_not_configured",
)
if not title:
return await self._build_game_library()
# if there is only one entry we can directly jump to it
if not item.identifier and len(entries) > 1:
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title="Xbox Game Media",
can_play=False,
can_expand=True,
children=[*await self._build_accounts(entries)],
children_media_class=MediaClass.DIRECTORY,
)
if not category:
return _build_categories(title)
identifier = XboxMediaSourceIdentifier(item)
if not identifier.xuid and len(entries) == 1:
if TYPE_CHECKING:
assert entries[0].unique_id
identifier.xuid = entries[0].unique_id
return await self._build_media_items(title, category)
try:
entry: XboxConfigEntry = next(
e for e in entries if e.unique_id == identifier.xuid
)
except StopIteration as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="account_not_configured",
) from e
async def _build_game_library(self):
"""Display installed games across all consoles."""
apps = await self.client.smartglass.get_installed_apps()
games = {
game.one_store_product_id: game
for game in apps.result
if game.is_game and game.title_id
}
if not identifier.title_id:
return await self._build_game_library(entry)
app_details = await self.client.catalog.get_products(
games.keys(),
FieldsTemplate.BROWSE,
)
if not identifier.media_type:
return await self._build_game_title(entry, identifier)
images = {
prod.product_id: prod.localized_properties[0].images
for prod in app_details.products
}
return await self._build_game_media(entry, identifier)
async def _build_accounts(
self, entries: list[XboxConfigEntry]
) -> list[BrowseMediaSource]:
"""List Xbox accounts."""
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=entry.unique_id,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.DIRECTORY,
title=entry.title,
can_play=False,
can_expand=True,
thumbnail=gamerpic(entry),
)
for entry in entries
]
async def _build_game_library(self, entry: XboxConfigEntry) -> BrowseMediaSource:
"""Display played games."""
return BrowseMediaSource(
domain=DOMAIN,
identifier="",
identifier=entry.unique_id,
media_class=MediaClass.DIRECTORY,
media_content_type="",
title="Xbox Game Media",
media_content_type=MediaClass.DIRECTORY,
title=f"Xbox / {entry.title}",
can_play=False,
can_expand=True,
children=[_build_game_item(game, images) for game in games.values()],
children=[*await self._build_games(entry)],
children_media_class=MediaClass.GAME,
)
async def _build_media_items(self, title, category):
"""Fetch requested gameclip/screenshot media."""
title_id, _, thumbnail = title.split("#", 2)
owner, kind = category.split("#", 1)
async def _build_games(self, entry: XboxConfigEntry) -> list[BrowseMediaSource]:
"""List Xbox games for the selected account."""
items: list[XboxMediaItem] = []
with suppress(ValidationError): # Unexpected API response
if kind == "gameclips":
if owner == "my":
response: GameclipsResponse = (
await self.client.gameclips.get_recent_clips_by_xuid(
self.client.xuid, title_id
)
)
elif owner == "community":
response: GameclipsResponse = await self.client.gameclips.get_recent_community_clips_by_title_id(
title_id
)
else:
return None
items = [
XboxMediaItem(
item.user_caption
or dt_util.as_local(item.date_recorded).strftime(
"%b. %d, %Y %I:%M %p"
),
item.thumbnails[0].uri,
item.game_clip_uris[0].uri,
MediaClass.VIDEO,
)
for item in response.game_clips
]
elif kind == "screenshots":
if owner == "my":
response: ScreenshotResponse = (
await self.client.screenshots.get_recent_screenshots_by_xuid(
self.client.xuid, title_id
)
)
elif owner == "community":
response: ScreenshotResponse = await self.client.screenshots.get_recent_community_screenshots_by_title_id(
title_id
)
else:
return None
items = [
XboxMediaItem(
item.user_caption
or dt_util.as_local(item.date_taken).strftime(
"%b. %d, %Y %I:%M%p"
),
item.thumbnails[0].uri,
item.screenshot_uris[0].uri,
MediaClass.IMAGE,
)
for item in response.screenshots
]
client = entry.runtime_data.client
if TYPE_CHECKING:
assert entry.unique_id
fields = [
TitleFields.ACHIEVEMENT,
TitleFields.STATS,
TitleFields.IMAGE,
]
try:
games = await client.titlehub.get_title_history(
entry.unique_id, fields, max_items=999
)
except TimeoutException as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{entry.unique_id}/{game.title_id}",
media_class=MediaClass.GAME,
media_content_type=MediaClass.GAME,
title=game.name,
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
thumbnail=game_thumbnail(game.images or []),
)
for game in games.titles
if game.achievement and game.achievement.source_version != 0
]
async def _build_game_title(
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> BrowseMediaSource:
"""Display game title."""
client = entry.runtime_data.client
try:
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
except TimeoutException as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
return BrowseMediaSource(
domain=DOMAIN,
identifier=f"{title}~~{category}",
media_class=MediaClass.DIRECTORY,
media_content_type="",
title=f"{owner.title()} {kind.title()}",
identifier=str(identifier),
media_class=MediaClass.GAME,
media_content_type=MediaClass.GAME,
title=f"Xbox / {entry.title} / {game.name}",
can_play=False,
can_expand=True,
children=[_build_media_item(title, category, item) for item in items],
children_media_class=MEDIA_CLASS_MAP[kind],
thumbnail=thumbnail,
children=[*self._build_categories(identifier)],
children_media_class=MediaClass.DIRECTORY,
)
def _build_categories(
self, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media categories."""
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier}/{media_type}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.DIRECTORY,
title=MAP_TITLE[media_type],
can_play=False,
can_expand=True,
children_media_class=MEDIA_CLASS_MAP[media_type],
)
for media_type in (
ATTR_GAMECLIPS,
ATTR_SCREENSHOTS,
ATTR_COMMUNITY_GAMECLIPS,
ATTR_COMMUNITY_SCREENSHOTS,
ATTR_GAME_MEDIA,
)
]
async def _build_game_media(
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> BrowseMediaSource:
"""List game media."""
client = entry.runtime_data.client
try:
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
except TimeoutException as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
return BrowseMediaSource(
domain=DOMAIN,
identifier=str(identifier),
media_class=MEDIA_CLASS_MAP[identifier.media_type],
media_content_type=MediaClass.DIRECTORY,
title=f"Xbox / {entry.title} / {game.name} / {MAP_TITLE[identifier.media_type]}",
can_play=False,
can_expand=True,
children=[
*await self._build_media_items_gameclips(entry, identifier)
+ await self._build_media_items_community_gameclips(entry, identifier)
+ await self._build_media_items_screenshots(entry, identifier)
+ await self._build_media_items_community_screenshots(entry, identifier)
+ self._build_media_items_promotional(identifier, game)
],
children_media_class=MEDIA_CLASS_MAP[identifier.media_type],
)
async def _build_media_items_gameclips(
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
if identifier.media_type != ATTR_GAMECLIPS:
return []
try:
gameclips = (
await client.gameclips.get_recent_clips_by_xuid(
identifier.xuid, identifier.title_id, max_items=999
)
).game_clips
except TimeoutException as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier}/{gameclip.game_clip_id}",
media_class=MediaClass.VIDEO,
media_content_type=MediaClass.VIDEO,
title=(
f"{gameclip.user_caption}"
f"{' | ' if gameclip.user_caption else ''}"
f"{dt_util.get_age(gameclip.date_recorded)}"
),
can_play=True,
can_expand=False,
thumbnail=gameclip.thumbnails[0].uri,
)
for gameclip in gameclips
]
async def _build_media_items_community_gameclips(
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
if identifier.media_type != ATTR_COMMUNITY_GAMECLIPS:
return []
try:
gameclips = (
await client.gameclips.get_recent_community_clips_by_title_id(
identifier.title_id
)
).game_clips
except TimeoutException as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier}/{gameclip.game_clip_id}",
media_class=MediaClass.VIDEO,
media_content_type=MediaClass.VIDEO,
title=(
f"{gameclip.user_caption}"
f"{' | ' if gameclip.user_caption else ''}"
f"{dt_util.get_age(gameclip.date_recorded)}"
),
can_play=True,
can_expand=False,
thumbnail=gameclip.thumbnails[0].uri,
)
for gameclip in gameclips
]
async def _build_media_items_screenshots(
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
if identifier.media_type != ATTR_SCREENSHOTS:
return []
try:
screenshots = (
await client.screenshots.get_recent_screenshots_by_xuid(
identifier.xuid, identifier.title_id, max_items=999
)
).screenshots
except TimeoutException as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier}/{screenshot.screenshot_id}",
media_class=MediaClass.VIDEO,
media_content_type=MediaClass.VIDEO,
title=(
f"{screenshot.user_caption}"
f"{' | ' if screenshot.user_caption else ''}"
f"{dt_util.get_age(screenshot.date_taken)} | {screenshot.resolution_height}p"
),
can_play=True,
can_expand=False,
thumbnail=screenshot.thumbnails[0].uri,
)
for screenshot in screenshots
]
async def _build_media_items_community_screenshots(
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
if identifier.media_type != ATTR_COMMUNITY_SCREENSHOTS:
return []
try:
screenshots = (
await client.screenshots.get_recent_community_screenshots_by_title_id(
identifier.title_id
)
).screenshots
except TimeoutException as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier}/{screenshot.screenshot_id}",
media_class=MediaClass.VIDEO,
media_content_type=MediaClass.VIDEO,
title=(
f"{screenshot.user_caption}"
f"{' | ' if screenshot.user_caption else ''}"
f"{dt_util.get_age(screenshot.date_taken)} | {screenshot.resolution_height}p"
),
can_play=True,
can_expand=False,
thumbnail=screenshot.thumbnails[0].uri,
)
for screenshot in screenshots
]
def _build_media_items_promotional(
self, identifier: XboxMediaSourceIdentifier, game: Title
) -> list[BrowseMediaSource]:
"""List promotional game media."""
if identifier.media_type != ATTR_GAME_MEDIA:
return []
return (
[
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier}/{game.images.index(image)}",
media_class=MediaClass.VIDEO,
media_content_type=MediaClass.VIDEO,
title=image.type,
can_play=True,
can_expand=False,
thumbnail=image.url,
)
for image in game.images
]
if game.images
else []
)
def _build_game_item(item: InstalledPackage, images: dict[str, list[Image]]):
"""Build individual game."""
thumbnail = ""
image = _find_media_image(images.get(item.one_store_product_id, [])) # type: ignore[arg-type]
if image is not None:
thumbnail = image.uri
if thumbnail[0] == "/":
thumbnail = f"https:{thumbnail}"
return BrowseMediaSource(
domain=DOMAIN,
identifier=f"{item.title_id}#{item.name}#{thumbnail}",
media_class=MediaClass.GAME,
media_content_type="",
title=item.name,
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
thumbnail=thumbnail,
)
def gamerpic(config_entry: XboxConfigEntry) -> str | None:
"""Return gamerpic."""
coordinator = config_entry.runtime_data
if TYPE_CHECKING:
assert config_entry.unique_id
person = coordinator.data.presence[coordinator.client.xuid]
return profile_pic(person)
def _build_categories(title):
"""Build base categories for Xbox media."""
_, name, thumbnail = title.split("#", 2)
base = BrowseMediaSource(
domain=DOMAIN,
identifier=f"{title}",
media_class=MediaClass.GAME,
media_content_type="",
title=name,
can_play=False,
can_expand=True,
children=[],
children_media_class=MediaClass.DIRECTORY,
thumbnail=thumbnail,
)
def game_thumbnail(images: list[Image]) -> str | None:
"""Return the title image."""
owners = ["my", "community"]
kinds = ["gameclips", "screenshots"]
for owner in owners:
for kind in kinds:
base.children.append(
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{title}~~{owner}#{kind}",
media_class=MediaClass.DIRECTORY,
media_content_type="",
title=f"{owner.title()} {kind.title()}",
can_play=False,
can_expand=True,
children_media_class=MEDIA_CLASS_MAP[kind],
)
)
for img_type in ("BrandedKeyArt", "Poster", "BoxArt"):
if match := next(
(i for i in images if i.type == img_type),
None,
):
return match.url
return base
def _build_media_item(title: str, category: str, item: XboxMediaItem):
"""Build individual media item."""
kind = category.split("#", 1)[1]
return BrowseMediaSource(
domain=DOMAIN,
identifier=f"{title}~~{category}~~{item.uri}",
media_class=item.media_class,
media_content_type=MIME_TYPE_MAP[kind],
title=item.caption,
can_play=True,
can_expand=False,
thumbnail=item.thumbnail,
)
return None

View File

@@ -24,6 +24,11 @@ from homeassistant.helpers.typing import StateType
from .coordinator import XboxConfigEntry
from .entity import XboxBaseEntity, XboxBaseEntityDescription, check_deprecated_entity
MAP_JOIN_RESTRICTIONS = {
"local": "invite_only",
"followed": "joinable",
}
class XboxSensor(StrEnum):
"""Xbox sensor."""
@@ -37,6 +42,8 @@ class XboxSensor(StrEnum):
FOLLOWER = "follower"
NOW_PLAYING = "now_playing"
FRIENDS = "friends"
IN_PARTY = "in_party"
JOIN_RESTRICTIONS = "join_restrictions"
@dataclass(kw_only=True, frozen=True)
@@ -95,6 +102,18 @@ def now_playing_attributes(_: Person, title: Title | None) -> dict[str, Any]:
return attributes
def join_restrictions(person: Person, _: Title | None = None) -> str | None:
"""Join restrictions for current party the user is in."""
return (
MAP_JOIN_RESTRICTIONS.get(
person.multiplayer_summary.party_details[0].join_restriction
)
if person.multiplayer_summary and person.multiplayer_summary.party_details
else None
)
def title_logo(_: Person, title: Title | None) -> str | None:
"""Get the game logo."""
@@ -159,6 +178,22 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
translation_key=XboxSensor.FRIENDS,
value_fn=lambda x, _: x.detail.friend_count if x.detail else None,
),
XboxSensorEntityDescription(
key=XboxSensor.IN_PARTY,
translation_key=XboxSensor.IN_PARTY,
value_fn=(
lambda x, _: x.multiplayer_summary.in_party
if x.multiplayer_summary
else None
),
),
XboxSensorEntityDescription(
key=XboxSensor.JOIN_RESTRICTIONS,
translation_key=XboxSensor.JOIN_RESTRICTIONS,
value_fn=join_restrictions,
device_class=SensorDeviceClass.ENUM,
options=list(MAP_JOIN_RESTRICTIONS.values()),
),
)

Some files were not shown because too many files have changed in this diff Show More