Compare commits

..

2 Commits

Author SHA1 Message Date
Matthias Alphart
db387599a2 Update homeassistant/components/knx/scene.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-20 10:59:38 +01:00
farmio
57facb26bb Support KNX scene entity configuration from UI 2025-12-20 10:47:47 +01:00
15 changed files with 233 additions and 326 deletions

View File

@@ -132,7 +132,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"device_tracker",
"fan",
"humidifier",
"input_boolean",
"lawn_mower",
"light",
"lock",

View File

@@ -20,13 +20,5 @@
"turn_on": {
"service": "mdi:toggle-switch"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:toggle-switch-off"
},
"turned_on": {
"trigger": "mdi:toggle-switch"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted input booleans to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::input_boolean::title%]",
@@ -21,15 +17,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"reload": {
"description": "Reloads helpers from the YAML-configuration.",
@@ -48,27 +35,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Input boolean",
"triggers": {
"turned_off": {
"description": "Triggers after one or more input booleans turn off.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Input boolean turned off"
},
"turned_on": {
"description": "Triggers after one or more input booleans turn on.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Input boolean turned on"
}
}
"title": "Input boolean"
}

View File

@@ -1,17 +0,0 @@
"""Provides triggers for input booleans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for input booleans."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: input_boolean
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -168,6 +168,7 @@ SUPPORTED_PLATFORMS_UI: Final = {
Platform.FAN,
Platform.DATETIME,
Platform.LIGHT,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
@@ -227,3 +228,9 @@ class FanConf:
"""Common config keys for fan."""
MAX_STEP: Final = "max_step"
class SceneConf:
"""Common config keys for scene."""
SCENE_NUMBER: Final = "scene_number"

View File

@@ -10,13 +10,23 @@ from homeassistant import config_entries
from homeassistant.components.scene import BaseScene
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType
from .const import KNX_ADDRESS, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, SceneConf
from .entity import (
KnxUiEntity,
KnxUiEntityPlatformController,
KnxYamlEntity,
_KnxEntityBase,
)
from .knx_module import KNXModule
from .schema import SceneSchema
from .storage.const import CONF_ENTITY, CONF_GA_SCENE
from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -26,18 +36,53 @@ async def async_setup_entry(
) -> None:
"""Set up scene(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
config: list[ConfigType] = knx_module.config_yaml[Platform.SCENE]
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.SCENE,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiScene,
),
)
async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config)
entities: list[KnxYamlEntity | KnxUiEntity] = []
if yaml_platform_config := knx_module.config_yaml.get(Platform.SCENE):
entities.extend(
KnxYamlScene(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SCENE):
entities.extend(
KnxUiScene(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
class KNXScene(KnxYamlEntity, BaseScene):
class _KnxScene(BaseScene, _KnxEntityBase):
"""Representation of a KNX scene."""
_device: XknxScene
async def _async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self._device.run()
def after_update_callback(self, device: XknxDevice) -> None:
"""Call after device was updated."""
self._async_record_activation()
super().after_update_callback(device)
class KnxYamlScene(_KnxScene, KnxYamlEntity):
"""Representation of a KNX scene configured from YAML."""
_device: XknxScene
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Init KNX scene."""
"""Initialize KNX scene."""
super().__init__(
knx_module=knx_module,
device=XknxScene(
@@ -52,11 +97,28 @@ class KNXScene(KnxYamlEntity, BaseScene):
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
)
async def _async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self._device.run()
def after_update_callback(self, device: XknxDevice) -> None:
"""Call after device was updated."""
self._async_record_activation()
super().after_update_callback(device)
class KnxUiScene(_KnxScene, KnxUiEntity):
"""Representation of a KNX scene configured from the UI."""
_device: XknxScene
def __init__(
self,
knx_module: KNXModule,
unique_id: str,
config: ConfigType,
) -> None:
"""Initialize KNX scene."""
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = XknxScene(
xknx=knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address=knx_conf.get_write(CONF_GA_SCENE),
scene_number=knx_conf.get(SceneConf.SCENE_NUMBER),
)

View File

@@ -61,6 +61,7 @@ from .const import (
CoverConf,
FanConf,
FanZeroMode,
SceneConf,
)
from .validation import (
backwards_compatible_xknx_climate_enum_member,
@@ -822,7 +823,7 @@ class SceneSchema(KNXPlatformSchema):
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Required(CONF_SCENE_NUMBER): vol.All(
vol.Required(SceneConf.SCENE_NUMBER): vol.All(
vol.Coerce(int), vol.Range(min=1, max=64)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,

View File

@@ -72,5 +72,8 @@ CONF_GA_WHITE_SWITCH: Final = "ga_white_switch"
CONF_GA_HUE: Final = "ga_hue"
CONF_GA_SATURATION: Final = "ga_saturation"
# Scene
CONF_GA_SCENE: Final = "ga_scene"
# Sensor
CONF_ALWAYS_CALLBACK: Final = "always_callback"

View File

@@ -40,6 +40,7 @@ from ..const import (
CoverConf,
FanConf,
FanZeroMode,
SceneConf,
)
from ..dpt import get_supported_dpts
from .const import (
@@ -82,6 +83,7 @@ from .const import (
CONF_GA_RED_BRIGHTNESS,
CONF_GA_RED_SWITCH,
CONF_GA_SATURATION,
CONF_GA_SCENE,
CONF_GA_SENSOR,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_SPEED,
@@ -419,6 +421,25 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
),
)
SCENE_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_GA_SCENE): GASelector(
state=False,
passive=False,
write_required=True,
valid_dpt=["17.001", "18.001"],
),
vol.Required(SceneConf.SCENE_NUMBER): AllSerializeFirst(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1, max=64, step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Coerce(int),
),
},
)
SWITCH_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"),
@@ -694,6 +715,7 @@ KNX_SCHEMA_FOR_PLATFORM = {
Platform.DATETIME: DATETIME_KNX_SCHEMA,
Platform.FAN: FAN_KNX_SCHEMA,
Platform.LIGHT: LIGHT_KNX_SCHEMA,
Platform.SCENE: SCENE_KNX_SCHEMA,
Platform.SENSOR: SENSOR_KNX_SCHEMA,
Platform.SWITCH: SWITCH_KNX_SCHEMA,
Platform.TIME: TIME_KNX_SCHEMA,

View File

@@ -187,7 +187,7 @@
"8_005": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_006": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_007": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_010": "Percent (-327.68 … 327.67)",
"8_010": "Percent (-327,68 … 327,67)",
"8_011": "Rotation angle",
"8_012": "Length (Altitude)",
"9": "Generic 2-byte floating point",
@@ -774,6 +774,19 @@
}
}
},
"scene": {
"description": "A KNX entity can activate a KNX scene and updates when the scene number is received.",
"knx": {
"ga_scene": {
"description": "Group address to activate a scene.",
"label": "Scene"
},
"scene_number": {
"description": "The scene number this entity is associated with.",
"label": "Scene number"
}
}
},
"sensor": {
"description": "Read-only entity for numeric or string datapoints. Temperature, percent etc.",
"knx": {
@@ -1061,7 +1074,7 @@
"name": "[%key:component::knx::services::send::fields::address::name%]"
},
"attribute": {
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set, the state will be sent. Eg. for a light the state is either “on” or “off” - with attribute you can expose its “brightness”.",
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”.",
"name": "Entity attribute"
},
"default": {

View File

@@ -1,228 +0,0 @@
"""Test input boolean triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.input_boolean import DOMAIN
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.fixture
async def target_input_booleans(hass: HomeAssistant) -> list[str]:
"""Create multiple input_boolean entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"input_boolean.turned_off",
"input_boolean.turned_on",
],
)
async def test_input_boolean_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the input_boolean triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="input_boolean.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="input_boolean.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_input_boolean_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_input_booleans: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the input_boolean state trigger fires when any input_boolean state changes to a specific state."""
other_entity_ids = set(target_input_booleans) - {entity_id}
# Set all input_booleans, including the tested one, to the initial state
for eid in target_input_booleans:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other input_booleans also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="input_boolean.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="input_boolean.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_input_boolean_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_input_booleans: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the input_boolean state trigger fires when the first input_boolean changes to a specific state."""
other_entity_ids = set(target_input_booleans) - {entity_id}
# Set all input_booleans, including the tested one, to the initial state
for eid in target_input_booleans:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other input_booleans should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="input_boolean.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="input_boolean.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_input_boolean_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_input_booleans: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the input_boolean state trigger fires when the last input_boolean changes to a specific state."""
other_entity_ids = set(target_input_booleans) - {entity_id}
# Set all input_booleans, including the tested one, to the initial state
for eid in target_input_booleans:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,24 @@
{
"version": 2,
"minor_version": 2,
"key": "knx/config_store.json",
"data": {
"entities": {
"scene": {
"knx_es_01KCXJ181N1TEDNC81WXEMXRNS": {
"entity": {
"name": "test",
"device_info": null,
"entity_category": null
},
"knx": {
"ga_scene": {
"write": "1/1/1"
},
"scene_number": 12
}
}
}
}
}
}

View File

@@ -1576,6 +1576,50 @@
'type': 'result',
})
# ---
# name: test_knx_get_schema[scene]
dict({
'id': 1,
'result': list([
dict({
'name': 'ga_scene',
'options': dict({
'passive': False,
'state': False,
'validDPTs': list([
dict({
'main': 17,
'sub': 1,
}),
dict({
'main': 18,
'sub': 1,
}),
]),
'write': dict({
'required': True,
}),
}),
'required': True,
'type': 'knx_group_address',
}),
dict({
'name': 'scene_number',
'required': True,
'selector': dict({
'number': dict({
'max': 64.0,
'min': 1.0,
'mode': 'box',
'step': 1.0,
}),
}),
'type': 'ha_selector',
}),
]),
'success': True,
'type': 'result',
})
# ---
# name: test_knx_get_schema[sensor]
dict({
'id': 1,

View File

@@ -2,10 +2,16 @@
from homeassistant.components.knx.const import KNX_ADDRESS
from homeassistant.components.knx.schema import SceneSchema
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, EntityCategory
from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_NAME,
EntityCategory,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import KnxEntityGenerator
from .conftest import KNXTestKit
from tests.common import async_capture_events
@@ -56,3 +62,35 @@ async def test_activate_knx_scene(
# different scene number - should not be recorded
await knx.receive_write("1/1/1", (0x00,))
assert len(events) == 4
async def test_scene_ui_create(
hass: HomeAssistant,
knx: KNXTestKit,
create_ui_entity: KnxEntityGenerator,
) -> None:
"""Test creating a scene."""
await knx.setup_integration()
await create_ui_entity(
platform=Platform.SCENE,
entity_data={"name": "test"},
knx_data={
"ga_scene": {"write": "1/1/1"},
"scene_number": 5,
},
)
# activate scene from HA
await hass.services.async_call(
"scene", "turn_on", {"entity_id": "scene.test"}, blocking=True
)
await knx.assert_write("1/1/1", (0x04,)) # raw scene number is 0-based
async def test_scene_ui_load(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test loading a scene from storage."""
await knx.setup_integration(config_store_fixture="config_store_scene.json")
# activate scene from HA
await hass.services.async_call(
"scene", "turn_on", {"entity_id": "scene.test"}, blocking=True
)
await knx.assert_write("1/1/1", (0x0B,))