From 85e5081fc3b713a76f8caaaeabc02f2a2ddf7332 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 22 Aug 2025 12:24:05 +0200 Subject: [PATCH 1/4] Use serialized schema from backend in UI entity configuration (#149496) --- homeassistant/components/knx/manifest.json | 2 +- .../knx/storage/entity_store_schema.py | 298 +++---- .../components/knx/storage/knx_selector.py | 177 +++- .../components/knx/storage/serialize.py | 47 + homeassistant/components/knx/storage/util.py | 18 + homeassistant/components/knx/strings.json | 270 ++++++ homeassistant/components/knx/websocket.py | 26 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../knx/snapshots/test_websocket.ambr | 801 ++++++++++++++++++ tests/components/knx/test_knx_selectors.py | 325 +++++-- tests/components/knx/test_websocket.py | 23 +- 12 files changed, 1771 insertions(+), 220 deletions(-) create mode 100644 homeassistant/components/knx/storage/serialize.py create mode 100644 tests/components/knx/snapshots/test_websocket.ambr diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 312ea56972f38..e9f3f3644d619 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.8.9.63154" + "knx-frontend==2025.8.21.181525" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 6c41a7d29e7b1..fe0dbf31b6bb7 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -27,7 +27,6 @@ ColorTempModes, CoverConf, ) -from ..validation import sync_state_validator from .const import ( CONF_COLOR, CONF_COLOR_TEMP_MAX, @@ -57,7 +56,14 @@ CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, ) -from .knx_selector import GASelector, GroupSelect +from .knx_selector import ( + AllSerializeFirst, + GASelector, + GroupSelect, + GroupSelectOption, + KNXSectionFlat, + SyncStateSelector, +) BASE_ENTITY_SCHEMA = vol.All( { @@ -85,86 +91,86 @@ ) -BINARY_SENSOR_SCHEMA = vol.Schema( +BINARY_SENSOR_KNX_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): { - vol.Required(CONF_GA_SENSOR): GASelector(write=False, state_required=True), - vol.Required(CONF_RESPOND_TO_READ, default=False): bool, - vol.Required(CONF_SYNC_STATE, default=True): sync_state_validator, - vol.Optional(CONF_INVERT): selector.BooleanSelector(), - vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), - vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( + "section_binary_sensor": KNXSectionFlat(), + vol.Required(CONF_GA_SENSOR): GASelector( + write=False, state_required=True, valid_dpt="1" + ), + vol.Optional(CONF_INVERT): selector.BooleanSelector(), + "section_advanced_options": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), + vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=600, step=0.1, unit_of_measurement="s" + ) + ), + vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, +) + +COVER_KNX_SCHEMA = AllSerializeFirst( + vol.Schema( + { + "section_binary_control": KNXSectionFlat(), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), + vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), + "section_stop_control": KNXSectionFlat(), + vol.Optional(CONF_GA_STOP): GASelector(state=False), + vol.Optional(CONF_GA_STEP): GASelector(state=False), + "section_position_control": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), + vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), + "section_tilt_control": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_ANGLE): GASelector(), + vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), + "section_travel_time": KNXSectionFlat(), + vol.Optional( + CoverConf.TRAVELLING_TIME_UP, default=25 + ): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, max=10, step=0.1, unit_of_measurement="s" + min=0, max=1000, step=0.1, unit_of_measurement="s" ) ), - vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( + vol.Optional( + CoverConf.TRAVELLING_TIME_DOWN, default=25 + ): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, max=600, step=0.1, unit_of_measurement="s" + min=0, max=1000, step=0.1, unit_of_measurement="s" ) ), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), }, - } -) - -COVER_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): vol.All( - vol.Schema( - { - vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), - vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), - vol.Optional(CONF_GA_STOP): GASelector(state=False), - vol.Optional(CONF_GA_STEP): GASelector(state=False), - vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), - vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), - vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), - vol.Optional(CONF_GA_ANGLE): GASelector(), - vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), - vol.Optional( - CoverConf.TRAVELLING_TIME_DOWN, default=25 - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=1000, step=0.1, unit_of_measurement="s" - ) - ), - vol.Optional( - CoverConf.TRAVELLING_TIME_UP, default=25 - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=1000, step=0.1, unit_of_measurement="s" - ) - ), - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - }, - extra=vol.REMOVE_EXTRA, - ), - vol.Any( - vol.Schema( - { - vol.Required(CONF_GA_UP_DOWN): GASelector( - state=False, write_required=True - ) - }, - extra=vol.ALLOW_EXTRA, - ), - vol.Schema( - { - vol.Required(CONF_GA_POSITION_SET): GASelector( - state=False, write_required=True - ) - }, - extra=vol.ALLOW_EXTRA, - ), - msg=( - "At least one of 'Up/Down control' or" - " 'Position - Set position' is required." - ), - ), + extra=vol.REMOVE_EXTRA, + ), + vol.Any( + vol.Schema( + { + vol.Required(CONF_GA_UP_DOWN): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + { + vol.Required(CONF_GA_POSITION_SET): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, ), - } + msg=( + "At least one of 'Open/Close control' or" + " 'Position - Set position' is required." + ), + ), ) @@ -177,81 +183,91 @@ class LightColorMode(StrEnum): XYY = "242.600" -@unique -class LightColorModeSchema(StrEnum): - """Enum for light color mode.""" - - DEFAULT = "default" - INDIVIDUAL = "individual" - HSV = "hsv" - - _hs_color_inclusion_msg = ( "'Hue', 'Saturation' and 'Brightness' addresses are required for HSV configuration" ) -LIGHT_KNX_SCHEMA = vol.All( +LIGHT_KNX_SCHEMA = AllSerializeFirst( vol.Schema( { - vol.Optional(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Optional(CONF_GA_BRIGHTNESS): GASelector(write_required=True), + "section_switch": KNXSectionFlat(), + vol.Optional(CONF_GA_SWITCH): GASelector( + write_required=True, valid_dpt="1" + ), + "section_brightness": KNXSectionFlat(), + vol.Optional(CONF_GA_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" + ), + "section_color_temp": KNXSectionFlat(collapsible=True), vol.Optional(CONF_GA_COLOR_TEMP): GASelector( write_required=True, dpt=ColorTempModes ), + vol.Required(CONF_COLOR_TEMP_MIN, default=2700): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=10000, step=1, unit_of_measurement="K" + ) + ), + vol.Required(CONF_COLOR_TEMP_MAX, default=6000): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=10000, step=1, unit_of_measurement="K" + ) + ), vol.Optional(CONF_COLOR): GroupSelect( - vol.Schema( - { + GroupSelectOption( + translation_key="single_address", + schema={ vol.Optional(CONF_GA_COLOR): GASelector( write_required=True, dpt=LightColorMode ) - } + }, ), - vol.Schema( - { - vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( - write_required=True - ), + GroupSelectOption( + translation_key="individual_addresses", + schema={ + "section_red": KNXSectionFlat(), vol.Optional(CONF_GA_RED_SWITCH): GASelector( - write_required=False + write_required=False, valid_dpt="1" ), - vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( - write_required=True + vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" ), + "section_green": KNXSectionFlat(), vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( - write_required=False + write_required=False, valid_dpt="1" + ), + vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" ), + "section_blue": KNXSectionFlat(), vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( - write_required=True + write_required=True, valid_dpt="5.001" ), vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( - write_required=False + write_required=False, valid_dpt="1" ), + "section_white": KNXSectionFlat(), vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( - write_required=True + write_required=True, valid_dpt="5.001" ), vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( - write_required=False + write_required=False, valid_dpt="1" ), - } + }, ), - vol.Schema( - { - vol.Required(CONF_GA_HUE): GASelector(write_required=True), + GroupSelectOption( + translation_key="hsv_addresses", + schema={ + vol.Required(CONF_GA_HUE): GASelector( + write_required=True, valid_dpt="5.001" + ), vol.Required(CONF_GA_SATURATION): GASelector( - write_required=True + write_required=True, valid_dpt="5.001" ), - } + }, ), - # msg="error in `color` config", - ), - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( - vol.Coerce(int), vol.Range(min=1) ), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), } ), vol.Any( @@ -291,26 +307,22 @@ class LightColorModeSchema(StrEnum): ), ) - -LIGHT_SCHEMA = vol.Schema( +SWITCH_KNX_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): LIGHT_KNX_SCHEMA, - } + "section_switch": KNXSectionFlat(), + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), + vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, ) - -SWITCH_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): { - vol.Optional(CONF_INVERT, default=False): bool, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - }, - } -) +KNX_SCHEMA_FOR_PLATFORM = { + Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA, + Platform.COVER: COVER_KNX_SCHEMA, + Platform.LIGHT: LIGHT_KNX_SCHEMA, + Platform.SWITCH: SWITCH_KNX_SCHEMA, +} ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( vol.Schema( @@ -326,18 +338,16 @@ class LightColorModeSchema(StrEnum): cv.key_value_schemas( CONF_PLATFORM, { - Platform.BINARY_SENSOR: vol.Schema( - {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA - ), - Platform.COVER: vol.Schema( - {vol.Required(CONF_DATA): COVER_SCHEMA}, extra=vol.ALLOW_EXTRA - ), - Platform.LIGHT: vol.Schema( - {vol.Required(CONF_DATA): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA - ), - Platform.SWITCH: vol.Schema( - {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA - ), + platform: vol.Schema( + { + vol.Required(CONF_DATA): { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): knx_schema, + }, + }, + extra=vol.ALLOW_EXTRA, + ) + for platform, knx_schema in KNX_SCHEMA_FOR_PLATFORM.items() }, ), ) diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index fe909f1fd0a38..5adf242c16be4 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -6,11 +6,103 @@ import voluptuous as vol -from ..validation import ga_validator, maybe_ga_validator +from ..validation import ga_validator, maybe_ga_validator, sync_state_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE +from .util import dpt_string_to_dict -class GroupSelect(vol.Any): +class AllSerializeFirst(vol.All): + """Use the first validated value for serialization. + + This is a version of vol.All with custom error handling to + show proper invalid markers for sub-schema items in the UI. + """ + + +class KNXSelectorBase: + """Base class for KNX selectors supporting optional nested schemas.""" + + schema: vol.Schema | vol.Any | vol.All + selector_type: str + # mark if self.schema should be serialized to `schema` key + serialize_subschema: bool = False + + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + return self.schema(data) + + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + # don't use "name", "default", "optional" or "required" in base output + # as it will be overwritten by the parent keys attributes + # "schema" will be overwritten by knx serializer if `self.serialize_subschema` is True + raise NotImplementedError("Subclasses must implement this method.") + + +class KNXSectionFlat(KNXSelectorBase): + """Generate a schema-neutral section with title and description for the following siblings.""" + + selector_type = "knx_section_flat" + schema = vol.Schema(None) + + def __init__( + self, + collapsible: bool = False, + ) -> None: + """Initialize the section.""" + self.collapsible = collapsible + + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + return { + "type": self.selector_type, + "collapsible": self.collapsible, + } + + +class KNXSection(KNXSelectorBase): + """Configuration groups similar to DataEntryFlow sections but with more options.""" + + selector_type = "knx_section" + serialize_subschema = True + + def __init__( + self, + schema: dict[str | vol.Marker, vol.Schemable], + collapsible: bool = True, + ) -> None: + """Initialize the section.""" + self.collapsible = collapsible + self.schema = vol.Schema(schema) + + def serialize(self) -> dict[str, Any]: + """Serialize the section to a dictionary.""" + return { + "type": self.selector_type, + "collapsible": self.collapsible, + } + + +class GroupSelectOption(KNXSelectorBase): + """Schema for group select options.""" + + selector_type = "knx_group_select_option" + serialize_subschema: bool = True + + def __init__(self, schema: vol.Schemable, translation_key: str) -> None: + """Initialize the group select option schema.""" + self.translation_key = translation_key + self.schema = vol.Schema(schema) + + def serialize(self) -> dict[str, Any]: + """Serialize the group select option to a dictionary.""" + return { + "type": self.selector_type, + "translation_key": self.translation_key, + } + + +class GroupSelectSchema(vol.Any): """Use the first validated value. This is a version of vol.Any with custom error handling to @@ -35,10 +127,33 @@ def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> raise vol.AnyInvalid(self.msg or "no valid value found", path=path) -class GASelector: +class GroupSelect(KNXSelectorBase): + """Selector for group select options.""" + + selector_type = "knx_group_select" + serialize_subschema = True + + def __init__( + self, + *options: GroupSelectOption, + collapsible: bool = True, + ) -> None: + """Initialize the group select selector.""" + self.collapsible = collapsible + self.schema = GroupSelectSchema(*options) + + def serialize(self) -> dict[str, Any]: + """Serialize the group select to a dictionary.""" + return { + "type": self.selector_type, + "collapsible": self.collapsible, + } + + +class GASelector(KNXSelectorBase): """Selector for a KNX group address structure.""" - schema: vol.Schema + selector_type = "knx_group_address" def __init__( self, @@ -48,6 +163,7 @@ def __init__( write_required: bool = False, state_required: bool = False, dpt: type[Enum] | None = None, + valid_dpt: str | Iterable[str] | None = None, ) -> None: """Initialize the group address selector.""" self.write = write @@ -56,12 +172,35 @@ def __init__( self.write_required = write_required self.state_required = state_required self.dpt = dpt + # valid_dpt is used in frontend to filter dropdown menu - no validation is done + self.valid_dpt = (valid_dpt,) if isinstance(valid_dpt, str) else valid_dpt self.schema = self.build_schema() - def __call__(self, data: Any) -> Any: - """Validate the passed data.""" - return self.schema(data) + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + + options: dict[str, Any] = { + "write": {"required": self.write_required} if self.write else False, + "state": {"required": self.state_required} if self.state else False, + "passive": self.passive, + } + if self.dpt is not None: + options["dptSelect"] = [ + { + "value": item.value, + "translation_key": item.value.replace(".", "_"), + "dpt": dpt_string_to_dict(item.value), # used for filtering GAs + } + for item in self.dpt + ] + if self.valid_dpt is not None: + options["validDPTs"] = [dpt_string_to_dict(dpt) for dpt in self.valid_dpt] + + return { + "type": self.selector_type, + "options": options, + } def build_schema(self) -> vol.Schema: """Create the schema based on configuration.""" @@ -118,3 +257,27 @@ def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None: schema[vol.Required(CONF_DPT)] = vol.In({item.value for item in self.dpt}) else: schema[vol.Remove(CONF_DPT)] = object + + +class SyncStateSelector(KNXSelectorBase): + """Selector for knx sync state validation.""" + + schema = vol.Schema(sync_state_validator) + selector_type = "knx_sync_state" + + def __init__(self, allow_false: bool = False) -> None: + """Initialize the sync state validator.""" + self.allow_false = allow_false + + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + return { + "type": self.selector_type, + "allow_false": self.allow_false, + } + + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + if not self.allow_false and not data: + raise vol.Invalid(f"Sync state cannot be {data}") + return self.schema(data) diff --git a/homeassistant/components/knx/storage/serialize.py b/homeassistant/components/knx/storage/serialize.py new file mode 100644 index 0000000000000..45b40fb907b57 --- /dev/null +++ b/homeassistant/components/knx/storage/serialize.py @@ -0,0 +1,47 @@ +"""Custom serializer for KNX schemas.""" + +from typing import Any, cast + +import voluptuous as vol +from voluptuous_serialize import UNSUPPORTED, UnsupportedType, convert + +from homeassistant.const import Platform +from homeassistant.helpers import selector + +from .entity_store_schema import KNX_SCHEMA_FOR_PLATFORM +from .knx_selector import AllSerializeFirst, GroupSelectSchema, KNXSelectorBase + + +def knx_serializer( + schema: vol.Schema, +) -> dict[str, Any] | list[dict[str, Any]] | UnsupportedType: + """Serialize KNX schema.""" + if isinstance(schema, GroupSelectSchema): + return [ + cast( + dict[str, Any], # GroupSelectOption converts to a dict with subschema + convert(option, custom_serializer=knx_serializer), + ) + for option in schema.validators + ] + if isinstance(schema, KNXSelectorBase): + result = schema.serialize() + if schema.serialize_subschema: + result["schema"] = convert(schema.schema, custom_serializer=knx_serializer) + return result + if isinstance(schema, AllSerializeFirst): + return convert(schema.validators[0], custom_serializer=knx_serializer) + + if isinstance(schema, selector.Selector): + return schema.serialize() | {"type": "ha_selector"} + + return UNSUPPORTED + + +def get_serialized_schema( + platform: Platform, +) -> dict[str, Any] | list[dict[str, Any]] | None: + """Get the schema for a specific platform.""" + if knx_schema := KNX_SCHEMA_FOR_PLATFORM.get(platform): + return convert(knx_schema, custom_serializer=knx_serializer) + return None diff --git a/homeassistant/components/knx/storage/util.py b/homeassistant/components/knx/storage/util.py index a3831070a7e43..978a056845587 100644 --- a/homeassistant/components/knx/storage/util.py +++ b/homeassistant/components/knx/storage/util.py @@ -3,11 +3,29 @@ from functools import partial from typing import Any +from xknx.typing import DPTMainSubDict + from homeassistant.helpers.typing import ConfigType from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE +def dpt_string_to_dict(dpt: str) -> DPTMainSubDict: + """Convert a DPT string to a typed dictionary with main and sub components. + + Examples: + >>> dpt_string_to_dict("1.010") + {'main': 1, 'sub': 10} + >>> dpt_string_to_dict("5") + {'main': 5, 'sub': None} + """ + dpt_num = dpt.split(".") + return DPTMainSubDict( + main=int(dpt_num[0]), + sub=int(dpt_num[1]) if len(dpt_num) > 1 else None, + ) + + def nested_get(dic: ConfigType, *keys: str, default: Any | None = None) -> Any: """Get the value from a nested dictionary.""" for key in keys: diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 921fc2c5288a6..e1fbaf6aebf72 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -339,5 +339,275 @@ "name": "[%key:common::action::reload%]", "description": "Reloads the KNX integration." } + }, + "config_panel": { + "entities": { + "create": { + "type_selection": { + "title": "Select entity type", + "header": "Create KNX entity" + }, + "header": "Create new entity", + "_": { + "entity": { + "title": "Entity configuration", + "description": "Home Assistant specific settings.", + "name_title": "Device and entity name", + "name_description": "Define how the entity should be named in Home Assistant.", + "device_description": "A device allows to group multiple entities. Select the device this entity belongs to or create a new one.", + "entity_label": "Entity name", + "entity_description": "Optional if a device is selected, otherwise required. If the entity is assigned to a device, the device name is used as prefix.", + "entity_category_title": "Entity category", + "entity_category_description": "Classification of a non-primary entity. Leave empty for standard behaviour." + }, + "knx": { + "title": "KNX configuration", + "knx_group_address": { + "dpt": "Datapoint type", + "send_address": "Send address", + "state_address": "State address", + "passive_addresses": "Passive addresses", + "valid_dpts": "Valid DPTs" + }, + "sync_state": { + "title": "State updater", + "description": "Actively request state updates from KNX bus for state addresses.", + "strategy": "Strategy", + "options": { + "true": "Use integration default", + "false": "Never", + "init": "Once when connection established", + "expire": "Expire after last value update", + "every": "Scheduled every" + } + } + } + }, + "binary_sensor": { + "description": "Read-only entity for binary datapoints. Window or door states etc.", + "knx": { + "section_binary_sensor": { + "title": "Binary sensor", + "description": "DPT 1 group addresses representing binary states." + }, + "invert": { + "label": "Invert", + "description": "Invert payload before processing." + }, + "section_advanced_options": { + "title": "State properties", + "description": "Properties of the binary sensor state." + }, + "ignore_internal_state": { + "label": "Force update", + "description": "Write each update to the state machine, even if the data is the same." + }, + "context_timeout": { + "label": "Context timeout", + "description": "The time in seconds between multiple identical telegram payloads would count towards an internal counter. This can be used to automate on mulit-clicks of a button. `0` to disable this feature." + }, + "reset_after": { + "label": "Reset after", + "description": "Reset back to “off” state after specified seconds." + } + } + }, + "cover": { + "description": "The KNX cover platform is used as an interface to shutter actuators.", + "knx": { + "section_binary_control": { + "title": "Open/Close control", + "description": "DPT 1 group addresses triggering full movement." + }, + "ga_up_down": { + "label": "Open/Close" + }, + "invert_updown": { + "label": "Invert", + "description": "Default is UP (0) to open a cover and DOWN (1) to close a cover. Enable this to invert the open/close commands from/to your KNX actuator." + }, + "section_stop_control": { + "title": "Stop", + "description": "DPT 1 group addresses for stopping movement." + }, + "ga_stop": { + "label": "Stop" + }, + "ga_step": { + "label": "Stepwise move" + }, + "section_position_control": { + "title": "Position", + "description": "DPT 5 group addresses for cover position." + }, + "ga_position_set": { + "label": "Set position" + }, + "ga_position_state": { + "label": "Current Position" + }, + "invert_position": { + "label": "Invert", + "description": "Invert payload before processing. Enable if KNX reports 0% as fully closed." + }, + "section_tilt_control": { + "title": "Tilt", + "description": "DPT 5 group addresses for slat tilt angle." + }, + "ga_angle": { + "label": "Tilt angle" + }, + "invert_angle": { + "label": "[%key:component::knx::config_panel::entities::create::cover::knx::invert_position::label%]", + "description": "[%key:component::knx::config_panel::entities::create::cover::knx::invert_position::description%]" + }, + "section_travel_time": { + "title": "Travel time", + "description": "Used to calculate intermediate positions of the cover while traveling." + }, + "travelling_time_up": { + "label": "Travel time for opening", + "description": "Time the cover needs to fully open in seconds." + }, + "travelling_time_down": { + "label": "Travel time for closing", + "description": "Time the cover needs to fully close in seconds." + } + } + }, + "light": { + "description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.", + "knx": { + "section_switch": { + "title": "Switch", + "description": "Turn the light on/off." + }, + "ga_switch": { + "label": "Switch" + }, + "section_brightness": { + "title": "Brightness", + "description": "Control the brightness of the light." + }, + "ga_brightness": { + "label": "Brightness" + }, + "section_color_temp": { + "title": "Color temperature", + "description": "Control the color temperature of the light." + }, + "ga_color_temp": { + "label": "Color temperature", + "options": { + "5_001": "Percent", + "7_600": "Kelvin", + "9": "2-byte floating point" + } + }, + "color_temp_min": { + "label": "Warmest possible color temperature" + }, + "color_temp_max": { + "label": "Coldest possible color temperature" + }, + "color": { + "title": "Color", + "description": "Control the color of the light.", + "options": { + "single_address": { + "label": "Single address", + "description": "RGB, RGBW or XYY color controlled by a single group address." + }, + "individual_addresses": { + "label": "Individual addresses", + "description": "RGB(W) using individual state and brightness group addresses." + }, + "hsv_addresses": { + "label": "HSV", + "description": "Hue, saturation and brightness using individual group addresses." + } + }, + "ga_color": { + "label": "Color", + "options": { + "232_600": "RGB", + "242_600": "XYY", + "251_600": "RGBW" + } + }, + "section_red": { + "title": "Red", + "description": "Control the lights red color. Brightness group address is required." + }, + "ga_red_switch": { + "label": "Red switch" + }, + "ga_red_brightness": { + "label": "Red brightness" + }, + "section_green": { + "title": "Green", + "description": "Control the lights green color. Brightness group address is required." + }, + "ga_green_switch": { + "label": "Green switch" + }, + "ga_green_brightness": { + "label": "Green brightness" + }, + "section_blue": { + "title": "Blue", + "description": "Control the lights blue color. Brightness group address is required." + }, + "ga_blue_switch": { + "label": "Blue switch" + }, + "ga_blue_brightness": { + "label": "Blue brightness" + }, + "section_white": { + "title": "White", + "description": "Control the lights white color. Brightness group address is required." + }, + "ga_white_switch": { + "label": "White switch" + }, + "ga_white_brightness": { + "label": "White brightness" + }, + "ga_hue": { + "label": "Hue", + "description": "Control the lights hue." + }, + "ga_saturation": { + "label": "Saturation", + "description": "Control the lights saturation." + } + } + } + }, + "switch": { + "description": "The KNX switch platform is used as an interface to switching actuators.", + "knx": { + "section_switch": { + "title": "Switching", + "description": "DPT 1 group addresses controlling the switch function." + }, + "ga_switch": { + "label": "Switch", + "description": "Group address to switch the device on/off." + }, + "invert": { + "label": "Invert", + "description": "Invert payloads before processing or sending." + }, + "respond_to_read": { + "label": "Respond to read", + "description": "Respond to GroupValueRead telegrams received to the configured send address." + } + } + } + } + } } } diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index b40dc2246b8cc..3efcde7edb6e1 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -14,7 +14,7 @@ from homeassistant.components import panel_custom, websocket_api from homeassistant.components.http import StaticPathConfig -from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -33,6 +33,7 @@ EntityStoreValidationSuccess, validate_entity_data, ) +from .storage.serialize import get_serialized_schema from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict if TYPE_CHECKING: @@ -57,6 +58,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_entity_config) websocket_api.async_register_command(hass, ws_get_entity_entries) websocket_api.async_register_command(hass, ws_create_device) + websocket_api.async_register_command(hass, ws_get_schema) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -363,6 +365,28 @@ def ws_validate_entity( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_schema", + vol.Required(CONF_PLATFORM): vol.Coerce(Platform), + } +) +@websocket_api.async_response +async def ws_get_schema( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Provide serialized schema for platform.""" + if schema := get_serialized_schema(msg[CONF_PLATFORM]): + connection.send_result(msg["id"], schema) + return + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Unknown platform" + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/requirements_all.txt b/requirements_all.txt index d319add19a6c0..d524084aea808 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.9.63154 +knx-frontend==2025.8.21.181525 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90b1e3bf2b209..30eaacf2ec7d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.9.63154 +knx-frontend==2025.8.21.181525 # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr new file mode 100644 index 0000000000000..b99196c8769b0 --- /dev/null +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -0,0 +1,801 @@ +# serializer version: 1 +# name: test_knx_get_schema[binary_sensor] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_binary_sensor', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_sensor', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': True, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': False, + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_advanced_options', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ignore_internal_state', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'context_timeout', + 'optional': True, + 'required': False, + 'selector': dict({ + 'number': dict({ + 'max': 10.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'reset_after', + 'optional': True, + 'required': False, + 'selector': dict({ + 'number': dict({ + 'max': 600.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'required': True, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[cover] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_binary_control', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_up_down', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert_updown', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': False, + 'name': 'section_stop_control', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_stop', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_step', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': True, + 'name': 'section_position_control', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_position_set', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_position_state', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': False, + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert_position', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_tilt_control', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_angle', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert_angle', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': False, + 'name': 'section_travel_time', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'default': 25, + 'name': 'travelling_time_up', + 'optional': True, + 'required': False, + 'selector': dict({ + 'number': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 25, + 'name': 'travelling_time_down', + 'optional': True, + 'required': False, + 'selector': dict({ + 'number': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[light] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_switch', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_brightness', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_brightness', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': True, + 'name': 'section_color_temp', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_color_temp', + 'optional': True, + 'options': dict({ + 'dptSelect': list([ + dict({ + 'dpt': dict({ + 'main': 7, + 'sub': 600, + }), + 'translation_key': '7_600', + 'value': '7.600', + }), + dict({ + 'dpt': dict({ + 'main': 9, + 'sub': None, + }), + 'translation_key': '9', + 'value': '9', + }), + dict({ + 'dpt': dict({ + 'main': 5, + 'sub': 1, + }), + 'translation_key': '5_001', + 'value': '5.001', + }), + ]), + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'default': 2700, + 'name': 'color_temp_min', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 10000.0, + 'min': 1.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 6000, + 'name': 'color_temp_max', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 10000.0, + 'min': 1.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'color', + 'optional': True, + 'required': False, + 'schema': list([ + dict({ + 'schema': list([ + dict({ + 'name': 'ga_color', + 'optional': True, + 'options': dict({ + 'dptSelect': list([ + dict({ + 'dpt': dict({ + 'main': 232, + 'sub': 600, + }), + 'translation_key': '232_600', + 'value': '232.600', + }), + dict({ + 'dpt': dict({ + 'main': 251, + 'sub': 600, + }), + 'translation_key': '251_600', + 'value': '251.600', + }), + dict({ + 'dpt': dict({ + 'main': 242, + 'sub': 600, + }), + 'translation_key': '242_600', + 'value': '242.600', + }), + ]), + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'single_address', + 'type': 'knx_group_select_option', + }), + dict({ + 'schema': list([ + dict({ + 'collapsible': False, + 'name': 'section_red', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_red_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_red_brightness', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_green', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_green_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_green_brightness', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_blue', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_blue_brightness', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_blue_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_white', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_white_brightness', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_white_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'individual_addresses', + 'type': 'knx_group_select_option', + }), + dict({ + 'schema': list([ + dict({ + 'name': 'ga_hue', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_saturation', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'hsv_addresses', + 'type': 'knx_group_select_option', + }), + ]), + 'type': 'knx_group_select', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[switch] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_switch', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_switch', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': False, + 'name': 'invert', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': False, + 'name': 'respond_to_read', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[tts] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Unknown platform', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- diff --git a/tests/components/knx/test_knx_selectors.py b/tests/components/knx/test_knx_selectors.py index 12acf691c08a7..61bcb71eb5e4d 100644 --- a/tests/components/knx/test_knx_selectors.py +++ b/tests/components/knx/test_knx_selectors.py @@ -4,9 +4,20 @@ import pytest import voluptuous as vol +from voluptuous_serialize import convert from homeassistant.components.knx.const import ColorTempModes -from homeassistant.components.knx.storage.knx_selector import GASelector +from homeassistant.components.knx.storage.knx_selector import ( + AllSerializeFirst, + GASelector, + GroupSelect, + GroupSelectOption, + KNXSection, + KNXSectionFlat, + SyncStateSelector, +) +from homeassistant.components.knx.storage.serialize import knx_serializer +from homeassistant.helpers import selector INVALID = "invalid" @@ -14,143 +25,329 @@ @pytest.mark.parametrize( ("selector_config", "data", "expected"), [ - # empty data is invalid + # valid data ( {}, - {}, - {INVALID: "At least one group address must be set"}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None, "passive": []}, ), ( - {"write": False}, {}, - {INVALID: "At least one group address must be set"}, + {"state": "1/2/3"}, + {"write": None, "state": "1/2/3", "passive": []}, ), ( - {"passive": False}, {}, - {INVALID: "At least one group address must be set"}, + {"passive": ["1/2/3"]}, + {"write": None, "state": None, "passive": ["1/2/3"]}, ), ( - {"write": False, "state": False, "passive": False}, {}, - {INVALID: "At least one group address must be set"}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, ), - # stale data is invalid ( {"write": False}, - {"write": "1/2/3"}, - {INVALID: "At least one group address must be set"}, + {"state": "1/2/3"}, + {"state": "1/2/3", "passive": []}, ), ( {"write": False}, - {"passive": []}, - {INVALID: "At least one group address must be set"}, - ), - ( - {"state": False}, - {"write": None}, - {INVALID: "At least one group address must be set"}, + {"passive": ["1/2/3"]}, + {"state": None, "passive": ["1/2/3"]}, ), ( {"passive": False}, - {"passive": ["1/2/3"]}, - {INVALID: "At least one group address must be set"}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None}, ), - # valid data + # required keys ( - {}, + {"write_required": True}, {"write": "1/2/3"}, {"write": "1/2/3", "state": None, "passive": []}, ), ( - {}, + {"state_required": True}, {"state": "1/2/3"}, {"write": None, "state": "1/2/3", "passive": []}, ), + # dpt key + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3", "dpt": "7.600"}, + {"write": "1/2/3", "state": None, "passive": [], "dpt": "7.600"}, + ), + ], +) +def test_ga_selector( + selector_config: dict[str, Any], + data: dict[str, Any], + expected: dict[str, Any], +) -> None: + """Test GASelector.""" + selector = GASelector(**selector_config) + result = selector(data) + assert result == expected + + +@pytest.mark.parametrize( + ("selector_config", "data", "error_str"), + [ + # empty data is invalid ( {}, - {"passive": ["1/2/3"]}, - {"write": None, "state": None, "passive": ["1/2/3"]}, + {}, + "At least one group address must be set", ), ( + {"write": False}, {}, - {"write": "1", "state": 2, "passive": ["1/2/3"]}, - {"write": "1", "state": 2, "passive": ["1/2/3"]}, + "At least one group address must be set", ), + ( + {"passive": False}, + {}, + "At least one group address must be set", + ), + ( + {"write": False, "state": False, "passive": False}, + {}, + "At least one group address must be set", + ), + # stale data is invalid ( {"write": False}, - {"state": "1/2/3"}, - {"state": "1/2/3", "passive": []}, + {"write": "1/2/3"}, + "At least one group address must be set", ), ( {"write": False}, - {"passive": ["1/2/3"]}, - {"state": None, "passive": ["1/2/3"]}, + {"passive": []}, + "At least one group address must be set", + ), + ( + {"state": False}, + {"write": None}, + "At least one group address must be set", ), ( {"passive": False}, - {"write": "1/2/3"}, - {"write": "1/2/3", "state": None}, + {"passive": ["1/2/3"]}, + "At least one group address must be set", ), # required keys ( {"write_required": True}, {}, - {INVALID: r"required key not provided*"}, + r"required key not provided*", ), ( {"state_required": True}, {}, - {INVALID: r"required key not provided*"}, - ), - ( - {"write_required": True}, - {"write": "1/2/3"}, - {"write": "1/2/3", "state": None, "passive": []}, - ), - ( - {"state_required": True}, - {"state": "1/2/3"}, - {"write": None, "state": "1/2/3", "passive": []}, + r"required key not provided*", ), ( {"write_required": True}, {"state": "1/2/3"}, - {INVALID: r"required key not provided*"}, + r"required key not provided*", ), ( {"state_required": True}, {"write": "1/2/3"}, - {INVALID: r"required key not provided*"}, + r"required key not provided*", ), # dpt key ( {"dpt": ColorTempModes}, {"write": "1/2/3"}, - {INVALID: r"required key not provided*"}, - ), - ( - {"dpt": ColorTempModes}, - {"write": "1/2/3", "dpt": "7.600"}, - {"write": "1/2/3", "state": None, "passive": [], "dpt": "7.600"}, + r"required key not provided*", ), ( {"dpt": ColorTempModes}, {"write": "1/2/3", "state": None, "passive": [], "dpt": "invalid"}, - {INVALID: r"value must be one of ['5.001', '7.600', '9']*"}, + r"value must be one of ['5.001', '7.600', '9']*", ), ], ) -def test_ga_selector( +def test_ga_selector_invalid( selector_config: dict[str, Any], data: dict[str, Any], - expected: dict[str, Any], + error_str: str, ) -> None: """Test GASelector.""" selector = GASelector(**selector_config) - if INVALID in expected: - with pytest.raises(vol.Invalid, match=expected[INVALID]): - selector(data) - else: - result = selector(data) - assert result == expected + with pytest.raises(vol.Invalid, match=error_str): + selector(data) + + +def test_sync_state_selector() -> None: + """Test SyncStateSelector.""" + selector = SyncStateSelector() + assert selector("expire 50") == "expire 50" + + with pytest.raises(vol.Invalid): + selector("invalid") + + with pytest.raises(vol.Invalid, match="Sync state cannot be False"): + selector(False) + + false_allowed = SyncStateSelector(allow_false=True) + assert false_allowed(False) is False + + +@pytest.mark.parametrize( + ("selector", "serialized"), + [ + ( + GASelector(), + { + "type": "knx_group_address", + "options": { + "write": {"required": False}, + "state": {"required": False}, + "passive": True, + }, + }, + ), + ( + GASelector( + state=False, write_required=True, passive=False, valid_dpt="5.001" + ), + { + "type": "knx_group_address", + "options": { + "write": {"required": True}, + "state": False, + "passive": False, + "validDPTs": [{"main": 5, "sub": 1}], + }, + }, + ), + ( + GASelector(dpt=ColorTempModes), + { + "type": "knx_group_address", + "options": { + "write": {"required": False}, + "state": {"required": False}, + "passive": True, + "dptSelect": [ + { + "value": "7.600", + "translation_key": "7_600", + "dpt": {"main": 7, "sub": 600}, + }, + { + "value": "9", + "translation_key": "9", + "dpt": {"main": 9, "sub": None}, + }, + { + "value": "5.001", + "translation_key": "5_001", + "dpt": {"main": 5, "sub": 1}, + }, + ], + }, + }, + ), + ], +) +def test_ga_selector_serialization( + selector: GASelector, serialized: dict[str, Any] +) -> None: + """Test GASelector serialization.""" + assert selector.serialize() == serialized + + +@pytest.mark.parametrize( + ("schema", "serialized"), + [ + ( + AllSerializeFirst(vol.Schema({"key": int}), vol.Schema({"ignored": str})), + [{"name": "key", "required": False, "type": "integer"}], + ), + ( + KNXSectionFlat(collapsible=True), + {"type": "knx_section_flat", "collapsible": True}, + ), + ( + KNXSection( + collapsible=True, + schema={"key": int}, + ), + { + "type": "knx_section", + "collapsible": True, + "schema": [{"name": "key", "required": False, "type": "integer"}], + }, + ), + ( + GroupSelect( + GroupSelectOption(translation_key="option_1", schema={"key_1": str}), + GroupSelectOption(translation_key="option_2", schema={"key_2": int}), + ), + { + "type": "knx_group_select", + "collapsible": True, + "schema": [ + { + "type": "knx_group_select_option", + "translation_key": "option_1", + "schema": [ + {"name": "key_1", "required": False, "type": "string"} + ], + }, + { + "type": "knx_group_select_option", + "translation_key": "option_2", + "schema": [ + {"name": "key_2", "required": False, "type": "integer"} + ], + }, + ], + }, + ), + ( + SyncStateSelector(), + { + "type": "knx_sync_state", + "allow_false": False, + }, + ), + ( + selector.BooleanSelector(), + { + "type": "ha_selector", + "selector": {"boolean": {}}, + }, + ), + ( # in a dict schema `name` and `required` keys are added + vol.Schema( + { + "section_test": KNXSectionFlat(), + vol.Optional("key"): selector.BooleanSelector(), + } + ), + [ + { + "name": "section_test", + "type": "knx_section_flat", + "required": False, + "collapsible": False, + }, + { + "name": "key", + "optional": True, + "required": False, + "type": "ha_selector", + "selector": {"boolean": {}}, + }, + ], + ), + ], +) +def test_serialization(schema: Any, serialized: dict[str, Any]) -> None: + """Test serialization of the selector.""" + assert convert(schema, custom_serializer=knx_serializer) == serialized diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 5c0f002a54102..bf8d88ab479a7 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -4,8 +4,13 @@ from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.knx.const import KNX_ADDRESS, KNX_MODULE_KEY +from homeassistant.components.knx.const import ( + KNX_ADDRESS, + KNX_MODULE_KEY, + SUPPORTED_PLATFORMS_UI, +) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY from homeassistant.components.knx.schema import SwitchSchema from homeassistant.const import CONF_NAME @@ -390,6 +395,22 @@ async def test_knx_subscribe_telegrams_command_project( assert res["event"]["timestamp"] is not None +@pytest.mark.parametrize("platform", sorted({*SUPPORTED_PLATFORMS_UI, "tts"})) +async def test_knx_get_schema( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + platform: str, +) -> None: + """Test knx/get_schema command returning proper schema data.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_schema", "platform": platform}) + res = await client.receive_json() + assert res == snapshot + + @pytest.mark.parametrize( "endpoint", [ From 158fb35c5bf34cfae6c814f9ac877a80f8e8e7c7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 22 Aug 2025 13:52:01 +0200 Subject: [PATCH 2/4] Add account reconfigure to Alexa Devices config flow (#149637) Co-authored-by: Josef Zweck --- .../components/alexa_devices/config_flow.py | 50 ++++++++++ .../alexa_devices/quality_scale.yaml | 2 +- .../components/alexa_devices/strings.json | 11 +++ .../alexa_devices/test_config_flow.py | 91 +++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index ca00d3e8250ca..052873f551db2 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -27,6 +27,12 @@ vol.Required(CONF_CODE): cv.string, } ) +STEP_RECONFIGURE = vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CODE): cv.string, + } +) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: @@ -124,3 +130,47 @@ async def async_step_reauth_confirm( data_schema=STEP_REAUTH_DATA_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the device.""" + reconfigure_entry = self._get_reconfigure_entry() + if not user_input: + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE, + ) + + updated_password = user_input[CONF_PASSWORD] + + self._async_abort_entries_match( + {CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]} + ) + + errors: dict[str, str] = {} + + try: + data = await validate_input( + self.hass, {**reconfigure_entry.data, **user_input} + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except CannotAuthenticate: + errors["base"] = "invalid_auth" + except CannotRetrieveData: + errors["base"] = "cannot_retrieve_data" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_PASSWORD: updated_password, + CONF_LOGIN_DATA: data, + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE, + errors=errors, + ) diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 5a2ff55b9b2a7..e2583b29e94a5 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 720b357d275dd..b1e9027ca536b 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -30,6 +30,16 @@ "password": "[%key:component::alexa_devices::common::data_description_password%]", "code": "[%key:component::alexa_devices::common::data_description_code%]" } + }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "code": "[%key:component::alexa_devices::common::data_code%]" + }, + "data_description": { + "password": "[%key:component::alexa_devices::common::data_description_password%]", + "code": "[%key:component::alexa_devices::common::data_description_code%]" + } } }, "abort": { @@ -37,6 +47,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index e4b0f8aa08733..9aea6fe4c44fd 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -208,3 +208,94 @@ async def test_reauth_not_successful( assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" assert mock_config_entry.data[CONF_CODE] == "111111" + + +async def test_reconfigure_successful( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the entry can be reconfigured.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # original entry + assert mock_config_entry.data[CONF_USERNAME] == TEST_USERNAME + + new_password = "new_fake_password" + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: new_password, + CONF_CODE: TEST_CODE, + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_PASSWORD] == new_password + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (CannotRetrieveData, "cannot_retrieve_data"), + ], +) +async def test_reconfigure_fails( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test that the host can be reconfigured.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_amazon_devices_client.login_mode_interactive.side_effect = side_effect + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + assert reconfigure_result["errors"] == {"base": error} + + mock_amazon_devices_client.login_mode_interactive.side_effect = None + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + }, + } From dd270f54fcdd63801935c1375c54c9bd9c8cd8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 22 Aug 2025 14:16:01 +0200 Subject: [PATCH 3/4] Delete Home Connect deprecated actions (#150929) --- .../components/home_connect/const.py | 7 - .../components/home_connect/services.py | 301 ------------------ .../components/home_connect/services.yaml | 96 ------ .../components/home_connect/strings.json | 121 ------- .../components/home_connect/test_services.py | 171 +--------- 5 files changed, 4 insertions(+), 692 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 64bf4af29a45e..c1aea03134fa5 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -61,19 +61,12 @@ BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" -SERVICE_OPTION_ACTIVE = "set_option_active" -SERVICE_OPTION_SELECTED = "set_option_selected" -SERVICE_PAUSE_PROGRAM = "pause_program" -SERVICE_RESUME_PROGRAM = "resume_program" -SERVICE_SELECT_PROGRAM = "select_program" SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options" SERVICE_SETTING = "change_setting" -SERVICE_START_PROGRAM = "start_program" ATTR_AFFECTS_TO = "affects_to" ATTR_KEY = "key" ATTR_PROGRAM = "program" -ATTR_UNIT = "unit" ATTR_VALUE = "value" AFFECTS_TO_ACTIVE_PROGRAM = "active_program" diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index 09c2f4a967de1..ca6eca4d9192d 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -8,7 +8,6 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfOptions, - CommandKey, Option, OptionKey, ProgramKey, @@ -21,7 +20,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import ( AFFECTS_TO_ACTIVE_PROGRAM, @@ -29,18 +27,11 @@ ATTR_AFFECTS_TO, ATTR_KEY, ATTR_PROGRAM, - ATTR_UNIT, ATTR_VALUE, DOMAIN, PROGRAM_ENUM_OPTIONS, - SERVICE_OPTION_ACTIVE, - SERVICE_OPTION_SELECTED, - SERVICE_PAUSE_PROGRAM, - SERVICE_RESUME_PROGRAM, - SERVICE_SELECT_PROGRAM, SERVICE_SET_PROGRAM_AND_OPTIONS, SERVICE_SETTING, - SERVICE_START_PROGRAM, TRANSLATION_KEYS_PROGRAMS_MAP, ) from .coordinator import HomeConnectConfigEntry @@ -88,43 +79,6 @@ } ) -# DEPRECATED: Remove in 2025.9.0 -SERVICE_OPTION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - vol.Optional(ATTR_UNIT): str, - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_PROGRAM_SCHEMA = vol.Any( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(int, str), - vol.Optional(ATTR_UNIT): str, - }, - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - }, -) - def _require_program_or_at_least_one_option(data: dict) -> dict: if ATTR_PROGRAM not in data and not any( @@ -216,205 +170,6 @@ async def _get_client_and_ha_id( return entry.runtime_data.client, ha_id -async def _async_service_program(call: ServiceCall, start: bool) -> None: - """Execute calls to services taking a program.""" - program = call.data[ATTR_PROGRAM] - client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) - - option_key = call.data.get(ATTR_KEY) - options = ( - [ - Option( - option_key, - call.data[ATTR_VALUE], - unit=call.data.get(ATTR_UNIT), - ) - ] - if option_key is not None - else None - ) - - async_create_issue( - call.hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_PROGRAM}: {program}", - *([f" {ATTR_KEY}: {options[0].key}"] if options else []), - *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), - *( - [f" {ATTR_UNIT}: {options[0].unit}"] - if options and options[0].unit - else [] - ), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", - f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", - *( - [ - f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" - ] - if options - else [] - ), - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - - try: - if start: - await client.start_program(ha_id, program_key=program, options=options) - else: - await client.set_selected_program( - ha_id, program_key=program, options=options - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="start_program" if start else "select_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "program": program, - }, - ) from err - - -async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None: - """Execute calls to services taking a program.""" - option_key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - unit = call.data.get(ATTR_UNIT) - client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - call.hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_KEY}: {option_key}", - f" {ATTR_VALUE}: {value}", - *([f" {ATTR_UNIT}: {unit}"] if unit else []), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", - f" {bsh_key_to_translation_key(option_key)}: {value}", - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - try: - if active: - await client.set_active_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - else: - await client.set_selected_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_options_active_program" - if active - else "set_options_selected_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "key": option_key, - "value": str(value), - }, - ) from err - - -async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None: - """Execute calls to services executing a command.""" - client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - call.hass, - DOMAIN, - "deprecated_command_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_command_actions", - ) - - try: - await client.put_command(ha_id, command_key=command_key, value=True) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="execute_command", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "command": command_key.value, - }, - ) from err - - -async def async_service_option_active(call: ServiceCall) -> None: - """Service for setting an option for an active program.""" - await _async_service_set_program_options(call, True) - - -async def async_service_option_selected(call: ServiceCall) -> None: - """Service for setting an option for a selected program.""" - await _async_service_set_program_options(call, False) - - async def async_service_setting(call: ServiceCall) -> None: """Service for changing a setting.""" key = call.data[ATTR_KEY] @@ -435,21 +190,6 @@ async def async_service_setting(call: ServiceCall) -> None: ) from err -async def async_service_pause_program(call: ServiceCall) -> None: - """Service for pausing a program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - - -async def async_service_resume_program(call: ServiceCall) -> None: - """Service for resuming a paused program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - - -async def async_service_select_program(call: ServiceCall) -> None: - """Service for selecting a program.""" - await _async_service_program(call, False) - - async def async_service_set_program_and_options(call: ServiceCall) -> None: """Service for setting a program and options.""" data = dict(call.data) @@ -517,54 +257,13 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None: ) from err -async def async_service_start_program(call: ServiceCall) -> None: - """Service for starting a program.""" - await _async_service_program(call, True) - - @callback def async_setup_services(hass: HomeAssistant) -> None: """Register custom actions.""" - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_ACTIVE, - async_service_option_active, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_SELECTED, - async_service_option_selected, - schema=SERVICE_OPTION_SCHEMA, - ) hass.services.async_register( DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA ) - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_PROGRAM, - async_service_pause_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - async_service_resume_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SELECT_PROGRAM, - async_service_select_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_START_PROGRAM, - async_service_start_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) hass.services.async_register( DOMAIN, SERVICE_SET_PROGRAM_AND_OPTIONS, diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index e07e8e9145716..a1e9d1217043c 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -1,51 +1,3 @@ -start_program: - fields: - device_id: - required: true - selector: - device: - integration: home_connect - program: - example: "Dishcare.Dishwasher.Program.Auto2" - required: true - selector: - text: - key: - example: "BSH.Common.Option.StartInRelative" - selector: - text: - value: - example: 1800 - selector: - object: - unit: - example: "seconds" - selector: - text: -select_program: - fields: - device_id: - required: true - selector: - device: - integration: home_connect - program: - example: "Dishcare.Dishwasher.Program.Auto2" - required: true - selector: - text: - key: - example: "BSH.Common.Option.StartInRelative" - selector: - text: - value: - example: 1800 - selector: - object: - unit: - example: "seconds" - selector: - text: set_program_and_options: fields: device_id: @@ -599,54 +551,6 @@ set_program_and_options: - laundry_care_common_enum_type_vario_perfect_off - laundry_care_common_enum_type_vario_perfect_eco_perfect - laundry_care_common_enum_type_vario_perfect_speed_perfect -pause_program: - fields: - device_id: - required: true - selector: - device: - integration: home_connect -resume_program: - fields: - device_id: - required: true - selector: - device: - integration: home_connect -set_option_active: - fields: - device_id: - required: true - selector: - device: - integration: home_connect - key: - example: "LaundryCare.Dryer.Option.DryingTarget" - required: true - selector: - text: - value: - example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" - required: true - selector: - object: -set_option_selected: - fields: - device_id: - required: true - selector: - device: - integration: home_connect - key: - example: "LaundryCare.Dryer.Option.DryingTarget" - required: true - selector: - text: - value: - example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" - required: true - selector: - object: change_setting: fields: device_id: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index fa24177a96782..1d3bffb78477a 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -145,28 +145,6 @@ } } } - }, - "deprecated_command_actions": { - "title": "The command related actions are deprecated in favor of the new buttons", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_command_actions::title%]", - "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." - } - } - } - }, - "deprecated_set_program_and_option_actions": { - "title": "The executed action is deprecated", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_set_program_and_option_actions::title%]", - "description": "`start_program`, `select_program`, `set_option_active`, and `set_option_selected` actions are deprecated and will be removed in the {remove_release} release, please use the `{new_action_key}` action instead. For the executed action:\n{deprecated_action_yaml}\nyou can do the following transformation using the recognized options:\n {new_action_yaml}\nIf the option is not in the recognized options, please submit an issue or a pull request requesting the addition of the option at {repo_link}." - } - } - } } }, "selector": { @@ -517,49 +495,6 @@ } }, "services": { - "start_program": { - "name": "Start program", - "description": "Selects a program and starts it.", - "fields": { - "device_id": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", - "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" - }, - "program": { "name": "Program", "description": "Program to select." }, - "key": { "name": "Option key", "description": "Key of the option." }, - "value": { - "name": "Option value", - "description": "Value of the option." - }, - "unit": { "name": "Option unit", "description": "Unit for the option." } - } - }, - "select_program": { - "name": "Select program", - "description": "Selects a program without starting it.", - "fields": { - "device_id": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", - "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" - }, - "program": { - "name": "[%key:component::home_connect::services::start_program::fields::program::name%]", - "description": "[%key:component::home_connect::services::start_program::fields::program::description%]" - }, - "key": { - "name": "[%key:component::home_connect::services::start_program::fields::key::name%]", - "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" - }, - "value": { - "name": "[%key:component::home_connect::services::start_program::fields::value::name%]", - "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" - }, - "unit": { - "name": "[%key:component::home_connect::services::start_program::fields::unit::name%]", - "description": "[%key:component::home_connect::services::start_program::fields::unit::description%]" - } - } - }, "set_program_and_options": { "name": "Set program and options", "description": "Starts or selects a program with options or sets the options for the active or the selected program.", @@ -744,62 +679,6 @@ } } }, - "pause_program": { - "name": "Pause program", - "description": "Pauses the current running program.", - "fields": { - "device_id": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", - "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" - } - } - }, - "resume_program": { - "name": "Resume program", - "description": "Resumes a paused program.", - "fields": { - "device_id": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", - "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" - } - } - }, - "set_option_active": { - "name": "Set active program option", - "description": "Sets an option for the active program.", - "fields": { - "device_id": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", - "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" - }, - "key": { - "name": "Key", - "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" - }, - "value": { - "name": "Value", - "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" - } - } - }, - "set_option_selected": { - "name": "Set selected program option", - "description": "Sets options for the selected program.", - "fields": { - "device_id": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", - "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" - }, - "key": { - "name": "[%key:component::home_connect::services::start_program::fields::key::name%]", - "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" - }, - "value": { - "name": "[%key:component::home_connect::services::start_program::fields::value::name%]", - "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" - } - } - }, "change_setting": { "name": "Change setting", "description": "Changes a setting.", diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py index 33a7f7aee7122..645ee1fb08cfa 100644 --- a/tests/components/home_connect/test_services.py +++ b/tests/components/home_connect/test_services.py @@ -1,11 +1,10 @@ """Tests for the Home Connect actions.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from typing import Any from unittest.mock import MagicMock -from aiohomeconnect.model import HomeAppliance, OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import HomeAppliance, SettingKey import pytest from syrupy.assertion import SnapshotAssertion @@ -14,37 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - -DEPRECATED_SERVICE_KV_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "set_option_active", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_option_selected", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, -] SERVICE_KV_CALL_PARAMS = [ - *DEPRECATED_SERVICE_KV_CALL_PARAMS, { "domain": DOMAIN, "service": "change_setting", @@ -57,70 +29,13 @@ }, ] -SERVICE_COMMAND_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "pause_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "resume_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, -] - - -SERVICE_PROGRAM_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "select_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "start_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, -] SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_active_program_option", - "set_option_selected": "set_selected_program_option", "change_setting": "set_setting", - "pause_program": "put_command", - "resume_program": "put_command", - "select_program": "set_selected_program", - "start_program": "start_program", } SERVICE_VALIDATION_ERROR_MAPPING = { - "set_option_active": r"Error.*setting.*options.*active.*program.*", - "set_option_selected": r"Error.*setting.*options.*selected.*program.*", "change_setting": r"Error.*assigning.*value.*setting.*", - "pause_program": r"Error.*executing.*command.*", - "resume_program": r"Error.*executing.*command.*", - "select_program": r"Error.*selecting.*program.*", - "start_program": r"Error.*starting.*program.*", } @@ -171,10 +86,7 @@ @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) +@pytest.mark.parametrize("service_call", SERVICE_KV_CALL_PARAMS) async def test_key_value_services( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -202,81 +114,6 @@ async def test_key_value_services( ) -@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) -@pytest.mark.parametrize( - ("service_call", "issue_id"), - [ - *zip( - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, - ["deprecated_set_program_and_option_actions"] - * ( - len(DEPRECATED_SERVICE_KV_CALL_PARAMS) - + len(SERVICE_PROGRAM_CALL_PARAMS) - ), - strict=True, - ), - *zip( - SERVICE_COMMAND_CALL_PARAMS, - ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), - strict=True, - ), - ], -) -async def test_programs_and_options_actions_deprecation( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - device_registry: dr.DeviceRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - appliance: HomeAppliance, - service_call: dict[str, Any], - issue_id: str, -) -> None: - """Test deprecated service keys.""" - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("service_call", "called_method"), @@ -360,7 +197,7 @@ async def test_set_program_and_options_exceptions( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + SERVICE_KV_CALL_PARAMS, ) async def test_services_exception_device_id( hass: HomeAssistant, @@ -430,7 +267,7 @@ async def test_services_appliance_not_found( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + SERVICE_KV_CALL_PARAMS, ) async def test_services_exception( hass: HomeAssistant, From 6c69b36b7b1e3dd0e1488489bd15117bc5eda889 Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 22 Aug 2025 23:06:23 +1000 Subject: [PATCH 4/4] Bump pysmlight to v0.2.8 (#151036) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 9340573f6ceba..32037b51ede97 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.2.7"], + "requirements": ["pysmlight==0.2.8"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index d524084aea808..3dfc131e9deee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2359,7 +2359,7 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.7 +pysmlight==0.2.8 # homeassistant.components.snmp pysnmp==7.1.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30eaacf2ec7d0..0201473921385 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1962,7 +1962,7 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.7 +pysmlight==0.2.8 # homeassistant.components.snmp pysnmp==7.1.21