From 9fd2ad425c5f11c3369d61361a6c39a6d2fca167 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 23 Jul 2025 07:22:48 +0200 Subject: [PATCH 1/7] Refactor KNX UI conditional selectors and migrate store data (#146067) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/knx/light.py | 89 ++++---- homeassistant/components/knx/manifest.json | 2 +- .../components/knx/storage/config_store.py | 19 +- homeassistant/components/knx/storage/const.py | 21 +- .../knx/storage/entity_store_schema.py | 195 ++++++++++-------- .../components/knx/storage/knx_selector.py | 26 +++ .../components/knx/storage/migration.py | 42 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/knx/conftest.py | 19 +- .../fixtures/config_store_binarysensor.json | 2 +- .../knx/fixtures/config_store_cover.json | 2 +- .../knx/fixtures/config_store_light.json | 142 +++++++++++++ .../fixtures/config_store_light_switch.json | 3 +- .../knx/fixtures/config_store_light_v1.json | 140 +++++++++++++ tests/components/knx/test_config_store.py | 48 +++++ tests/components/knx/test_light.py | 15 +- 17 files changed, 617 insertions(+), 152 deletions(-) create mode 100644 homeassistant/components/knx/storage/migration.py create mode 100644 tests/components/knx/fixtures/config_store_light.json create mode 100644 tests/components/knx/fixtures/config_store_light_v1.json diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index cbecb878e1272c..1ab6883a4371c7 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -33,6 +33,7 @@ from .knx_module import KNXModule from .schema import LightSchema from .storage.const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_ENTITY, @@ -223,7 +224,7 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value: color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE - color_dpt = conf.get_dpt(CONF_GA_COLOR) + color_dpt = conf.get_dpt(CONF_COLOR, CONF_GA_COLOR) return XknxLight( xknx, @@ -232,59 +233,77 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight group_address_switch_state=conf.get_state_and_passive(CONF_GA_SWITCH), group_address_brightness=conf.get_write(CONF_GA_BRIGHTNESS), group_address_brightness_state=conf.get_state_and_passive(CONF_GA_BRIGHTNESS), - group_address_color=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGB - else None, - group_address_color_state=conf.get_state_and_passive(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGB - else None, - group_address_rgbw=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGBW - else None, - group_address_rgbw_state=conf.get_state_and_passive(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGBW - else None, - group_address_hue=conf.get_write(CONF_GA_HUE), - group_address_hue_state=conf.get_state_and_passive(CONF_GA_HUE), - group_address_saturation=conf.get_write(CONF_GA_SATURATION), - group_address_saturation_state=conf.get_state_and_passive(CONF_GA_SATURATION), - group_address_xyy_color=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.XYY - else None, - group_address_xyy_color_state=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.XYY - else None, + group_address_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_rgbw=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_rgbw_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_hue=conf.get_write(CONF_COLOR, CONF_GA_HUE), + group_address_hue_state=conf.get_state_and_passive(CONF_COLOR, CONF_GA_HUE), + group_address_saturation=conf.get_write(CONF_COLOR, CONF_GA_SATURATION), + group_address_saturation_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_SATURATION + ), + group_address_xyy_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), + group_address_xyy_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), group_address_tunable_white=group_address_tunable_white, group_address_tunable_white_state=group_address_tunable_white_state, group_address_color_temperature=group_address_color_temp, group_address_color_temperature_state=group_address_color_temp_state, - group_address_switch_red=conf.get_write(CONF_GA_RED_SWITCH), - group_address_switch_red_state=conf.get_state_and_passive(CONF_GA_RED_SWITCH), - group_address_brightness_red=conf.get_write(CONF_GA_RED_BRIGHTNESS), + group_address_switch_red=conf.get_write(CONF_COLOR, CONF_GA_RED_SWITCH), + group_address_switch_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_SWITCH + ), + group_address_brightness_red=conf.get_write(CONF_COLOR, CONF_GA_RED_BRIGHTNESS), group_address_brightness_red_state=conf.get_state_and_passive( - CONF_GA_RED_BRIGHTNESS + CONF_COLOR, CONF_GA_RED_BRIGHTNESS ), - group_address_switch_green=conf.get_write(CONF_GA_GREEN_SWITCH), + group_address_switch_green=conf.get_write(CONF_COLOR, CONF_GA_GREEN_SWITCH), group_address_switch_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_SWITCH + CONF_COLOR, CONF_GA_GREEN_SWITCH ), group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), group_address_brightness_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_BRIGHTNESS + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS ), group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), group_address_brightness_blue_state=conf.get_state_and_passive( - CONF_GA_BLUE_BRIGHTNESS + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS ), - group_address_switch_white=conf.get_write(CONF_GA_WHITE_SWITCH), + group_address_switch_white=conf.get_write(CONF_COLOR, CONF_GA_WHITE_SWITCH), group_address_switch_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_SWITCH + CONF_COLOR, CONF_GA_WHITE_SWITCH + ), + group_address_brightness_white=conf.get_write( + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), - group_address_brightness_white=conf.get_write(CONF_GA_WHITE_BRIGHTNESS), group_address_brightness_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_BRIGHTNESS + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), color_temperature_type=color_temperature_type, min_kelvin=knx_config[CONF_COLOR_TEMP_MIN], diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index baa830bfaa47d6..5145d2d22f820f 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.4.1.91934" + "knx-frontend==2025.6.13.181749" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2899448a1288b2..2e93256de47dd1 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -13,10 +13,11 @@ from ..const import DOMAIN from .const import CONF_DATA +from .migration import migrate_1_to_2 _LOGGER = logging.getLogger(__name__) -STORAGE_VERSION: Final = 1 +STORAGE_VERSION: Final = 2 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -45,6 +46,20 @@ async def update_entity( """Update an existing entities configuration.""" +class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]): + """Storage handler for KNXConfigStore.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + # version 2 introduced in 2025.8 + migrate_1_to_2(old_data) + + return old_data + + class KNXConfigStore: """Manage KNX config store data.""" @@ -56,7 +71,7 @@ def __init__( """Initialize config store.""" self.hass = hass self.config_entry = config_entry - self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY) self.data = KNXConfigStoreModel(entities={}) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 7cae0e9bbf6822..78cd38c9d00509 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -2,6 +2,7 @@ from typing import Final +# Common CONF_DATA: Final = "data" CONF_ENTITY: Final = "entity" CONF_DEVICE_INFO: Final = "device_info" @@ -12,10 +13,22 @@ CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" -CONF_GA_COLOR_TEMP: Final = "ga_color_temp" + +# Cover +CONF_GA_UP_DOWN: Final = "ga_up_down" +CONF_GA_STOP: Final = "ga_stop" +CONF_GA_STEP: Final = "ga_step" +CONF_GA_POSITION_SET: Final = "ga_position_set" +CONF_GA_POSITION_STATE: Final = "ga_position_state" +CONF_GA_ANGLE: Final = "ga_angle" + +# Light CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MAX: Final = "color_temp_max" CONF_GA_BRIGHTNESS: Final = "ga_brightness" +CONF_GA_COLOR_TEMP: Final = "ga_color_temp" +# Light/color +CONF_COLOR: Final = "color" CONF_GA_COLOR: Final = "ga_color" CONF_GA_RED_BRIGHTNESS: Final = "ga_red_brightness" CONF_GA_RED_SWITCH: Final = "ga_red_switch" @@ -27,9 +40,3 @@ CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" -CONF_GA_UP_DOWN: Final = "ga_up_down" -CONF_GA_STOP: Final = "ga_stop" -CONF_GA_STEP: Final = "ga_step" -CONF_GA_POSITION_SET: Final = "ga_position_set" -CONF_GA_POSITION_STATE: Final = "ga_position_state" -CONF_GA_ANGLE: Final = "ga_angle" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 85bcbd1809f0ad..6c41a7d29e7b12 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -29,6 +29,7 @@ ) from ..validation import sync_state_validator from .const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_DATA, @@ -43,23 +44,20 @@ CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_SWITCH, CONF_GA_HUE, - CONF_GA_PASSIVE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SENSOR, - CONF_GA_STATE, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_SWITCH, CONF_GA_UP_DOWN, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, - CONF_GA_WRITE, ) -from .knx_selector import GASelector +from .knx_selector import GASelector, GroupSelect BASE_ENTITY_SCHEMA = vol.All( { @@ -87,24 +85,6 @@ ) -def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: - """Validate group address schema or remove key if no address is set.""" - # frontend will return {key: {"write": None, "state": None}} for unused GA sets - # -> remove this entirely for optional keys - # if one GA is set, validate as usual - return { - vol.Optional(key): ga_selector, - vol.Remove(key): vol.Schema( - { - vol.Optional(CONF_GA_WRITE): None, - vol.Optional(CONF_GA_STATE): None, - vol.Optional(CONF_GA_PASSIVE): vol.IsFalse(), # None or empty list - }, - extra=vol.ALLOW_EXTRA, - ), - } - - BINARY_SENSOR_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -134,16 +114,14 @@ def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: vol.Required(DOMAIN): vol.All( vol.Schema( { - **optional_ga_schema(CONF_GA_UP_DOWN, GASelector(state=False)), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_STOP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_STEP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_POSITION_SET, GASelector(state=False)), - **optional_ga_schema( - CONF_GA_POSITION_STATE, GASelector(write=False) - ), + 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(), - **optional_ga_schema(CONF_GA_ANGLE, GASelector()), + vol.Optional(CONF_GA_ANGLE): GASelector(), vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), vol.Optional( CoverConf.TRAVELLING_TIME_DOWN, default=25 @@ -208,73 +186,112 @@ class LightColorModeSchema(StrEnum): HSV = "hsv" -_LIGHT_COLOR_MODE_SCHEMA = "_light_color_mode_schema" +_hs_color_inclusion_msg = ( + "'Hue', 'Saturation' and 'Brightness' addresses are required for HSV configuration" +) -_COMMON_LIGHT_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - **optional_ga_schema( - CONF_GA_COLOR_TEMP, GASelector(write_required=True, dpt=ColorTempModes) + +LIGHT_KNX_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_GA_BRIGHTNESS): GASelector(write_required=True), + vol.Optional(CONF_GA_COLOR_TEMP): GASelector( + write_required=True, dpt=ColorTempModes + ), + vol.Optional(CONF_COLOR): GroupSelect( + vol.Schema( + { + vol.Optional(CONF_GA_COLOR): GASelector( + write_required=True, dpt=LightColorMode + ) + } + ), + vol.Schema( + { + vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_RED_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( + write_required=False + ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( + write_required=False + ), + } + ), + vol.Schema( + { + vol.Required(CONF_GA_HUE): GASelector(write_required=True), + vol.Required(CONF_GA_SATURATION): GASelector( + write_required=True + ), + } + ), + # 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.Any( + vol.Schema( + {vol.Required(CONF_GA_SWITCH): object}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Schema( # brightness addresses are required in INDIVIDUAL_COLOR_SCHEMA + {vol.Required(CONF_COLOR): {vol.Required(CONF_GA_RED_BRIGHTNESS): object}}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( - vol.Coerce(int), vol.Range(min=1) + msg="either 'address' or 'individual_colors' is required", + ), + vol.Any( + vol.Schema( # 'brightness' is non-optional for hs-color + { + vol.Required(CONF_GA_BRIGHTNESS, msg=_hs_color_inclusion_msg): object, + vol.Required(CONF_COLOR): { + vol.Required(CONF_GA_HUE, msg=_hs_color_inclusion_msg): object, + vol.Required( + CONF_GA_SATURATION, msg=_hs_color_inclusion_msg + ): object, + }, + }, + extra=vol.ALLOW_EXTRA, ), - }, - extra=vol.REMOVE_EXTRA, -) - -_DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.DEFAULT.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema( - CONF_GA_COLOR, - GASelector(write_required=True, dpt=LightColorMode), + vol.Schema( # hs-colors not used + { + vol.Optional(CONF_COLOR): { + vol.Optional(CONF_GA_HUE): None, + vol.Optional(CONF_GA_SATURATION): None, + }, + }, + extra=vol.ALLOW_EXTRA, ), - } -) - -_INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.INDIVIDUAL.value, - **optional_ga_schema(CONF_GA_SWITCH, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_RED_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_GREEN_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BLUE_SWITCH, GASelector(write_required=False)), - **optional_ga_schema(CONF_GA_WHITE_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_WHITE_SWITCH, GASelector(write_required=False)), - } -) - -_HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.HSV.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Required(CONF_GA_BRIGHTNESS): GASelector(write_required=True), - vol.Required(CONF_GA_HUE): GASelector(write_required=True), - vol.Required(CONF_GA_SATURATION): GASelector(write_required=True), - } + msg=_hs_color_inclusion_msg, + ), ) -LIGHT_KNX_SCHEMA = cv.key_value_schemas( - _LIGHT_COLOR_MODE_SCHEMA, - default_schema=_DEFAULT_LIGHT_SCHEMA, - value_schemas={ - LightColorModeSchema.DEFAULT: _DEFAULT_LIGHT_SCHEMA, - LightColorModeSchema.INDIVIDUAL: _INDIVIDUAL_LIGHT_SCHEMA, - LightColorModeSchema.HSV: _HSV_LIGHT_SCHEMA, - }, -) - LIGHT_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index a1510dbb384aaa..fe909f1fd0a384 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -1,5 +1,6 @@ """Selectors for KNX.""" +from collections.abc import Hashable, Iterable from enum import Enum from typing import Any @@ -9,6 +10,31 @@ from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE +class GroupSelect(vol.Any): + """Use the first validated value. + + This is a version of vol.Any with custom error handling to + show proper invalid markers for sub-schema items in the UI. + """ + + def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> Any: + """Execute the validation functions.""" + errors: list[vol.Invalid] = [] + for func in funcs: + try: + if path is None: + return func(v) + return func(path, v) + except vol.Invalid as e: + errors.append(e) + if errors: + raise next( + (err for err in errors if "extra keys not allowed" not in err.msg), + errors[0], + ) + raise vol.AnyInvalid(self.msg or "no valid value found", path=path) + + class GASelector: """Selector for a KNX group address structure.""" diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py new file mode 100644 index 00000000000000..f7d7941e5cc108 --- /dev/null +++ b/homeassistant/components/knx/storage/migration.py @@ -0,0 +1,42 @@ +"""Migration functions for KNX config store schema.""" + +from typing import Any + +from homeassistant.const import Platform + +from . import const as store_const + + +def migrate_1_to_2(data: dict[str, Any]) -> None: + """Migrate from schema 1 to schema 2.""" + if lights := data.get("entities", {}).get(Platform.LIGHT): + for light in lights.values(): + _migrate_light_schema_1_to_2(light["knx"]) + + +def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None: + """Migrate light color mode schema.""" + # Remove no more needed helper data from schema + light_knx_data.pop("_light_color_mode_schema", None) + + # Move color related group addresses to new "color" key + color: dict[str, Any] = {} + for color_key in ( + # optional / required and exclusive keys are the same in old and new schema + store_const.CONF_GA_COLOR, + store_const.CONF_GA_HUE, + store_const.CONF_GA_SATURATION, + store_const.CONF_GA_RED_BRIGHTNESS, + store_const.CONF_GA_RED_SWITCH, + store_const.CONF_GA_GREEN_BRIGHTNESS, + store_const.CONF_GA_GREEN_SWITCH, + store_const.CONF_GA_BLUE_BRIGHTNESS, + store_const.CONF_GA_BLUE_SWITCH, + store_const.CONF_GA_WHITE_BRIGHTNESS, + store_const.CONF_GA_WHITE_SWITCH, + ): + if color_key in light_knx_data: + color[color_key] = light_knx_data.pop(color_key) + + if color: + light_knx_data[store_const.CONF_COLOR] = color diff --git a/requirements_all.txt b/requirements_all.txt index 8544d2125e2f77..c6b57127837860 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1304,7 +1304,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.6.13.181749 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 097e7cbbea11de..20500240a5e930 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1126,7 +1126,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.6.13.181749 # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 32f7745a6e038f..576fce802c09e2 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -76,6 +76,7 @@ async def setup_integration( yaml_config: ConfigType | None = None, config_store_fixture: str | None = None, add_entry_to_hass: bool = True, + state_updater: bool = True, ) -> None: """Create the KNX integration.""" @@ -118,14 +119,24 @@ def fish_xknx(*args, **kwargs): self.mock_config_entry.add_to_hass(self.hass) knx_config = {DOMAIN: yaml_config or {}} - with patch( - "xknx.xknx.knx_interface_factory", - return_value=knx_ip_interface_mock(), - side_effect=fish_xknx, + with ( + patch( + "xknx.xknx.knx_interface_factory", + return_value=knx_ip_interface_mock(), + side_effect=fish_xknx, + ), ): + state_updater_patcher = patch( + "xknx.xknx.StateUpdater.register_remote_value" + ) + if not state_updater: + state_updater_patcher.start() + await async_setup_component(self.hass, DOMAIN, knx_config) await self.hass.async_block_till_done() + state_updater_patcher.stop() + ######################## # Telegram counter tests ######################## diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 427867cff8c635..2b6e5887f9e92b 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_cover.json b/tests/components/knx/fixtures/config_store_cover.json index 6ec8dcc90fa138..8f89a4ee47b6bd 100644 --- a/tests/components/knx/fixtures/config_store_cover.json +++ b/tests/components/knx/fixtures/config_store_cover.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json new file mode 100644 index 00000000000000..61ec1044746e31 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light.json @@ -0,0 +1,142 @@ +{ + "version": 2, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + } + } + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store_light_switch.json b/tests/components/knx/fixtures/config_store_light_switch.json index 5eabcfa87f9f37..0b14535bbea417 100644 --- a/tests/components/knx/fixtures/config_store_light_switch.json +++ b/tests/components/knx/fixtures/config_store_light_switch.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { @@ -33,7 +33,6 @@ "knx": { "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/21", "state": "1/0/21", diff --git a/tests/components/knx/fixtures/config_store_light_v1.json b/tests/components/knx/fixtures/config_store_light_v1.json new file mode 100644 index 00000000000000..3e049e145f217e --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light_v1.json @@ -0,0 +1,140 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "individual", + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "hsv", + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index aee0a4036ff9b9..3e902f8f402752 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -14,6 +14,7 @@ from . import KnxEntityGenerator from .conftest import KNXTestKit +from tests.common import async_load_json_object_fixture from tests.typing import WebSocketGenerator @@ -379,6 +380,7 @@ async def test_validate_entity( await knx.setup_integration() client = await hass_ws_client(hass) + # valid data await client.send_json_auto_id( { "type": "knx/validate_entity", @@ -410,3 +412,49 @@ async def test_validate_entity( assert res["result"]["errors"][0]["path"] == ["data", "knx", "ga_switch", "write"] assert res["result"]["errors"][0]["error_message"] == "required key not provided" assert res["result"]["error_base"].startswith("required key not provided") + + # invalid group_select data + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.LIGHT, + "data": { + "entity": {"name": "test_name"}, + "knx": { + "color": { + "ga_red_brightness": {"write": "1/2/3"}, + "ga_green_brightness": {"write": "1/2/4"}, + # ga_blue_brightness is missing - which is required + } + }, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + # This shall test that a required key of the second GroupSelect schema is missing + # and not yield the "extra keys not allowed" error of the first GroupSelect Schema + assert res["result"]["errors"][0]["path"] == [ + "data", + "knx", + "color", + "ga_blue_brightness", + ] + assert res["result"]["errors"][0]["error_message"] == "required key not provided" + assert res["result"]["error_base"].startswith("required key not provided") + + +async def test_migration_1_to_2( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +) -> None: + """Test migration from schema 1 to schema 2.""" + await knx.setup_integration( + config_store_fixture="config_store_light_v1.json", state_updater=False + ) + new_data = await async_load_json_object_fixture( + hass, "config_store_light.json", "knx" + ) + assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index fb0246763a4082..5edf150ef4f84a 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1182,7 +1182,6 @@ async def test_light_ui_create( entity_data={"name": "test"}, knx_data={ "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1223,7 +1222,6 @@ async def test_light_ui_color_temp( "write": "3/3/3", "dpt": color_temp_mode, }, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1257,7 +1255,6 @@ async def test_light_ui_multi_mode( knx_data={ "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/1", "passive": [], @@ -1275,11 +1272,13 @@ async def test_light_ui_multi_mode( "state": "0/6/3", "passive": [], }, - "ga_color": { - "write": "0/6/4", - "dpt": "251.600", - "state": "0/6/5", - "passive": [], + "color": { + "ga_color": { + "write": "0/6/4", + "dpt": "251.600", + "state": "0/6/5", + "passive": [], + }, }, }, ) From 5f2f0386093f3513300d85f097089207ec8edbca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jul 2025 20:14:41 -1000 Subject: [PATCH 2/7] Bump dbus-fast to 2.44.2 (#149281) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index cf3ee8e0db9ef8..3b1e6e70ff6ab8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", - "dbus-fast==2.43.0", + "dbus-fast==2.44.2", "habluetooth==4.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa0e1768d524c1..9f0e0408efd7a7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==45.0.3 -dbus-fast==2.43.0 +dbus-fast==2.44.2 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index c6b57127837860..479e5598aaf2f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -762,7 +762,7 @@ datadog==0.15.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.2 # homeassistant.components.debugpy debugpy==1.8.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20500240a5e930..eae65039b8f205 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -665,7 +665,7 @@ datadog==0.15.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.2 # homeassistant.components.debugpy debugpy==1.8.14 From 40571dff3d4f41b440b230b45039fd6a66927cb4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 09:33:27 +0200 Subject: [PATCH 3/7] Replace typo "effect" with "affect" in `insteon` (#149292) --- homeassistant/components/insteon/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 3a15d667ca7901..dedbc9c4fa902a 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -18,16 +18,16 @@ } }, "hubv1": { - "title": "Insteon Hub Version 1", - "description": "Configure the Insteon Hub Version 1 (pre-2014).", + "title": "Insteon Hub version 1", + "description": "Configure the Insteon Hub version 1 (pre-2014).", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" } }, "hubv2": { - "title": "Insteon Hub Version 2", - "description": "Configure the Insteon Hub Version 2.", + "title": "Insteon Hub version 2", + "description": "Configure the Insteon Hub version 2.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", @@ -144,7 +144,7 @@ }, "reload": { "name": "[%key:common::action::reload%]", - "description": "If enabled, all current records are cleared from memory (does not effect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." + "description": "If enabled, all current records are cleared from memory (does not affect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." } } }, From a5ab52301494aa152674cf974a5831c3296f78c1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 10:03:04 +0200 Subject: [PATCH 4/7] Fix sentence-casing in `tomorrowio` (#149293) --- homeassistant/components/tomorrowio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index c3f52155d2980b..033b338f1a4c3e 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -23,10 +23,10 @@ "options": { "step": { "init": { - "title": "Update Tomorrow.io Options", + "title": "Update Tomorrow.io options", "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", "data": { - "timestep": "Min. Between NowCast Forecasts" + "timestep": "Minutes between NowCast forecasts" } } } From 9a6ba225e4887d7f7871d5a49334f05452e65fbf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 10:33:31 +0200 Subject: [PATCH 5/7] Fix typo "paela" in `miele` (#149295) --- homeassistant/components/miele/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 865f3313ad5e65..18893c238fe641 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -698,7 +698,7 @@ "parsnip_cut_into_batons": "Parsnip (cut into batons)", "parsnip_diced": "Parsnip (diced)", "parsnip_sliced": "Parsnip (sliced)", - "pasta_paela": "Pasta/Paela", + "pasta_paela": "Pasta/paella", "pears_halved": "Pears (halved)", "pears_quartered": "Pears (quartered)", "pears_to_cook_large_halved": "Pears to cook (large, halved)", From 51a46a128c4544a2fc78904e617615a7e67cddda Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:46:52 +0200 Subject: [PATCH 6/7] Begin migrating unifiprotect to use the public API (#149126) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/__init__.py | 43 ++++- .../components/unifiprotect/config_flow.py | 20 +++ .../components/unifiprotect/const.py | 2 +- .../components/unifiprotect/strings.json | 30 +++- .../components/unifiprotect/utils.py | 3 + tests/components/unifiprotect/conftest.py | 2 + .../unifiprotect/fixtures/sample_nvr.json | 2 +- .../unifiprotect/test_config_flow.py | 144 +++++++++++++-- tests/components/unifiprotect/test_init.py | 165 ++++++++++++++++++ .../unifiprotect/test_media_source.py | 1 + 10 files changed, 380 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 440250d45a39e7..5fa9a85d3418b6 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -16,9 +16,13 @@ from uiprotect.test_util.anonymize import anonymize_data # noqa: F401 from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -33,7 +37,6 @@ DEVICES_THAT_ADOPT, DOMAIN, MIN_REQUIRED_PROTECT_V, - OUTDATED_LOG_MESSAGE, PLATFORMS, ) from .data import ProtectData, UFPConfigEntry @@ -69,6 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" + protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") @@ -89,6 +93,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: bootstrap = protect.bootstrap nvr_info = bootstrap.nvr auth_user = bootstrap.users.get(bootstrap.auth_user_id) + + # Check if API key is missing + if not protect.is_api_key_set() and auth_user and nvr_info.can_write(auth_user): + try: + new_api_key = await protect.create_api_key( + name=f"Home Assistant ({hass.config.location_name})" + ) + except NotAuthorized as err: + _LOGGER.error("Failed to create API key: %s", err) + else: + protect.set_api_key(new_api_key) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_API_KEY: new_api_key} + ) + + if not protect.is_api_key_set(): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_key_required", + ) + if auth_user and auth_user.cloud_account: ir.async_create_issue( hass, @@ -103,12 +128,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: ) if nvr_info.version < MIN_REQUIRED_PROTECT_V: - _LOGGER.error( - OUTDATED_LOG_MESSAGE, - nvr_info.version, - MIN_REQUIRED_PROTECT_V, + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="protect_version", + translation_placeholders={ + "current_version": str(nvr_info.version), + "min_version": str(MIN_REQUIRED_PROTECT_V), + }, ) - return False if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index c83b3f110105ef..0eab326d609d26 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -23,6 +23,7 @@ OptionsFlowWithReload, ) from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_ID, CONF_PASSWORD, @@ -214,6 +215,7 @@ async def async_step_discovery_confirm( CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -247,6 +249,7 @@ async def _async_get_nvr_data( session = async_create_clientsession( self.hass, cookie_jar=CookieJar(unsafe=True) ) + public_api_session = async_get_clientsession(self.hass) host = user_input[CONF_HOST] port = user_input.get(CONF_PORT, DEFAULT_PORT) @@ -254,10 +257,12 @@ async def _async_get_nvr_data( protect = ProtectApiClient( session=session, + public_api_session=public_api_session, host=host, port=port, username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], + api_key=user_input[CONF_API_KEY], verify_ssl=verify_ssl, cache_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), config_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), @@ -286,6 +291,14 @@ async def _async_get_nvr_data( auth_user = bootstrap.users.get(bootstrap.auth_user_id) if auth_user and auth_user.cloud_account: errors["base"] = "cloud_user" + try: + await protect.get_meta_info() + except NotAuthorized as ex: + _LOGGER.debug(ex) + errors[CONF_API_KEY] = "invalid_auth" + except ClientError as ex: + _LOGGER.error(ex) + errors["base"] = "cannot_connect" return nvr_data, errors @@ -318,12 +331,18 @@ async def async_step_reauth_confirm( } return self.async_show_form( step_id="reauth_confirm", + description_placeholders={ + "local_user_documentation_url": await async_local_user_documentation_url( + self.hass + ), + }, data_schema=vol.Schema( { vol.Required( CONF_USERNAME, default=form_data.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -366,6 +385,7 @@ async def async_step_user( CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index d041b71312576c..f7138c24341ef2 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -52,7 +52,7 @@ DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} -MIN_REQUIRED_PROTECT_V = Version("1.20.0") +MIN_REQUIRED_PROTECT_V = Version("6.0.0") OUTDATED_LOG_MESSAGE = ( "You are running v%s of UniFi Protect. Minimum required version is v%s. Please" " upgrade UniFi Protect and then retry" diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 23c662f5d711aa..f20b56d29e44e1 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -10,19 +10,27 @@ "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "host": "Hostname or IP address of your UniFi Protect device." + "host": "Hostname or IP address of your UniFi Protect device.", + "api_key": "API key for your local user account." } }, "reauth_confirm": { "title": "UniFi Protect reauth", + "description": "Your credentials or API key seem to be missing or invalid. For instructions on how to create a local user or generate a new API key, please refer to the documentation: {local_user_documentation_url}", "data": { "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account.", + "username": "Username for your local (not cloud) user account." } }, "discovery_confirm": { @@ -30,14 +38,18 @@ "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account." } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", + "protect_version": "Minimum required version is v6.0.0. Please upgrade UniFi Protect and then retry.", "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { @@ -669,5 +681,13 @@ } } } + }, + "exceptions": { + "api_key_required": { + "message": "API key is required. Please reauthenticate this integration to provide an API key." + }, + "protect_version": { + "message": "Your UniFi Protect version ({current_version}) is too old. Minimum required: {min_version}." + } } } diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 61314346d32344..9071a24eae668b 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -110,13 +110,16 @@ def async_create_api_client( """Create ProtectApiClient from config entry.""" session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + public_api_session = async_create_clientsession(hass) return ProtectApiClient( host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], + api_key=entry.data.get("api_key"), verify_ssl=entry.data[CONF_VERIFY_SSL], session=session, + public_api_session=public_api_session, subscribed_models=DEVICES_FOR_SUBSCRIBE, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index c49ade514bc5bd..895ba62f81a6f4 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -32,6 +32,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -68,6 +69,7 @@ def mock_ufp_config_entry(): "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 13e93a8c2e748f..dc841ab7a1e45b 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -5,7 +5,7 @@ "canAutoUpdate": true, "isStatsGatheringEnabled": true, "timezone": "America/New_York", - "version": "2.2.6", + "version": "6.0.0", "ucoreVersion": "2.3.26", "firmwareVersion": "2.3.10", "uiVersion": null, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 880578719cdc1c..a5cda887b4db79 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -74,6 +74,10 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -89,6 +93,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -99,6 +104,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -116,9 +122,15 @@ async def test_form_version_too_old( ) bootstrap.nvr = old_nvr - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -126,6 +138,7 @@ async def test_form_version_too_old( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -133,15 +146,21 @@ async def test_form_version_too_old( assert result2["errors"] == {"base": "protect_version"} -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +async def test_form_invalid_auth_password(hass: HomeAssistant) -> None: + """Test we handle invalid auth password.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -149,6 +168,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -156,6 +176,38 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: assert result2["errors"] == {"password": "invalid_auth"} +async def test_form_invalid_auth_api_key( + hass: HomeAssistant, bootstrap: Bootstrap +) -> None: + """Test we handle invalid auth api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NotAuthorized, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"api_key": "invalid_auth"} + + async def test_form_cloud_user( hass: HomeAssistant, bootstrap: Bootstrap, cloud_account: CloudAccount ) -> None: @@ -167,9 +219,15 @@ async def test_form_cloud_user( user = bootstrap.users[bootstrap.auth_user_id] user.cloud_account = cloud_account bootstrap.users[bootstrap.auth_user_id] = user - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -177,6 +235,7 @@ async def test_form_cloud_user( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -190,9 +249,15 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NvrError, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NvrError, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NvrError, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -200,6 +265,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -217,6 +283,7 @@ async def test_form_reauth_auth( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -234,15 +301,22 @@ async def test_form_reauth_auth( "name": "Mock Title", } - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -260,12 +334,17 @@ async def test_form_reauth_auth( "homeassistant.components.unifiprotect.async_setup", return_value=True, ) as mock_setup, + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { "username": "test-username", "password": "new-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -283,6 +362,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -383,6 +463,10 @@ async def test_discovered_by_unifi_discovery_direct_connect( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -397,6 +481,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -407,6 +492,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -425,6 +511,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -583,6 +670,10 @@ async def test_discovered_by_unifi_discovery( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", side_effect=[NotAuthorized, bootstrap], ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -597,6 +688,7 @@ async def test_discovered_by_unifi_discovery( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -607,6 +699,7 @@ async def test_discovered_by_unifi_discovery( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -644,6 +737,10 @@ async def test_discovered_by_unifi_discovery_partial( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -658,6 +755,7 @@ async def test_discovered_by_unifi_discovery_partial( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -668,6 +766,7 @@ async def test_discovered_by_unifi_discovery_partial( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -686,6 +785,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -716,6 +816,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "127.0.0.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -746,6 +847,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -787,6 +889,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -827,6 +930,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -841,6 +948,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -851,6 +959,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "nomatchsameip.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -869,6 +978,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 3156327f1a5079..b951d95fbdc9fa 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -18,6 +18,7 @@ async_ufp_instance_for_config_entry_ids, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -29,6 +30,19 @@ from tests.typing import WebSocketGenerator +@pytest.fixture +def mock_user_can_write_nvr(request: pytest.FixtureRequest, ufp: MockUFPFixture): + """Fixture to mock can_write method on NVR objects with indirect parametrization.""" + can_write_result = getattr(request, "param", True) + original_can_write = ufp.api.bootstrap.nvr.can_write + mock_can_write = Mock(return_value=can_write_result) + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", mock_can_write) + try: + yield mock_can_write + finally: + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", original_can_write) + + async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture) -> None: """Test working setup of unifiprotect entry.""" @@ -68,6 +82,7 @@ async def test_setup_multiple( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -331,6 +346,112 @@ async def test_async_ufp_instance_for_config_entry_ids( assert result == expected_result +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_creates_api_key_when_missing( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key is created when missing and user has write permissions.""" + # Setup: API key is not set initially, user has write permissions + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") + + # Mock set_api_key to update is_api_key_set return value when called + def set_api_key_side_effect(key): + ufp.api.is_api_key_set.return_value = True + + ufp.api.set_api_key.side_effect = set_api_key_side_effect + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify API key was created and set + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") + + # Verify config entry was updated with new API key + assert ufp.entry.data[CONF_API_KEY] == "new-api-key-123" + assert ufp.entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [False], indirect=True) +async def test_setup_skips_api_key_creation_when_no_write_permission( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key creation is skipped when user has no write permissions.""" + # Setup: API key is not set, user has no write permissions + ufp.api.is_api_key_set.return_value = False + + # Should fail with auth error since no API key and can't create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_handles_api_key_creation_failure( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling of API key creation failure.""" + # Setup: API key is not set, user has write permissions, but creation fails + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock( + side_effect=NotAuthorized("Failed to create API key") + ) + + # Should fail with auth error due to API key creation failure + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted but set_api_key was not called + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_not_called() + + +async def test_setup_with_existing_api_key( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test setup when API key is already set.""" + # Setup: API key is already set + ufp.api.is_api_key_set.return_value = True + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.LOADED + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_api_key_creation_returns_none( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling when API key creation returns None.""" + # Setup: API key is not set, creation returns None (empty response) + # set_api_key will be called with None but is_api_key_set will still be False + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value=None) + + # Should fail with auth error since API key creation returned None + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted and set_api_key was called with None + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with(None) + + async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: """Test remove CONF_ALLOW_EA from options while migrating a 1 config entry to 2.""" with ( @@ -350,3 +471,47 @@ async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: assert entry.version == 2 assert entry.options.get(CONF_ALLOW_EA) is None assert entry.unique_id == "123456" + + +async def test_setup_skips_api_key_creation_when_no_auth_user( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test that API key creation is skipped when auth_user is None.""" + # Setup: API key is not set, auth_user is None + ufp.api.is_api_key_set.return_value = False + + # Mock the users dictionary to return None for any user ID + with patch.dict(ufp.api.bootstrap.users, {}, clear=True): + # Should fail with auth error since no API key and no auth user to create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_fails_when_api_key_still_missing_after_creation( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that setup fails when API key is still missing after creation attempts.""" + # Setup: API key is not set and remains not set even after attempts + ufp.api.is_api_key_set.return_value = False # type: ignore[attr-defined] + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") # type: ignore[method-assign] + ufp.api.set_api_key = Mock() # type: ignore[method-assign] # Mock this but API key still won't be "set" + + # Setup should fail since API key is still not set after creation + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify entry is in setup error state (which will trigger reauth automatically) + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted + ufp.api.create_api_key.assert_called_once_with( # type: ignore[attr-defined] + name="Home Assistant (test home)" + ) + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") # type: ignore[attr-defined] diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 61f9680bdbcb48..02d07bb1d4d20a 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -234,6 +234,7 @@ async def test_browse_media_root_multiple_consoles( "host": "1.1.1.2", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect2", "port": 443, "verify_ssl": False, From c4d742f549bf44c60f7e60edd8b892041868bd0b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 11:01:19 +0200 Subject: [PATCH 7/7] Add missing hyphen to "auto-renew period" in `whois` (#149296) --- homeassistant/components/whois/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index b236bb06208d08..814b952d417faf 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -52,7 +52,7 @@ "name": "Status", "state": { "add_period": "Add period", - "auto_renew_period": "Auto renew period", + "auto_renew_period": "Auto-renew period", "inactive": "Inactive", "ok": "Active", "active": "Active",