diff --git a/homeassistant/components/gatus/sensor.py b/homeassistant/components/gatus/sensor.py index e76a6d21d75275..a04022ce8081cd 100644 --- a/homeassistant/components/gatus/sensor.py +++ b/homeassistant/components/gatus/sensor.py @@ -33,6 +33,16 @@ class GatusSensorEntityDescription(SensorEntityDescription): ] +DNS_RCODE_MAP = { + "NOERROR": "no_error", + "FORMERR": "format_error", + "SERVFAIL": "server_failure", + "NXDOMAIN": "non_existent_domain", + "NOTIMP": "not_implemented", + "REFUSED": "refused", +} + + SENSOR_TYPES: tuple[GatusSensorEntityDescription, ...] = ( GatusSensorEntityDescription( key="response_time", @@ -79,6 +89,19 @@ class GatusSensorEntityDescription(SensorEntityDescription): else None ), ), + GatusSensorEntityDescription( + key="dns_rcode", + translation_key="dns_rcode", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator, endpoint: ( + DNS_RCODE_MAP.get( + endpoint.results[-1].dns_rcode, + endpoint.results[-1].dns_rcode.lower(), + ) + if endpoint.results and endpoint.results[-1].dns_rcode is not None + else None + ), + ), ) @@ -94,9 +117,16 @@ async def async_setup_entry( GatusEndpointSensor(coordinator, entry, endpoint_key, description) for endpoint_key, endpoint in coordinator.data.items() for description in SENSOR_TYPES - if description.key != "certificate_expiration" - or ( - endpoint.results and endpoint.results[-1].certificate_expiration is not None + if ( + description.key != "certificate_expiration" + or ( + endpoint.results + and endpoint.results[-1].certificate_expiration is not None + ) + ) + and ( + description.key != "dns_rcode" + or (endpoint.results and endpoint.results[-1].dns_rcode is not None) ) ) diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json index f9b79d99244e11..b4f0ba34e3198e 100644 --- a/homeassistant/components/gatus/strings.json +++ b/homeassistant/components/gatus/strings.json @@ -61,6 +61,17 @@ "certificate_expiration": { "name": "Certificate expiration" }, + "dns_rcode": { + "name": "DNS response code", + "state": { + "format_error": "Format error", + "no_error": "No error", + "non_existent_domain": "Non-existent domain", + "not_implemented": "Not implemented", + "refused": "Refused", + "server_failure": "Server failure" + } + }, "last_event": { "name": "Last event", "state": { diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 3d322f54882b04..84d2ba3a035a5e 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -146,6 +146,9 @@ "light_sensor_enabled": { "name": "Light sensor enabled" }, + "motion_aware": { + "name": "MotionAware" + }, "motion_sensor_enabled": { "name": "Motion sensor enabled" } diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index 31dc261748e614..caf4ca0008c6e3 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -4,7 +4,12 @@ from typing import Any, override from aiohue.v2 import HueBridgeV2 -from aiohue.v2.controllers.config import BehaviorInstance, BehaviorInstanceController +from aiohue.v2.controllers.config import ( + BehaviorInstance, + BehaviorInstanceController, + MotionAreaConfiguration, + MotionAreaConfigurationController, +) from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( LightLevel, @@ -23,9 +28,10 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueConfigEntry +from .bridge import HueBridge, HueConfigEntry from .const import DOMAIN from .v2.entity import HueBaseEntity @@ -47,17 +53,20 @@ async def async_setup_entry( def register_items( controller: BehaviorInstanceController | LightLevelController + | MotionAreaConfigurationController | MotionController, switch_class: type[ HueBehaviorInstanceEnabledEntity | HueLightSensorEnabledEntity + | HueMotionAreaConfigurationEnabledEntity | HueMotionSensorEnabledEntity ], resource_filter: Callable[[Any], bool] | None = None, ): @callback def async_add_entity( - event_type: EventType, resource: BehaviorInstance | LightLevel | Motion + event_type: EventType, + resource: BehaviorInstance | LightLevel | MotionAreaConfiguration | Motion, ) -> None: """Add entity from Hue resource.""" if resource_filter is not None and not resource_filter(resource): @@ -107,13 +116,21 @@ def is_user_automation(resource: BehaviorInstance) -> bool: HueBehaviorInstanceEnabledEntity, is_user_automation, ) + register_items( + api.config.motion_area_configuration, HueMotionAreaConfigurationEnabledEntity + ) class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): """Represent a Switch entity from a Hue resource that toggles.""" - controller: BehaviorInstanceController | LightLevelController | MotionController - resource: BehaviorInstance | LightLevel | Motion + controller: ( + BehaviorInstanceController + | LightLevelController + | MotionAreaConfigurationController + | MotionController + ) + resource: BehaviorInstance | LightLevel | MotionAreaConfiguration | Motion entity_description = SwitchEntityDescription( key="sensing_service_enabled", @@ -190,6 +207,35 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self.bridge.async_request_call(self.controller.stop, self.resource.id) +class HueMotionAreaConfigurationEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity to enable/disable a Hue MotionAware zone.""" + + controller: MotionAreaConfigurationController + resource: MotionAreaConfiguration + + entity_description = SwitchEntityDescription( + key="motion_area_configuration", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + translation_key="motion_aware", + ) + + def __init__( + self, + bridge: HueBridge, + controller: MotionAreaConfigurationController, + resource: MotionAreaConfiguration, + ) -> None: + """Initialize the switch.""" + super().__init__(bridge, controller, resource) + # link the switch to the group the MotionAware zone is associated with + self.hue_group = controller.get_group(resource.id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.hue_group.id)}, + ) + + class HueMotionSensorEnabledEntity(HueResourceEnabledEntity): """Representation of a Switch entity to enable/disable a Hue motion sensor.""" diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 85905aed7af93d..126a74bd3cea70 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==9.0.0"] + "requirements": ["infrared-protocols==9.1.0"] } diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 1fcaa7d6a30ae6..e76b5d3772cbc4 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -68,9 +68,7 @@ async def async_setup_entry( KnxYamlBinarySensor(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get( - Platform.BINARY_SENSOR - ): + if ui_config := knx_module.config_store.get_entity_configs(Platform.BINARY_SENSOR): entities.extend( KnxUiBinarySensor(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index ae29fc8fc4d29b..1ed7978ba5b655 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -49,7 +49,7 @@ async def async_setup_entry( KnxYamlButton(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.BUTTON): + if ui_config := knx_module.config_store.get_entity_configs(Platform.BUTTON): entities.extend( KnxUiButton(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 543f7196fb68b7..37b6f0aeabd5dc 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -103,7 +103,7 @@ async def async_setup_entry( KnxYamlClimate(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.CLIMATE): + if ui_config := knx_module.config_store.get_entity_configs(Platform.CLIMATE): entities.extend( KnxUiClimate(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 54b3df8805e9c0..66bbcc850f4bcb 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -124,6 +124,7 @@ SERVICE_KNX_READ: Final = "read" REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue" +REPAIR_ISSUE_ENTITY_VALIDATION_ERROR: Final = "entity_validation_error" REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR: Final = "telegram_backend_error" diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 22fee83012772b..9270e4e2b8ce72 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -74,7 +74,7 @@ async def async_setup_entry( KnxYamlCover(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.COVER): + if ui_config := knx_module.config_store.get_entity_configs(Platform.COVER): entities.extend( KnxUiCover(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index 9fa40c87b14b53..e6996d570a9a3f 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -59,7 +59,7 @@ async def async_setup_entry( KnxYamlDate(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.DATE): + if ui_config := knx_module.config_store.get_entity_configs(Platform.DATE): entities.extend( KnxUiDate(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index f73735e635bcc8..0b7964cd61c30e 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -60,7 +60,7 @@ async def async_setup_entry( KnxYamlDateTime(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.DATETIME): + if ui_config := knx_module.config_store.get_entity_configs(Platform.DATETIME): entities.extend( KnxUiDateTime(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index 187c890590ced5..37571cfd95287d 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -14,7 +14,6 @@ CONF_ID, CONF_NAME, CONF_UNIQUE_ID, - EntityCategory, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -267,7 +266,6 @@ def __init__( self._attr_name = entity_config[CONF_NAME] self._attr_unique_id = unique_id - if entity_category := entity_config.get(CONF_ENTITY_CATEGORY): - self._attr_entity_category = EntityCategory(entity_category) - if device_info := entity_config.get(CONF_DEVICE_INFO): + self._attr_entity_category = entity_config[CONF_ENTITY_CATEGORY] + if device_info := entity_config[CONF_DEVICE_INFO]: self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index d288465380b272..36acb2d35facb1 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -122,7 +122,7 @@ async def async_setup_entry( KnxYamlFan(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN): + if ui_config := knx_module.config_store.get_entity_configs(Platform.FAN): entities.extend( KnxUiFan(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 353796ae2307ee..fa4455b7cd0967 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -83,7 +83,7 @@ async def async_setup_entry( KnxYamlLight(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.LIGHT): + if ui_config := knx_module.config_store.get_entity_configs(Platform.LIGHT): entities.extend( KnxUiLight(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 039de497a55fb6..96e5ba7c137547 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -49,7 +49,7 @@ async def async_setup_entry( KnxYamlNotify(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.NOTIFY): + if ui_config := knx_module.config_store.get_entity_configs(Platform.NOTIFY): entities.extend( KnxUiNotify(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 0a1ed401563a0a..3f679243342f19 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -68,7 +68,7 @@ async def async_setup_entry( KnxYamlNumber(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.NUMBER): + if ui_config := knx_module.config_store.get_entity_configs(Platform.NUMBER): entities.extend( KnxUiNumber(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/repairs.py b/homeassistant/components/knx/repairs.py index cba639cd513231..4a3b90e4becd8b 100644 --- a/homeassistant/components/knx/repairs.py +++ b/homeassistant/components/knx/repairs.py @@ -2,6 +2,7 @@ from collections.abc import Callable from functools import partial +import logging from typing import TYPE_CHECKING, Any, Final import voluptuous as vol @@ -9,6 +10,7 @@ from xknx.telegram import GroupAddress, IndividualAddress, Telegram from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir, selector from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -21,12 +23,15 @@ CONF_KNX_KNXKEY_PASSWORD, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY, + REPAIR_ISSUE_ENTITY_VALIDATION_ERROR, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, KNXConfigEntryData, ) from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file +_LOGGER = logging.getLogger(__name__) + CONF_KEYRING_FILE: Final = "knxkeys_file" @@ -43,6 +48,35 @@ async def async_create_fix_flow( raise ValueError(f"unknown repair {issue_id}") +########################### +# Entity store schema issue +########################### + + +@callback +def async_create_entity_validation_issue( + hass: HomeAssistant, platform: Platform, unique_ids: list[str] +) -> None: + """Create a repair issue for invalid entity configurations in the config store.""" + _LOGGER.error( + "Invalid KNX %s configuration in storage. These entities were not set up: %s", + platform, + ", ".join(unique_ids), + ) + ir.async_create_issue( + hass, + DOMAIN, + f"{REPAIR_ISSUE_ENTITY_VALIDATION_ERROR}_{platform}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key=REPAIR_ISSUE_ENTITY_VALIDATION_ERROR, + translation_placeholders={ + "platform": platform, + "entities": "\n".join(f"- {unique_id}" for unique_id in unique_ids), + }, + ) + + ###################### # DataSecure key issue ###################### diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index 10012540c2cabd..ef95fec8dffbac 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -51,7 +51,7 @@ async def async_setup_entry( KnxYamlScene(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.SCENE): + if ui_config := knx_module.config_store.get_entity_configs(Platform.SCENE): entities.extend( KnxUiScene(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index b46b8295ac30fd..350eb23495b3e5 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -71,7 +71,7 @@ async def async_setup_entry( KnxYamlSelect(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.SELECT): + if ui_config := knx_module.config_store.get_entity_configs(Platform.SELECT): entities.extend( KnxUiSelect(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index ba7f8b3fd9d26c..f65922a99fdf9c 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -156,7 +156,7 @@ async def async_setup_entry( KnxYamlSensor(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.SENSOR): + if ui_config := knx_module.config_store.get_entity_configs(Platform.SENSOR): entities.extend( KnxUiSensor(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2c8a19c54b12a3..2b1da794031326 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -12,8 +12,13 @@ from homeassistant.util.ulid import ulid_now from ..const import DOMAIN, KNX_MODULE_KEY +from ..repairs import async_create_entity_validation_issue from . import migration from .const import CONF_DATA +from .entity_store_validation import ( + EntityStoreValidationException, + validate_entity_data, +) from .expose_controller import KNXExposeStoreConfigModel, KNXExposeStoreModel from .time_server import KNXTimeServerStoreModel @@ -118,6 +123,28 @@ def add_platform( """Add platform controller.""" self._platform_controllers[platform] = controller + @callback + def get_entity_configs(self, platform: Platform) -> KNXPlatformStoreModel: + """Return validated entity configurations for a platform. + + Invalid configurations are reported as a repair issue and stay in + `self.data` so they aren't dropped from storage. + """ + validated: KNXPlatformStoreModel = {} + invalid: list[str] = [] + for unique_id, config in self.data["entities"].get(platform, {}).items(): + try: + result = validate_entity_data( + {CONF_PLATFORM: platform, CONF_DATA: config} + ) + except EntityStoreValidationException: + invalid.append(unique_id) + else: + validated[unique_id] = result[CONF_DATA] + if invalid: + async_create_entity_validation_issue(self.hass, platform, invalid) + return validated + async def create_entity( self, platform: Platform, data: dict[str, Any] ) -> str | None: diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index f5534358ac313f..7feda1efe0b2d5 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -407,19 +407,21 @@ class LightColorMode(StrEnum): probatio.Optional(CONF_GA_COLOR_TEMP): GASelector( write_required=True, dpt=ColorTempModes ), - probatio.Required( - CONF_COLOR_TEMP_MIN, default=2700 - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=1, max=10000, step=1, unit_of_measurement="K" - ) + probatio.Required(CONF_COLOR_TEMP_MIN, default=2700): AllSerializeFirst( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=10000, step=1, unit_of_measurement="K" + ) + ), + probatio.Coerce(int), ), - probatio.Required( - CONF_COLOR_TEMP_MAX, default=6000 - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=1, max=10000, step=1, unit_of_measurement="K" - ) + probatio.Required(CONF_COLOR_TEMP_MAX, default=6000): AllSerializeFirst( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=10000, step=1, unit_of_measurement="K" + ) + ), + probatio.Coerce(int), ), probatio.Optional(CONF_COLOR): GroupSelect( GroupSelectOption( diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 6b41f1578d9e0d..e5588d0abcb8df 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1321,6 +1321,10 @@ }, "title": "KNX Data Secure telegrams can't be decrypted" }, + "entity_validation_error": { + "description": "The stored configuration of the following KNX {platform} entities is invalid, so they were not set up:\n\n{entities}\n\nCorrect or delete them. Check the logs for details.", + "title": "Invalid KNX entity configuration" + }, "telegram_storage_error": { "description": "The configured KNX telegram storage backend failed to initialize. As a result, KNX telegrams are currently not being stored. Check the logs for details on the error and ensure your database is accessible.", "title": "KNX telegram storage error" diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 55d622b33dc782..200cc391d79ae8 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -65,7 +65,7 @@ async def async_setup_entry( KnxYamlSwitch(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.SWITCH): + if ui_config := knx_module.config_store.get_entity_configs(Platform.SWITCH): entities.extend( KnxUiSwitch(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index af0a8d37a1dfca..f035740aa6077c 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -66,7 +66,7 @@ async def async_setup_entry( KnxYamlText(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.TEXT): + if ui_config := knx_module.config_store.get_entity_configs(Platform.TEXT): entities.extend( KnxUiText(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index a3c4d6d63d6349..19b962e4eb430a 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -59,7 +59,7 @@ async def async_setup_entry( KnxYamlTime(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.TIME): + if ui_config := knx_module.config_store.get_entity_configs(Platform.TIME): entities.extend( KnxUiTime(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 14b45d4183757d..03315f8efd7ec9 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -72,7 +72,7 @@ async def async_setup_entry( KnxYamlWeather(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.data["entities"].get(Platform.WEATHER): + if ui_config := knx_module.config_store.get_entity_configs(Platform.WEATHER): entities.extend( KnxUiWeather(knx_module, unique_id, config) for unique_id, config in ui_config.items() diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index bf10f95b2e07ca..40575366121f17 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -7,5 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["librouteros"], + "quality_scale": "silver", "requirements": ["librouteros==4.1.1"] } diff --git a/homeassistant/components/mikrotik/quality_scale.yaml b/homeassistant/components/mikrotik/quality_scale.yaml new file mode 100644 index 00000000000000..c1ad61dd02f4d4 --- /dev/null +++ b/homeassistant/components/mikrotik/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not provide actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide actions + docs-conditions: + status: exempt + comment: Integration does not provide conditions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not provide triggers + entity-event-setup: + status: exempt + comment: Integration does not provide event entities + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: librouteros uses the RouterOS binary API over raw sockets, not HTTP. + strict-typing: + status: todo + comment: >- + librouteros does not ship a py.typed marker (not PEP 561 compliant) diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index a39a93b7b22b3e..575914c3534915 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -35,7 +35,7 @@ rules: docs-configuration-parameters: status: exempt comment: No options flow - docs-installation-parameters: todo + docs-installation-parameters: done docs-troubleshooting: done entity-category: done entity-disabled-by-default: done @@ -68,7 +68,7 @@ rules: repair-issues: status: exempt comment: No known repair scenarios - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index a9a75c85cd6be2..a7b7f3842e55dd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -612,7 +612,16 @@ def async_set_service_schema( } if "target" in schema: - description["target"] = schema["target"] + # Match validation applied to descriptions loaded from services.yaml. + try: + description["target"] = TargetSelector.CONFIG_SCHEMA(schema["target"]) + except vol.Invalid as err: + _LOGGER.warning( + "Invalid target in the description of service %s.%s, ignoring it: %s", + domain, + service, + err, + ) if ( response := hass.services.supports_response(domain, service) diff --git a/requirements.txt b/requirements.txt index 4a3814aaf7d888..554f70c761207d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.8.28 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==9.0.0 +infrared-protocols==9.1.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index 275da687941d60..3ffad0728a2dea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1393,7 +1393,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==9.0.0 +infrared-protocols==9.1.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 019e382a84a279..a8c8ffa160acb8 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -579,7 +579,6 @@ class Rule: "mfi", "microbees", "microsoft", - "mikrotik", "mill", "min_max", "minio", @@ -1524,7 +1523,6 @@ class Rule: "mfi", "microbees", "microsoft", - "mikrotik", "mill", "min_max", "minio", diff --git a/tests/components/gatus/conftest.py b/tests/components/gatus/conftest.py index 9b95ae34da78e9..6a869f5a963169 100644 --- a/tests/components/gatus/conftest.py +++ b/tests/components/gatus/conftest.py @@ -47,6 +47,7 @@ def mock_gatus_client() -> Generator[AsyncMock]: status=200, duration=23123100, certificate_expiration=7776000000000000, + dns_rcode="NOERROR", ) ], events=[Event(type="HEALTHY", timestamp="2026-01-01T00:00:00Z")], diff --git a/tests/components/gatus/snapshots/test_sensor.ambr b/tests/components/gatus/snapshots/test_sensor.ambr index eb2fa149cf2f3d..a8b7639b4e02f5 100644 --- a/tests/components/gatus/snapshots/test_sensor.ambr +++ b/tests/components/gatus/snapshots/test_sensor.ambr @@ -50,6 +50,56 @@ 'state': '2026-04-01T00:00:00+00:00', }) # --- +# name: test_sensor_setup_and_states[sensor.core_backend_service_dns_response_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.core_backend_service_dns_response_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DNS response code', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DNS response code', + 'platform': 'gatus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dns_rcode', + 'unique_id': '1234567890abcdef1234567890abcdef_backend_service_dns_rcode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.core_backend_service_dns_response_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Core Backend Service DNS response code', + }), + 'context': , + 'entity_id': 'sensor.core_backend_service_dns_response_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensor_setup_and_states[sensor.core_backend_service_last_event-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/gatus/test_sensor.py b/tests/components/gatus/test_sensor.py index db20bec0dd7e4a..0facd887b4251d 100644 --- a/tests/components/gatus/test_sensor.py +++ b/tests/components/gatus/test_sensor.py @@ -50,6 +50,7 @@ def _to_endpoint_statuses(raw_data: list[dict[str, Any]]) -> list[EndpointStatus status=r.get("status"), duration=r.get("duration"), certificate_expiration=r.get("certificateExpiration"), + dns_rcode=r.get("dnsRcode"), ) for r in ep.get("results", []) ], @@ -196,3 +197,31 @@ async def test_sensor_missing_certificate_expiration( state = hass.states.get("sensor.backend_service_certificate_expiration") assert state is None + + +async def test_sensor_missing_dns_rcode( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a result missing DNS rcode creates no entity.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[ + Result( + success=True, + status=200, + duration=12500000, + dns_rcode=None, + ) + ], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.backend_service_dns_response_code") + assert state is None diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index 5fd6e7c4666ef0..66e8e738944013 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -7,7 +7,7 @@ from homeassistant.components.hue.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.json import JsonArrayType from .conftest import setup_platform @@ -21,6 +21,8 @@ FAKE_ZIGBEE_CONNECTIVITY, ) +TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" + async def test_switch( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType @@ -31,8 +33,8 @@ async def test_switch( await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 4 entities should be created from test data - assert len(hass.states.async_all()) == 4 + # 5 entities should be created from test data + assert len(hass.states.async_all()) == 5 # test config switch to enable/disable motion sensor test_entity = hass.states.get("switch.hue_motion_sensor_motion_sensor_enabled") @@ -49,6 +51,35 @@ async def test_switch( assert test_entity.state == "on" assert test_entity.attributes["device_class"] == "switch" + # test config switch to enable/disable a MotionAware zone + test_entity = hass.states.get("switch.test_room_test_room_motionaware") + assert test_entity is not None + assert test_entity.name == "Test Room MotionAware" + assert test_entity.state == "on" + assert test_entity.attributes["device_class"] == "switch" + + +async def test_motionaware_switch_device( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the MotionAware switch is attached to the zone device, not the bridge.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) + + entity_entry = entity_registry.async_get("switch.test_room_test_room_motionaware") + assert entity_entry is not None + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_ROOM_ID), mock_bridge_v2.config_entry.entry_id + ) + assert zone_device is not None + assert entity_entry.device_id == zone_device.id + async def test_switch_turn_on_service( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType @@ -115,6 +146,62 @@ async def test_switch_turn_off_service( assert test_entity.state == "off" +async def test_motionaware_switch_turn_on_off_service( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test enabling/disabling a MotionAware zone.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) + + test_entity_id = "switch.test_room_test_room_motionaware" + + # verify the switch is on before we start + assert hass.states.get(test_entity_id).state == "on" + + # call the HA turn_off service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to the motion_area_configuration resource + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert ( + mock_bridge_v2.mock_requests[0]["path"] + == "clip/v2/resource/motion_area_configuration/" + "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" + ) + assert mock_bridge_v2.mock_requests[0]["json"]["enabled"] is False + + # Now generate update event by emitting the json we've sent as incoming event + event = { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "type": "motion_area_configuration", + **mock_bridge_v2.mock_requests[0]["json"], + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # the switch should now be off + assert hass.states.get(test_entity_id).state == "off" + + # call the HA turn_on service + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": test_entity_id}, + blocking=True, + ) + + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["method"] == "put" + assert mock_bridge_v2.mock_requests[1]["json"]["enabled"] is True + + async def test_switch_added(hass: HomeAssistant, mock_bridge_v2: Mock) -> None: """Test new switch added to bridge.""" await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) @@ -179,7 +266,7 @@ async def test_internal_behavior_instance_not_added( assert hass.states.get("switch.philips_hue_automation_wall_switch_hallway") is None assert hass.states.get("switch.philips_hue_automation_timer_test") is not None - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 5 async def test_internal_behavior_instance_entity_removed( diff --git a/tests/components/knx/fixtures/config_store_date.json b/tests/components/knx/fixtures/config_store_date.json index 48cbc29abde44c..5d69052790cc4b 100644 --- a/tests/components/knx/fixtures/config_store_date.json +++ b/tests/components/knx/fixtures/config_store_date.json @@ -34,7 +34,7 @@ "passive": [] }, "respond_to_read": false, - "sync_state": false + "sync_state": true } } } diff --git a/tests/components/knx/fixtures/config_store_datetime.json b/tests/components/knx/fixtures/config_store_datetime.json index c5dbd5fc88f9b1..c6ff74c20208db 100644 --- a/tests/components/knx/fixtures/config_store_datetime.json +++ b/tests/components/knx/fixtures/config_store_datetime.json @@ -34,7 +34,7 @@ "passive": [] }, "respond_to_read": false, - "sync_state": false + "sync_state": true } } } diff --git a/tests/components/knx/fixtures/config_store_invalid.json b/tests/components/knx/fixtures/config_store_invalid.json new file mode 100644 index 00000000000000..70322ec49a582b --- /dev/null +++ b/tests/components/knx/fixtures/config_store_invalid.json @@ -0,0 +1,64 @@ +{ + "version": 2, + "minor_version": 4, + "key": "knx/config_store.json", + "data": { + "entities": { + "switch": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "valid", + "device_info": null, + "entity_category": "config" + }, + "knx": { + "ga_switch": { + "write": "1/1/45", + "state": "1/0/45", + "passive": [] + }, + "invert": false, + "sync_state": true, + "respond_to_read": false + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "invalid group address", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "not a group address", + "state": null, + "passive": [] + }, + "invert": false, + "sync_state": true, + "respond_to_read": false + } + } + }, + "light": { + "knx_es_01J85ZKTFHSZNG4X9DYBE592TF": { + "entity": { + "name": "missing defaults", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "1/1/21", + "state": "1/0/21", + "passive": [] + }, + "sync_state": true + } + } + } + }, + "expose": {}, + "time_server": {} + } +} diff --git a/tests/components/knx/fixtures/config_store_time.json b/tests/components/knx/fixtures/config_store_time.json index ad0d6de2ed52f3..fa6c2bb8a88b32 100644 --- a/tests/components/knx/fixtures/config_store_time.json +++ b/tests/components/knx/fixtures/config_store_time.json @@ -34,7 +34,7 @@ "passive": [] }, "respond_to_read": false, - "sync_state": false + "sync_state": true } } } diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 7edf7cc2f6ea2f..04f7618c8e3caa 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -4,12 +4,17 @@ import pytest +from homeassistant.components.knx.const import ( + DOMAIN, + KNX_MODULE_KEY, + REPAIR_ISSUE_ENTITY_VALIDATION_ERROR, +) from homeassistant.components.knx.storage.config_store import ( STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, ) -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import KnxEntityGenerator from .conftest import KNXTestKit @@ -605,6 +610,86 @@ async def test_delete_expose_error( ) +################## +# STORE VALIDATION +################## + +VALID_SWITCH_UID = "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7" +INVALID_SWITCH_UID = "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB" +LIGHT_UID = "knx_es_01J85ZKTFHSZNG4X9DYBE592TF" + + +async def test_load_skips_invalid_entity_config( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an invalid stored config is skipped without failing its platform.""" + await knx.setup_integration( + config_store_fixture="config_store_invalid.json", state_updater=False + ) + assert entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, VALID_SWITCH_UID + ) + assert ( + entity_registry.async_get_entity_id(Platform.SWITCH, DOMAIN, INVALID_SWITCH_UID) + is None + ) + + issue = issue_registry.async_get_issue( + DOMAIN, f"{REPAIR_ISSUE_ENTITY_VALIDATION_ERROR}_{Platform.SWITCH}" + ) + assert issue is not None + assert issue.severity is ir.IssueSeverity.ERROR + assert issue.translation_placeholders == { + "platform": Platform.SWITCH, + "entities": f"- {INVALID_SWITCH_UID}", + } + + +async def test_load_applies_schema_defaults_and_coercion( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, +) -> None: + """Test stored configs are normalized on load. + + The light in the fixture predates `color_temp_min` / `color_temp_max`, which + `KnxUiLight.__init__` reads by direct key access, and the switch stores + `entity_category` as a plain string. + """ + await knx.setup_integration( + config_store_fixture="config_store_invalid.json", state_updater=False + ) + assert hass.states.get("light.missing_defaults") is not None + config_store = hass.data[KNX_MODULE_KEY].config_store + light_config = config_store.get_entity_configs(Platform.LIGHT)[LIGHT_UID][DOMAIN] + assert light_config["color_temp_min"] == 2700 + assert light_config["color_temp_max"] == 6000 + + switch_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, VALID_SWITCH_UID + ) + assert entity_registry.async_get(switch_id).entity_category is EntityCategory.CONFIG + + +async def test_load_valid_store_creates_no_issue( + hass: HomeAssistant, + knx: KNXTestKit, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a valid store doesn't raise a repair issue.""" + await knx.setup_integration( + config_store_fixture="config_store_light_switch.json", state_updater=False + ) + assert not [ + issue + for issue in issue_registry.issues.values() + if issue.issue_id.startswith(REPAIR_ISSUE_ENTITY_VALIDATION_ERROR) + ] + + ########### # MIGRATION ########### diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index 1bdea26fabf0b7..eb631b8b1ec559 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -281,7 +281,7 @@ async def test_number_ui_load(knx: KNXTestKit) -> None: ) knx.assert_state( "number.test_options", - "3000", + "3000.0", # `min`, `max` and `step` are floats after validation unit_of_measurement="kW", device_class="power", min=3000, diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py index db6f39e8cf0ae2..f1d4811eb02811 100644 --- a/tests/components/lyngdorf/test_media_player.py +++ b/tests/components/lyngdorf/test_media_player.py @@ -485,6 +485,20 @@ async def test_no_streaming_features_on_model_without_streamer( assert state.attributes.get(ATTR_MEDIA_TITLE) is None +@pytest.mark.usefixtures("init_integration") +async def test_no_position_before_the_streamer_reports_one( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test an attached player that has not yet reported a position.""" + playing_receiver.position_ms = None + notify_receiver_update(playing_receiver) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert state.attributes.get(ATTR_MEDIA_POSITION) is None + + @pytest.mark.usefixtures("init_integration") async def test_position_jump_updates_state( hass: HomeAssistant, diff --git a/tests/components/paperless_ngx/test_config_flow.py b/tests/components/paperless_ngx/test_config_flow.py index d6c9550073276d..dad3789010d1fd 100644 --- a/tests/components/paperless_ngx/test_config_flow.py +++ b/tests/components/paperless_ngx/test_config_flow.py @@ -121,7 +121,14 @@ async def test_config_flow_error_handling( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=USER_INPUT_ONE, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_ONE, ) assert result["type"] is FlowResultType.FORM @@ -223,9 +230,17 @@ async def test_config_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, - data=USER_INPUT_ONE, context={"source": config_entries.SOURCE_USER}, ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_ONE, + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 8a8937992176a0..4250e65e65c49c 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1512,6 +1512,57 @@ def load_yaml(fname, secrets=None): } +@pytest.mark.parametrize( + ("target", "expected"), + [ + pytest.param( + {"entity": {"domain": "light"}}, + {"entity": [{"domain": ["light"]}]}, + id="normalized", + ), + pytest.param( + {"entity": [{"domain": "light"}]}, + {"entity": [{"domain": ["light"]}]}, + id="already_normalized", + ), + ], +) +async def test_set_service_schema_target( + hass: HomeAssistant, + target: dict[str, Any], + expected: dict[str, Any], +) -> None: + """Test the target of a registered description is normalized.""" + await async_setup_component(hass, LOGGER_DOMAIN, {LOGGER_DOMAIN: {}}) + hass.services.async_register(LOGGER_DOMAIN, "new_service", lambda x: None, None) + + service.async_set_service_schema( + hass, LOGGER_DOMAIN, "new_service", {"target": target} + ) + + descriptions = await service.async_get_all_descriptions(hass) + assert descriptions[LOGGER_DOMAIN]["new_service"]["target"] == expected + + +async def test_set_service_schema_invalid_target( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a target nothing can read is left out of the description.""" + await async_setup_component(hass, LOGGER_DOMAIN, {LOGGER_DOMAIN: {}}) + hass.services.async_register(LOGGER_DOMAIN, "new_service", lambda x: None, None) + + service.async_set_service_schema( + hass, LOGGER_DOMAIN, "new_service", {"target": {"entity": ["light"]}} + ) + + descriptions = await service.async_get_all_descriptions(hass) + assert "target" not in descriptions[LOGGER_DOMAIN]["new_service"] + assert ( + "Invalid target in the description of service logger.new_service" in caplog.text + ) + + async def test_register_with_mixed_case(hass: HomeAssistant) -> None: """Test registering a service with mixed case.