diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 5fe0995ff46970..afc55ba7101870 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -174,9 +174,10 @@ def _update_from_device(self) -> None: self.current_cover_position, ) - if self._entity_info.endpoint.has_attribute( + has_tilt_position_attribute = self._entity_info.endpoint.has_attribute( None, clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths - ): + ) + if has_tilt_position_attribute: # current tilt position is inverted in matter (100 is closed, 0 is open) current_cover_tilt_position = self.get_matter_attribute_value( clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths @@ -206,9 +207,17 @@ def _update_from_device(self) -> None: commands = self.get_matter_attribute_value( clusters.WindowCovering.Attributes.AcceptedCommandList ) + feature_map = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.FeatureMap + ) if clusters.WindowCovering.Commands.GoToLiftPercentage.command_id in commands: supported_features |= CoverEntityFeature.SET_POSITION - if clusters.WindowCovering.Commands.GoToTiltPercentage.command_id in commands: + # Some devices report GoToTiltPercentage in AcceptedCommandList even + # without tilt support, so also require the FeatureMap Tilt bit. + if ( + clusters.WindowCovering.Commands.GoToTiltPercentage.command_id in commands + and feature_map & clusters.WindowCovering.Bitmaps.Feature.kTilt + ): supported_features |= CoverEntityFeature.SET_TILT_POSITION self._attr_supported_features = supported_features @@ -271,6 +280,11 @@ def _update_from_device(self) -> None: clusters.WindowCovering.Attributes.OperationalStatus, clusters.WindowCovering.Attributes.Type, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + ), + # tilt is optional, not required: some devices (e.g. Shelly 2PM Gen4) + # report it as present but null instead of omitting it when tilt is + # disabled, which would otherwise fail schema matching + optional_attributes=( clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, ), ), diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index ad4d9aa4d6ce78..da1ca2d2eeb0ae 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import LOGGER from .entity import MatterEntity, MatterEntityDescription from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema @@ -117,12 +118,25 @@ def _on_matter_node_event( """Call on NodeEvent.""" if data.endpoint_id != self._endpoint.endpoint_id: return + + # event ids are only unique within a cluster, and an endpoint can host + # more clusters than the switch this entity was made for + if data.cluster_id != clusters.Switch.id: + return + + event_type: str | None = EVENT_TYPES_MAP.get(data.event_id) if data.event_id == clusters.Switch.Events.MultiPressComplete.event_id: # multi press event presses = (data.data or {}).get("totalNumberOfPressesCounted", 1) event_type = f"multi_press_{presses}" - else: - event_type = EVENT_TYPES_MAP[data.event_id] + + if event_type is None: + LOGGER.debug( + "Ignoring unknown switch event id %s for %s", + data.event_id, + self.entity_id, + ) + return if event_type not in self.event_types: # this should not happen, but guard for bad things diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index b8769a0f191f08..97d601c835b10b 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -239,14 +239,16 @@ def _get_hs_color(self) -> tuple[float, float]: return hs_color - def _get_color_temperature(self) -> int: + def _get_color_temperature(self) -> int | None: """Get color temperature from matter.""" color_temp = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorTemperatureMireds ) - assert color_temp is not None + if color_temp is None: + LOGGER.debug("Got no color temperature for %s", self.entity_id) + return None LOGGER.debug( "Got color temperature %s for %s", @@ -261,8 +263,10 @@ def _get_brightness(self) -> int | None: level_control = self._endpoint.get_cluster(clusters.LevelControl) - # We should not get here if brightness is not supported. - assert level_control is not None + if level_control is None: + # we should not get here if brightness is not supported + LOGGER.debug("Got no level control cluster for %s", self.entity_id) + return None LOGGER.debug( "Got brightness %s for %s", @@ -289,9 +293,15 @@ def _get_color_mode(self) -> ColorMode: clusters.ColorControl.Attributes.ColorMode ) - assert color_mode is not None - - ha_color_mode = COLOR_MODE_MAP[color_mode] + if (ha_color_mode := COLOR_MODE_MAP.get(color_mode)) is None: + # ColorMode is nullable and a device is free to report a value + # outside of the enum, neither of which we can map to a color + LOGGER.debug( + "Got unexpected color mode (%s) for %s", + color_mode, + self.entity_id, + ) + return ColorMode.UNKNOWN LOGGER.debug( "Got color mode (%s) for %s", @@ -419,12 +429,14 @@ def _update_from_device(self) -> None: if self._supports_brightness: self._attr_brightness = self._get_brightness() - if ( - self._supports_color_temperature - and (color_temperature := self._get_color_temperature()) > 0 - ): - self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( - color_temperature + if self._supports_color_temperature: + # a device without a usable value has no color temperature to + # report, rather than the one it gave us last time + color_temperature = self._get_color_temperature() + self._attr_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(color_temperature) + if color_temperature + else None ) if self._supports_color: diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 5e6934099a1144..ee56e011a75c7e 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -16,7 +16,6 @@ CONF_DEVICE_CLASS, CONF_NAME, CONF_SCAN_INTERVAL, - CONF_SLAVE, CONF_STRUCTURE, CONF_UNIQUE_ID, STATE_OFF, @@ -40,7 +39,6 @@ CALL_TYPE_X_COILS, CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DATA_TYPE, - CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_MAX_VALUE, CONF_MIN_VALUE, @@ -63,7 +61,7 @@ SIGNAL_STOP_ENTITY, DataType, ) -from .modbus import ModbusHub +from .modbus import ModbusHub, entity_unit_id class ModbusBaseEntity(Entity): @@ -80,10 +78,7 @@ def __init__( """Initialize the Modbus binary sensor.""" self._hub = hub - if (conf_slave := entry.get(CONF_SLAVE)) is not None: - self._device_address = conf_slave - else: - self._device_address = entry.get(CONF_DEVICE_ADDRESS, 1) + self._device_address = entity_unit_id(entry) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 6a7982757ffab9..da8771ed65e4ea 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -19,6 +19,7 @@ CONF_METHOD, CONF_NAME, CONF_PORT, + CONF_SLAVE, CONF_TIMEOUT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, @@ -27,6 +28,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType +from .connection import ModbusEndpoint from .const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, @@ -38,6 +40,7 @@ CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_DEVICE_ADDRESS, CONF_MSG_WAIT, CONF_PARITY, CONF_STOPBITS, @@ -109,6 +112,13 @@ ] +def entity_unit_id(entity_config: dict[str, Any]) -> int: + """Return the unit an entity config addresses, defaulting to 1.""" + if (conf_slave := entity_config.get(CONF_SLAVE)) is not None: + return int(conf_slave) + return int(entity_config.get(CONF_DEVICE_ADDRESS, 1)) + + async def async_modbus_setup( hass: HomeAssistant, config: ConfigType, @@ -198,8 +208,11 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: "timeout": client_config[CONF_TIMEOUT], "retries": 3, } + # The endpoint is keyed like `ModbusParams.endpoint`, so that a hub and + # a shared connection to one device can be told apart from two devices if self._config_type == SERIAL: # serial configuration + self.endpoint: ModbusEndpoint = ("serial", client_config[CONF_PORT]) if client_config[CONF_METHOD] == "ascii": self._pb_params["framer"] = FramerType.ASCII else: @@ -215,6 +228,11 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: else: # network configuration self._pb_params["host"] = client_config[CONF_HOST] + self.endpoint = ( + "udp" if self._config_type == UDP else "tcp", + client_config[CONF_HOST].lower(), + client_config[CONF_PORT], + ) if self._config_type == RTUOVERTCP: self._pb_params["framer"] = FramerType.RTU else: @@ -227,6 +245,19 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: else: self._msg_wait = 0 + self.units = sorted( + { + entity_unit_id(entity_config) + for _, conf_key in PLATFORMS + for entity_config in client_config.get(conf_key, []) + } + ) + + @property + def connected(self) -> bool: + """Return whether the client currently holds a link to the device.""" + return self._client is not None and self._client.connected + def _log_error(self, text: str) -> None: if text == self._last_log_error: return diff --git a/homeassistant/components/modbus/services.py b/homeassistant/components/modbus/services.py index 834707d48c8c5e..2abb1963e7f473 100644 --- a/homeassistant/components/modbus/services.py +++ b/homeassistant/components/modbus/services.py @@ -121,9 +121,12 @@ async def _async_reload_config(call: ServiceCall) -> None: reload_config = await async_integration_yaml_config(hass, DOMAIN) if not reload_config: LOGGER.debug("Modbus not present anymore") + hubs.clear() return LOGGER.debug("Modbus reloading") - await async_modbus_setup(hass, reload_config) + # Setup replaces the hubs only once it has new ones to replace them with + if not await async_modbus_setup(hass, reload_config): + hubs.clear() @callback diff --git a/homeassistant/components/modbus/websocket_api.py b/homeassistant/components/modbus/websocket_api.py index 7a027702d4fb6f..57d388327ea2df 100644 --- a/homeassistant/components/modbus/websocket_api.py +++ b/homeassistant/components/modbus/websocket_api.py @@ -8,9 +8,13 @@ from homeassistant.core import HomeAssistant, callback from .connection import async_get_connection_info +from .const import DATA_MODBUS_HUBS TYPE_LIST_CONNECTIONS: Final = "modbus/connections/list" +SOURCE_CONFIG_ENTRY: Final = "config_entry" +SOURCE_YAML: Final = "yaml" + @callback def async_setup(hass: HomeAssistant) -> None: @@ -26,7 +30,13 @@ def websocket_list_connections( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """List the connections, and which config entries hold units on each.""" + """List the connections held over config entries, then those from YAML. + + The unit ids of a connection are keyed by config entry id, or by hub name + for one from YAML. A YAML hub is a link of its own rather than a hold on a + shared connection, so it is listed separately even when it addresses a + device a config entry also talks to. + """ connection.send_result( msg["id"], { @@ -34,9 +44,19 @@ def websocket_list_connections( { "endpoint": list(info.endpoint), "connected": info.connected, + "source": SOURCE_CONFIG_ENTRY, "units": info.units, } for info in async_get_connection_info(hass) ] + + [ + { + "endpoint": list(hub.endpoint), + "connected": hub.connected, + "source": SOURCE_YAML, + "units": {name: hub.units}, + } + for name, hub in hass.data.get(DATA_MODBUS_HUBS, {}).items() + ] }, ) diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json index 9a3a8e3c1e7121..236112f836aaa4 100644 --- a/homeassistant/components/neopool/manifest.json +++ b/homeassistant/components/neopool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["neopool_modbus"], "quality_scale": "platinum", - "requirements": ["neopool-modbus==4.6.2"] + "requirements": ["neopool-modbus==4.7.0"] } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index b653eea86584e0..30f9a4c75a6ec2 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -782,6 +782,7 @@ async def async_will_remove_from_hass(self) -> None: @websocket_api.websocket_command({"type": "script/config", "entity_id": str}) +@websocket_api.require_admin def websocket_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index b9b0633aa710ba..fcd5e05eef1f2d 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -52,12 +52,12 @@ def is_on(self) -> bool | None: StarlinkBinarySensorEntityDescription( key="update", device_class=BinarySensorDeviceClass.UPDATE, - value_fn=lambda data: data.alert["alert_install_pending"], + value_fn=lambda data: data.alert.get("alert_install_pending"), ), StarlinkBinarySensorEntityDescription( key="roaming", translation_key="roaming", - value_fn=lambda data: data.alert["alert_roaming"], + value_fn=lambda data: data.alert.get("alert_roaming"), ), StarlinkBinarySensorEntityDescription( key="currently_obstructed", @@ -70,48 +70,48 @@ def is_on(self) -> bool | None: key="heating", translation_key="heating", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.alert["alert_is_heating"], + value_fn=lambda data: data.alert.get("alert_is_heating"), ), StarlinkBinarySensorEntityDescription( key="power_save_idle", translation_key="power_save_idle", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.alert["alert_is_power_save_idle"], + value_fn=lambda data: data.alert.get("alert_is_power_save_idle"), ), StarlinkBinarySensorEntityDescription( key="mast_near_vertical", translation_key="mast_near_vertical", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.alert["alert_mast_not_near_vertical"], + value_fn=lambda data: data.alert.get("alert_mast_not_near_vertical"), ), StarlinkBinarySensorEntityDescription( key="motors_stuck", translation_key="motors_stuck", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.alert["alert_motors_stuck"], + value_fn=lambda data: data.alert.get("alert_motors_stuck"), ), StarlinkBinarySensorEntityDescription( key="slow_ethernet", translation_key="slow_ethernet", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.alert["alert_slow_ethernet_speeds"], + value_fn=lambda data: data.alert.get("alert_slow_ethernet_speeds"), ), StarlinkBinarySensorEntityDescription( key="thermal_throttle", translation_key="thermal_throttle", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.alert["alert_thermal_throttle"], + value_fn=lambda data: data.alert.get("alert_thermal_throttle"), ), StarlinkBinarySensorEntityDescription( key="unexpected_location", translation_key="unexpected_location", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.alert["alert_unexpected_location"], + value_fn=lambda data: data.alert.get("alert_unexpected_location"), ), StarlinkBinarySensorEntityDescription( key="connection", diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 1cbdaf47ea629c..3a477f3c20a8f3 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -90,7 +90,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "entry_not_loaded": "The Teslemetry configuration entry is not loaded. Please ensure it is set up correctly before adding a vehicle.", - "no_vehicles": "Every vehicle in your Teslemetry account has already been added over Bluetooth." + "no_vehicles": "Every eligible vehicle in your Teslemetry account has already been added over Bluetooth." }, "entry_type": "Bluetooth vehicle", "error": { @@ -100,7 +100,7 @@ "timeout": "Timed out waiting for the vehicle to accept the key. Try again after approving the key on the vehicle's touchscreen." }, "initiate_flow": { - "user": "Add Bluetooth vehicle" + "user": "Add local vehicle" }, "progress": { "pair": "Approve Home Assistant's virtual key on the vehicle's touchscreen. Waiting for the vehicle to accept the key." @@ -111,7 +111,7 @@ "title": "Approve the virtual key" }, "scan": { - "description": "Home Assistant will look for vehicle {vin} over Bluetooth to enable local command control. Make sure the vehicle is awake and within Bluetooth range of a Home Assistant Bluetooth adapter, then continue.", + "description": "Home Assistant will look for vehicle {vin} over Bluetooth. Make sure the vehicle is within Bluetooth range of a Home Assistant Bluetooth adapter and has fewer than three phone keys in range, then continue.", "title": "Set up Bluetooth control" }, "user": { @@ -121,8 +121,8 @@ "data_description": { "vin": "The account vehicle to enable local Bluetooth control for." }, - "description": "Select which of your Teslemetry account vehicles to add over Bluetooth for local command control.", - "title": "Add Bluetooth vehicle" + "description": "Select which of your eligible vehicles to configure the local Bluetooth connection.", + "title": "Add local vehicle" } } } diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py index 8cc39c13014641..d75f2df1d9bfec 100644 --- a/homeassistant/components/uptime_kuma/const.py +++ b/homeassistant/components/uptime_kuma/const.py @@ -19,6 +19,7 @@ MonitorType.SNMP, MonitorType.SMTP, MonitorType.NTP, + MonitorType.SFTP, } HAS_HOST = HAS_PORT | { MonitorType.PING, diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index d693f312268995..423b0064678756 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "platinum", - "requirements": ["pythonkuma==0.5.2"] + "requirements": ["pythonkuma==0.5.3"] } diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index b6eb88e8ec5223..6fa6fae04b412e 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -127,6 +127,7 @@ "radius": "RADIUS", "real_browser": "HTTP(s) - Browser engine", "redis": "Redis", + "sftp": "SFTP", "sip_options": "SIP OPTIONS Ping", "smtp": "SMTP", "snmp": "SNMP", diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 9f843d61e758c9..b373d5dd4a6f84 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -9,7 +9,6 @@ import functools as ft import inspect import logging -import re import sys from typing import ( TYPE_CHECKING, @@ -151,10 +150,6 @@ "trigger": None, } -INPUT_ENTITY_ID = re.compile( - r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(? Any: has_at_least_one_key(CONF_BELOW, CONF_ABOVE), ) +INPUT_ENTITY_ID = re.compile( + r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(? dict[str, Any]: else: validated = STATE_CONDITION_STATE_SCHEMA(value) - return key_dependency("for", "state")(validated) + validated = key_dependency("for", "state")(validated) + + if CONF_FOR in validated: + # `for` is anchored to the entity's last_changed, which only reflects a + # single current state. It therefore can't track an attribute, multiple + # states, or a state resolved from another entity. + if CONF_ATTRIBUTE in validated: + raise vol.Invalid("Cannot use 'for' with an attribute") + state = validated[CONF_STATE] + # A single-element list is just that one state; unwrap it so the + # input-entity check below also rejects `state: [input_select.x]`. + if isinstance(state, list): + if len(state) != 1: + raise vol.Invalid("Cannot use 'for' with a list of states") + state = state[0] + if INPUT_ENTITY_ID.match(state): + raise vol.Invalid("Cannot use 'for' with a state referencing an entity") + + return validated TEMPLATE_CONDITION_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9ad99a6e378db0..2d1e8c86462c6b 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -381,6 +381,22 @@ def _normalize_connections_validator( raise ValueError(f"Invalid mac address format: {value}") +def _report_deprecated_config_entries_property( + instance: object, name: str, *replacements: str +) -> None: + """Report use of a deprecated multi-config-entry compatibility property.""" + class_name = type(instance).__name__ + replacement = " and ".join(f"`{class_name}.{field}`" for field in replacements) + report_usage( + f"accesses `{class_name}.{name}`, which is deprecated because a device " + f"belongs to a single config entry; use {replacement} instead", + breaks_in_ha_version="2027.10.0", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + ) + + @attr.s(frozen=True, slots=True) class BaseDeviceEntry: """Base class for device registry entries.""" @@ -399,14 +415,28 @@ class BaseDeviceEntry: name: str | None = attr.ib(default=None) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + @property + def _config_entries(self) -> set[str]: + """Return the config entries this device belongs to, without reporting.""" + return {self.config_entry_id} + + @property + def _config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belongs to, without reporting.""" + return {self.config_entry_id: {self.config_subentry_id}} + @property def config_entries(self) -> set[str]: """Return the config entries this device belongs to. Deprecated compatibility shim: a device now belongs to a single config - entry, available as config_entry_id. + entry, available as config_entry_id. It can be removed in HA Core 2027.10. """ - return {self.config_entry_id} + if not self.is_composite_device: + _report_deprecated_config_entries_property( + self, "config_entries", "config_entry_id" + ) + return self._config_entries @property def config_entries_subentries(self) -> dict[str, set[str | None]]: @@ -414,8 +444,16 @@ def config_entries_subentries(self) -> dict[str, set[str | None]]: Deprecated compatibility shim: a device now belongs to a single config entry and subentry, available as config_entry_id and config_subentry_id. + It can be removed in HA Core 2027.10. """ - return {self.config_entry_id: {self.config_subentry_id}} + if not self.is_composite_device: + _report_deprecated_config_entries_property( + self, + "config_entries_subentries", + "config_entry_id", + "config_subentry_id", + ) + return self._config_entries_subentries @property def primary_config_entry(self) -> str: @@ -423,7 +461,12 @@ def primary_config_entry(self) -> str: Deprecated compatibility shim: a device now belongs to a single config entry, available as config_entry_id, which is its primary config entry. + It can be removed in HA Core 2027.10. """ + if not self.is_composite_device: + _report_deprecated_config_entries_property( + self, "primary_config_entry", "config_entry_id" + ) return self.config_entry_id @property @@ -511,24 +554,16 @@ class DeviceEntry(BaseDeviceEntry): @property @override - def config_entries(self) -> set[str]: - """Return the config entries this device belongs to. - - Deprecated compatibility shim: a device now belongs to a single config - entry, available as config_entry_id. - """ + def _config_entries(self) -> set[str]: + """Return the config entries this device belongs to, without reporting.""" if self._composite_subentries is not None: return set(self._composite_subentries) return {self.config_entry_id} @property @override - def config_entries_subentries(self) -> dict[str, set[str | None]]: - """Return the config subentries this device belongs to. - - Deprecated compatibility shim: a device now belongs to a single config - entry and subentry, available as config_entry_id and config_subentry_id. - """ + def _config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belongs to, without reporting.""" if self._composite_subentries is not None: return { entry_id: set(subentries) @@ -559,10 +594,10 @@ def dict_repr(self) -> dict[str, Any]: # config_entries and config_entries_subentries are deprecated and kept for # backwards compatibility, they can be removed in HA Core 2027.8. They use the # compatibility properties so a restored composite reports its merged entries. - "config_entries": list(self.config_entries), + "config_entries": list(self._config_entries), "config_entries_subentries": { entry_id: list(subentries) - for entry_id, subentries in self.config_entries_subentries.items() + for entry_id, subentries in self._config_entries_subentries.items() }, "config_entry_id": self.config_entry_id, "config_subentry_id": self.config_subentry_id, @@ -581,7 +616,8 @@ def dict_repr(self) -> dict[str, Any]: "name_by_user": self.name_by_user, "name": self.name, "parent_device_id": None, - "primary_config_entry": self.primary_config_entry, + # primary_config_entry is deprecated, it can be removed in HA Core 2027.10. + "primary_config_entry": self.config_entry_id, "serial_number": self.serial_number, "sw_version": self.sw_version, "via_device_id": self.via_device_id, @@ -614,7 +650,9 @@ def as_storage_fragment(self) -> json_fragment: "name_by_user": self.name_by_user, "name": self.name, "has_composite_identifiers": (self.has_composite_identifiers), - "primary_config_entry": self.primary_config_entry, + # primary_config_entry is deprecated, it can be removed in HA Core + # 2027.10. + "primary_config_entry": self.config_entry_id, "serial_number": self.serial_number, "sw_version": self.sw_version, "via_device_id": self.via_device_id, @@ -776,16 +814,24 @@ class DeletedDeviceEntry: def config_entries(self) -> set[str]: """Return the config entries this device belonged to. - Deprecated compatibility shim; empty for orphaned deleted devices. + Deprecated compatibility shim; empty for orphaned deleted devices. It can be + removed in HA Core 2027.10. """ + _report_deprecated_config_entries_property( + self, "config_entries", "config_entry_id" + ) return {self.config_entry_id} if self.config_entry_id is not None else set() @property def config_entries_subentries(self) -> dict[str, set[str | None]]: """Return the config subentries this device belonged to. - Deprecated compatibility shim; empty for orphaned deleted devices. + Deprecated compatibility shim; empty for orphaned deleted devices. It can be + removed in HA Core 2027.10. """ + _report_deprecated_config_entries_property( + self, "config_entries_subentries", "config_entry_id", "config_subentry_id" + ) if self.config_entry_id is None: return {} return {self.config_entry_id: {self.config_subentry_id}} diff --git a/requirements_all.txt b/requirements_all.txt index 4570a8b3aa8d67..922c60f37ff7ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1698,7 +1698,7 @@ nad-receiver==0.4.0 ndms2-client==0.1.2 # homeassistant.components.neopool -neopool-modbus==4.6.2 +neopool-modbus==4.7.0 # homeassistant.components.ness_alarm nessclient==1.3.1 @@ -2844,7 +2844,7 @@ python-xbox==0.2.2 pythonegardia==1.0.52 # homeassistant.components.uptime_kuma -pythonkuma==0.5.2 +pythonkuma==0.5.3 # homeassistant.components.tile pytile==2024.12.0 diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index df336f530df3c2..5c1752aa8a1b7f 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -93,6 +93,7 @@ "mock_window_covering_full", "mock_window_covering_lift", "mock_window_covering_pa_lift", + "mock_window_covering_pa_lift_null_tilt", "mock_window_covering_pa_tilt", "mock_window_covering_tilt", "onoff_light_with_levelcontrol_present", diff --git a/tests/components/matter/fixtures/nodes/mock_window_covering_pa_lift_null_tilt.json b/tests/components/matter/fixtures/nodes/mock_window_covering_pa_lift_null_tilt.json new file mode 100644 index 00000000000000..1508a2129fef05 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mock_window_covering_pa_lift_null_tilt.json @@ -0,0 +1,315 @@ +{ + "node_id": 400, + "date_commissioned": "2023-03-29T08:23:30.740085", + "last_interview": "2023-03-29T08:23:30.740087", + "interview_version": 2, + "available": true, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 29, 30, 31, 40, 42, 43, 44, 45, 48, 49, 50, 51, 54, 60, 62, 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/30/0": [], + "0/30/65532": 0, + "0/30/65533": 1, + "0/30/65528": [], + "0/30/65529": [], + "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Shelly", + "0/40/2": 4895, + "0/40/3": "Shelly 2PM Gen4", + "0/40/4": 12288, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "mock-shelly-2pm-gen4-null-tilt-0001", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "7630EF9998EDF03C", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/45/0": 0, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "TE9OR0FOLUlPVA==", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "TE9OR0FOLUlPVA==", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "hPcDB5/k", + "5": ["wKgIhg=="], + "6": ["/oAAAAAAAACG9wP//gef5A==", "JA4DsgZ+bsCG9wP//gef5A=="], + "7": 1 + } + ], + "0/51/1": 35, + "0/51/2": 123, + "0/51/3": 0, + "0/51/4": 6, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "mJfMGB1w", + "0/54/1": 0, + "0/54/2": 3, + "0/54/3": 1, + "0/54/4": -36, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "254": 2 + } + ], + "0/62/1": [ + { + "1": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQAkAgE3AycUBZIG4P1iqI0kFQEYJgRBkLUrJgXBw5YtNwYnFAWSBuD9YqiNJBUBGCQHASQIATAJQQRruztKRDFfiVjMY19sSsnKqBZJlZrQ/ClUtTYatvOZxbTC53iCqhwHaIJthMWs7ICwtSX1Vr5lGkzDXQjH/oQ6Nwo1ASkBGCQCYDAEFJd2wRMLYsFFA1PRCdMviVipH3OWMAUUl3bBEwtiwUUDU9EJ0y+JWKkfc5YYMAtASJa3FJ84kws+OOWNEMgRvcZA/d0AJVmmoqoWrorxxfpVKujZuN8Kc193rwBckfxd69s3OS1y8HCZTtooCemIpBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEUsyszWxq0GlTQM3iyuL9LgBWj+6CZkKd0F887fdD5kIoNnM2Ew4HTXj6FmzQovu8+yxq9PtNv2e3Rb6Sl4B7RTcKNQEpARgkAmAwBBRQgiuTWL+gqw+f9Etus1wVMBtFgTAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQFyHXux9szIosC1gP+/1/7BX3PfGaX2GF172oHSAoMXnLJ7OawkzgWIykEj7oRIjKv3XRR27y3KhV83817SfCOkY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "room", + "1": "bedroom 2" + }, + { + "0": "orientation", + "1": "North" + }, + { + "0": "floor", + "1": "2" + }, + { + "0": "direction", + "1": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 514, + "1": 1 + } + ], + "1/29/1": [3, 4, 29, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/258/0": 0, + "1/258/7": 13, + "1/258/8": 0, + "1/258/10": 0, + "1/258/11": 6, + "1/258/13": 0, + "1/258/14": 6, + "1/258/23": 1, + "1/258/65532": 5, + "1/258/65533": 5, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 5, 8], + "1/258/65531": [ + 0, 7, 8, 9, 10, 11, 12, 13, 14, 15, 23, 26, 65528, 65529, 65531, 65532, + 65533 + ], + "1/258/9": null, + "1/258/12": null, + "1/258/15": null, + "1/258/26": 0 + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index cc8d967d7e23c5..03931ed096a02a 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -2436,6 +2436,57 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[mock_window_covering_pa_lift_null_tilt][binary_sensor.shelly_2pm_gen4_configuration_status-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.shelly_2pm_gen4_configuration_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Configuration status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Configuration status', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'config_status_operational', + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-1-WindowCoveringConfigStatusOperational-258-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[mock_window_covering_pa_lift_null_tilt][binary_sensor.shelly_2pm_gen4_configuration_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Shelly 2PM Gen4 Configuration status', + }), + 'context': , + 'entity_id': 'binary_sensor.shelly_2pm_gen4_configuration_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[mock_window_covering_pa_tilt][binary_sensor.mock_pa_tilt_window_covering_configuration_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 7989a86d7fa48d..f5a5f59a05fd32 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -3809,6 +3809,57 @@ 'state': 'unknown', }) # --- +# name: test_buttons[mock_window_covering_pa_lift_null_tilt][button.shelly_2pm_gen4_identify-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': 'button', + 'entity_category': , + 'entity_id': 'button.shelly_2pm_gen4_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[mock_window_covering_pa_lift_null_tilt][button.shelly_2pm_gen4_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'identify', + : 'Shelly 2PM Gen4 Identify', + }), + 'context': , + 'entity_id': 'button.shelly_2pm_gen4_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[onoff_light_with_levelcontrol_present][button.d215s_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index cabd34f7b4460a..692e4cabead60b 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -269,6 +269,60 @@ 'state': 'open', }) # --- +# name: test_covers[mock_window_covering_pa_lift_null_tilt][cover.shelly_2pm_gen4-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.shelly_2pm_gen4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-1-MatterCoverPositionAwareLiftAndTilt-258-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[mock_window_covering_pa_lift_null_tilt][cover.shelly_2pm_gen4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 100, + : 'shade', + : 'Shelly 2PM Gen4', + : False, + : , + }), + 'context': , + 'entity_id': 'cover.shelly_2pm_gen4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_covers[mock_window_covering_pa_tilt][cover.mock_pa_tilt_window_covering-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 9943ba15392b33..f5d84ad10a0057 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -25721,6 +25721,286 @@ 'state': '-36', }) # --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_boot_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.shelly_2pm_gen4_boot_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boot reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boot reason', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'boot_reason', + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-0-GeneralDiagnosticsBootReason-51-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_boot_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Shelly 2PM Gen4 Boot reason', + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'context': , + 'entity_id': 'sensor.shelly_2pm_gen4_boot_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'software_reset', + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_reboot_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.shelly_2pm_gen4_reboot_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reboot count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reboot count', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot_count', + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-0-GeneralDiagnosticsRebootCount-51-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_reboot_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Shelly 2PM Gen4 Reboot count', + : , + }), + 'context': , + 'entity_id': 'sensor.shelly_2pm_gen4_reboot_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_target_opening_position-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.shelly_2pm_gen4_target_opening_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target opening position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target opening position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_covering_target_position', + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-1-TargetPositionLiftPercent100ths-258-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_target_opening_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Shelly 2PM Gen4 Target opening position', + : , + }), + 'context': , + 'entity_id': 'sensor.shelly_2pm_gen4_target_opening_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_uptime-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.shelly_2pm_gen4_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-0-GeneralDiagnosticsUpTime-51-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Shelly 2PM Gen4 Uptime', + }), + 'context': , + 'entity_id': 'sensor.shelly_2pm_gen4_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T13:57:57+00:00', + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_wi_fi_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.shelly_2pm_gen4_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi RSSI', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_rssi', + 'unique_id': '00000000000004D2-0000000000000190-MatterNodeDevice-0-WiFiDiagnosticsRssi-54-4', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[mock_window_covering_pa_lift_null_tilt][sensor.shelly_2pm_gen4_wi_fi_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'Shelly 2PM Gen4 Wi-Fi RSSI', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.shelly_2pm_gen4_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-36', + }) +# --- # name: test_sensors[mock_window_covering_pa_tilt][sensor.mock_pa_tilt_window_covering_reboot_count-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index b7a9b03224ed50..780efca584475c 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -51,6 +51,7 @@ async def test_covers( [ ("mock_window_covering_lift", "cover.mock_lift_window_covering"), ("mock_window_covering_pa_lift", "cover.longan_link_wncv_da01"), + ("mock_window_covering_pa_lift_null_tilt", "cover.shelly_2pm_gen4"), ("mock_window_covering_tilt", "cover.mock_tilt_window_covering"), ("mock_window_covering_pa_tilt", "cover.mock_pa_tilt_window_covering"), ("mock_window_covering_full", "cover.mock_full_window_covering"), @@ -121,6 +122,7 @@ async def test_cover( [ ("mock_window_covering_lift", "cover.mock_lift_window_covering"), ("mock_window_covering_pa_lift", "cover.longan_link_wncv_da01"), + ("mock_window_covering_pa_lift_null_tilt", "cover.shelly_2pm_gen4"), ("mock_window_covering_full", "cover.mock_full_window_covering"), ], ) @@ -248,6 +250,30 @@ async def test_cover_position_aware_lift( assert state.state == CoverState.CLOSED +@pytest.mark.parametrize( + ("node_fixture", "entity_id"), + [ + ("mock_window_covering_pa_lift_null_tilt", "cover.shelly_2pm_gen4"), + ], +) +async def test_cover_position_aware_lift_null_tilt( + hass: HomeAssistant, + matter_node: MatterNode, + entity_id: str, +) -> None: + """Test null tilt attribute while tilt is disabled (Shelly 2PM Gen4). + + See home-assistant/core#149876. + """ + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes["supported_features"] & CoverEntityFeature.SET_TILT_POSITION + == 0 + ) + assert "current_tilt_position" not in state.attributes + + @pytest.mark.parametrize( ("node_fixture", "entity_id"), [ diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 6500d285d37898..2df52a3971dd17 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -119,3 +119,75 @@ async def test_generic_switch_multi_node( ) state = hass.states.get("event.mock_generic_switch_button_1") assert state.attributes[ATTR_EVENT_TYPE] == "multi_press_2" + + +@pytest.mark.parametrize("node_fixture", ["mock_generic_switch"]) +async def test_generic_switch_unknown_event( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test an event id that is not a switch event is ignored.""" + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_node.node_id, + endpoint_id=1, + cluster_id=59, + event_id=1, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data=None, + ), + ) + state = hass.states.get("event.mock_generic_switch_button") + last_event = state.state + + # an event id outside of the switch event id space + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_node.node_id, + endpoint_id=1, + cluster_id=59, + event_id=7, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data=None, + ), + ) + + state = hass.states.get("event.mock_generic_switch_button") + assert state.state == last_event + assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" + + # an event id that another cluster on the same endpoint uses, here the + # door lock LockOperation event, which shares its id with a long press + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_node.node_id, + endpoint_id=1, + cluster_id=257, + event_id=2, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data=None, + ), + ) + + state = hass.states.get("event.mock_generic_switch_button") + assert state.state == last_event + assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 5b1a0806e95554..4b67e8c7b8c745 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -1,8 +1,10 @@ """Test Matter lights.""" +from typing import Any from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models.node import MatterNode import pytest from syrupy.assertion import SnapshotAssertion @@ -486,3 +488,80 @@ async def test_extended_color_light( ] ) matter_client.send_device_command.reset_mock() + + +@pytest.mark.parametrize("node_fixture", ["color_temperature_light"]) +async def test_light_null_color_temperature( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test a light that stops reporting a color temperature.""" + entity_id = "light.mock_color_temperature_light" + + set_node_attribute(matter_node, 1, 768, 7, 300) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["color_temp_kelvin"] == 3333 + + set_node_attribute(matter_node, 1, 768, 7, NullValue) + await trigger_subscription_callback(hass, matter_client) + + # the last known value is not the current one, so it is not reported + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["color_temp_kelvin"] is None + + +@pytest.mark.parametrize( + "color_mode", + [ + pytest.param(NullValue, id="null"), + pytest.param(255, id="out_of_range"), + ], +) +@pytest.mark.parametrize("node_fixture", ["extended_color_light"]) +async def test_light_unexpected_color_mode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, + color_mode: Any, +) -> None: + """Test a light that reports a color mode we cannot map.""" + entity_id = "light.mock_extended_color_light" + + set_node_attribute(matter_node, 1, 768, 8, 0) + set_node_attribute(matter_node, 1, 8, 0, 128) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["color_mode"] == ColorMode.HS + + set_node_attribute(matter_node, 1, 768, 8, color_mode) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + # the color the light is showing is anyone's guess, but it is still a light + assert state.attributes["color_mode"] == ColorMode.UNKNOWN + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, "brightness": 128}, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.LevelControl.Commands.MoveToLevelWithOnOff( + level=128, + transitionTime=0, + ), + ) diff --git a/tests/components/modbus/fixtures/configuration_no_entities.yaml b/tests/components/modbus/fixtures/configuration_no_entities.yaml new file mode 100644 index 00000000000000..0f12ac886860b8 --- /dev/null +++ b/tests/components/modbus/fixtures/configuration_no_entities.yaml @@ -0,0 +1,5 @@ +modbus: + type: "tcp" + host: "testHost" + port: 5001 + name: "testModbus" diff --git a/tests/components/modbus/test_websocket_api.py b/tests/components/modbus/test_websocket_api.py index 65941f19720dc1..a827961209491a 100644 --- a/tests/components/modbus/test_websocket_api.py +++ b/tests/components/modbus/test_websocket_api.py @@ -1,20 +1,25 @@ """Test the Modbus websocket API.""" from collections.abc import Callable, Generator +from typing import Any from unittest.mock import AsyncMock, patch from modbus_connection import ModbusTcpParams from modbus_connection.tmodbus import ModbusConnection import pytest +from homeassistant import config as hass_config from homeassistant.components.modbus import async_get_unit +from homeassistant.components.modbus.const import DATA_MODBUS_HUBS from homeassistant.config_entries import ConfigFlow +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, MockModule, + get_fixture_path, mock_config_flow, mock_integration, mock_platform, @@ -23,6 +28,35 @@ type ConsumerFactory = Callable[[], MockConfigEntry] +YAML_HUB_NAME = "yaml_hub" + +# The host is given in mixed case, as a shared connection folds the one it is +# keyed by to lower case +TCP_TRANSPORT = {"type": "tcp", "host": "Device.Local", "port": 502} + +SERIAL_TRANSPORT = { + "type": "serial", + "port": "/dev/ttyUSB0", + "baudrate": 9600, + "bytesize": 8, + "method": "rtu", + "parity": "E", + "stopbits": 1, +} + + +def yaml_hub(transport: dict[str, Any]) -> dict[str, Any]: + """Return a hub config on *transport*, with sensors on three units.""" + return { + "name": YAML_HUB_NAME, + "sensors": [ + {"name": "on unit 3", "address": 10, "slave": 3}, + {"name": "on unit 2", "address": 11, "device_address": 2}, + {"name": "on the default unit", "address": 12}, + ], + **transport, + } + class MockFlow(ConfigFlow): """A config flow for the integration standing in for a consumer.""" @@ -76,6 +110,7 @@ async def test_list_connections( { "endpoint": ["tcp", "device.local", 502], "connected": False, + "source": "config_entry", "units": {first.entry_id: [1], second.entry_id: [2]}, } ] @@ -104,6 +139,7 @@ async def test_a_connection_that_is_up_reports_itself_connected( { "endpoint": ["tcp", "device.local", 502], "connected": True, + "source": "config_entry", "units": {entry.entry_id: [1]}, } ] @@ -185,3 +221,157 @@ async def test_one_entry_holding_two_units( result = (await client.receive_json())["result"] assert result["connections"][0]["units"] == {entry.entry_id: [1, 2]} + + +async def test_a_yaml_hub_is_listed_with_the_units_its_entities_address( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_pymodbus: AsyncMock, +) -> None: + """A hub is flagged as YAML and keyed by its name, having no config entry.""" + mock_pymodbus.connected = True + assert await async_setup_component( + hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]} + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "modbus/connections/list"}) + result = (await client.receive_json())["result"] + + assert result == { + "connections": [ + { + "endpoint": ["tcp", "device.local", 502], + "connected": True, + "source": "yaml", + "units": {YAML_HUB_NAME: [1, 2, 3]}, + } + ] + } + + +async def test_a_closed_yaml_hub_reports_itself_not_connected( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_pymodbus: AsyncMock, +) -> None: + """The stop action drops the client, which is no longer a link.""" + mock_pymodbus.connected = True + assert await async_setup_component( + hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]} + ) + await hass.data[DATA_MODBUS_HUBS][YAML_HUB_NAME].async_close() + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "modbus/connections/list"}) + result = (await client.receive_json())["result"] + + assert result["connections"][0]["connected"] is False + + +async def test_a_yaml_hub_is_listed_beside_a_connection_to_the_same_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + consumer: ConsumerFactory, + mock_pymodbus: AsyncMock, +) -> None: + """A hub is a link of its own, so it is never folded into a shared one.""" + mock_pymodbus.connected = False + assert await async_setup_component( + hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]} + ) + + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + async_get_unit(hass, entry, ModbusTcpParams(host="device.local", port=502), 7) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "modbus/connections/list"}) + result = (await client.receive_json())["result"] + + assert result["connections"] == [ + { + "endpoint": ["tcp", "device.local", 502], + "connected": False, + "source": "config_entry", + "units": {entry.entry_id: [7]}, + }, + { + "endpoint": ["tcp", "device.local", 502], + "connected": False, + "source": "yaml", + "units": {YAML_HUB_NAME: [1, 2, 3]}, + }, + ] + + +@pytest.mark.parametrize( + ("transport", "endpoint"), + [ + pytest.param(TCP_TRANSPORT, ["tcp", "device.local", 502], id="tcp"), + pytest.param( + {**TCP_TRANSPORT, "type": "rtuovertcp"}, + ["tcp", "device.local", 502], + id="rtuovertcp", + ), + pytest.param( + {**TCP_TRANSPORT, "type": "udp"}, ["udp", "device.local", 502], id="udp" + ), + pytest.param(SERIAL_TRANSPORT, ["serial", "/dev/ttyUSB0"], id="serial"), + ], +) +async def test_the_endpoint_of_a_yaml_hub_follows_its_transport( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_pymodbus: AsyncMock, + transport: dict[str, Any], + endpoint: list[str | int], +) -> None: + """A hub is keyed by the device it addresses, as a shared connection is. + + An RTU-over-TCP hub keys as TCP: the framing differs, the device does not. + """ + mock_pymodbus.connected = True + assert await async_setup_component( + hass, "modbus", {"modbus": [yaml_hub(transport)]} + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "modbus/connections/list"}) + result = (await client.receive_json())["result"] + + assert result["connections"][0]["endpoint"] == endpoint + + +@pytest.mark.parametrize( + "fixture", + [ + pytest.param("configuration_empty.yaml", id="modbus gone from yaml"), + pytest.param("configuration_no_entities.yaml", id="hub without entities"), + ], +) +async def test_a_yaml_hub_a_reload_leaves_behind_is_not_listed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_pymodbus: AsyncMock, + fixture: str, +) -> None: + """A reload that sets no hub up again leaves no connection behind. + + The reload closes the hubs before reading the new config, so one it does + not set up again is a link to a device nothing talks to. + """ + mock_pymodbus.connected = True + assert await async_setup_component( + hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]} + ) + + yaml_path = get_fixture_path(fixture, "modbus") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call("modbus", SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "modbus/connections/list"}) + + assert (await client.receive_json())["result"] == {"connections": []} diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 821dda261cf820..cd3b0b01d8faba 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -1611,6 +1611,39 @@ async def test_websocket_config( assert msg["error"]["code"] == "not_found" +async def test_websocket_config_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test config command requires admin.""" + config = { + "alias": "hello", + "sequence": [{"action": "light.turn_on"}], + } + assert await async_setup_component( + hass, + DOMAIN, + { + "script": { + "hello": config, + }, + }, + ) + client = await hass_ws_client(hass, hass_read_only_access_token) + await client.send_json( + { + "id": 5, + "type": "script/config", + "entity_id": "script.hello", + } + ) + + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + async def test_script_service_changed_entity_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/components/starlink/test_binary_sensor.py b/tests/components/starlink/test_binary_sensor.py new file mode 100644 index 00000000000000..c048a9552b6743 --- /dev/null +++ b/tests/components/starlink/test_binary_sensor.py @@ -0,0 +1,97 @@ +"""Tests Starlink binary sensors.""" + +from copy import deepcopy +from unittest.mock import patch + +import pytest + +from homeassistant.components.starlink.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, STATE_OFF, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType + +from .patchers import ( + HISTORY_STATS_SUCCESS_PATCHER, + LOCATION_DATA_SUCCESS_PATCHER, + SLEEP_DATA_SUCCESS_PATCHER, + STATUS_DATA_FIXTURE, + STATUS_DATA_TARGET, +) + +from tests.common import MockConfigEntry + +# Alerts that a dish which does not support roaming stops reporting. The +# library builds the alert dict from the fields the dish actually sends, so +# these keys are simply absent instead of being False. +MISSING_ALERTS = ( + "alert_roaming", + "alert_unexpected_location", + "alert_is_power_save_idle", +) + + +async def setup_integration( + hass: HomeAssistant, status_data: JsonArrayType +) -> MockConfigEntry: + """Set up the Starlink integration with the given status data.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4:0000"}) + + with ( + LOCATION_DATA_SUCCESS_PATCHER, + SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, + patch(STATUS_DATA_TARGET, return_value=status_data), + ): + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +@pytest.mark.parametrize( + "entity_id", + [ + "binary_sensor.starlink_roaming_mode", + "binary_sensor.starlink_unexpected_location", + "binary_sensor.starlink_sleep", + ], +) +async def test_alert_reported_by_dish(hass: HomeAssistant, entity_id: str) -> None: + """Test that an alert the dish reports is used as the state.""" + await setup_integration(hass, deepcopy(STATUS_DATA_FIXTURE)) + + assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.mark.parametrize( + "entity_id", + [ + "binary_sensor.starlink_roaming_mode", + "binary_sensor.starlink_unexpected_location", + "binary_sensor.starlink_sleep", + ], +) +async def test_alert_not_reported_by_dish(hass: HomeAssistant, entity_id: str) -> None: + """Test that an alert the dish omits is unknown instead of raising.""" + status_data = deepcopy(STATUS_DATA_FIXTURE) + for alert in MISSING_ALERTS: + del status_data[2][alert] + + await setup_integration(hass, status_data) + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + +async def test_remaining_alerts_unaffected(hass: HomeAssistant) -> None: + """Test that alerts the dish still reports keep working.""" + status_data = deepcopy(STATUS_DATA_FIXTURE) + for alert in MISSING_ALERTS: + del status_data[2][alert] + status_data[2]["alert_motors_stuck"] = True + + await setup_integration(hass, status_data) + + assert hass.states.get("binary_sensor.starlink_motors_stuck").state == "on" + assert hass.states.get("binary_sensor.starlink_thermal_throttle").state == STATE_OFF diff --git a/tests/components/uptime_kuma/snapshots/test_sensor.ambr b/tests/components/uptime_kuma/snapshots/test_sensor.ambr index e1e38d069daf57..b82334c99ac2bf 100644 --- a/tests/components/uptime_kuma/snapshots/test_sensor.ambr +++ b/tests/components/uptime_kuma/snapshots/test_sensor.ambr @@ -71,6 +71,7 @@ 'system_service', 'pm2', 'real_browser', + 'sftp', 'group', 'push', 'manual', @@ -143,6 +144,7 @@ 'system_service', 'pm2', 'real_browser', + 'sftp', 'group', 'push', 'manual', @@ -776,6 +778,7 @@ 'system_service', 'pm2', 'real_browser', + 'sftp', 'group', 'push', 'manual', @@ -848,6 +851,7 @@ 'system_service', 'pm2', 'real_browser', + 'sftp', 'group', 'push', 'manual', @@ -1586,6 +1590,7 @@ 'system_service', 'pm2', 'real_browser', + 'sftp', 'group', 'push', 'manual', @@ -1658,6 +1663,7 @@ 'system_service', 'pm2', 'real_browser', + 'sftp', 'group', 'push', 'manual', diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index a2b401398ec3fb..4789bc13e82a6c 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1385,15 +1385,25 @@ async def test_state_raises(hass: HomeAssistant) -> None: test.async_check() -async def test_state_for(hass: HomeAssistant) -> None: - """Test state with duration.""" +@pytest.mark.parametrize( + "req_state", + [ + pytest.param("100", id="scalar"), + pytest.param(["100"], id="single_item_list"), + ], +) +async def test_state_for(hass: HomeAssistant, req_state: str | list[str]) -> None: + """Test state with duration. + + A single-element list `state` is equivalent to the scalar form. + """ config = { "condition": "and", "conditions": [ { "condition": "state", "entity_id": ["sensor.temperature"], - "state": "100", + "state": req_state, "for": {"seconds": 5}, }, ], @@ -1462,6 +1472,56 @@ async def test_state_for_invalid_template( assert not test.async_check() +@pytest.mark.parametrize( + ("extra_config", "error"), + [ + pytest.param( + {"attribute": "battery_level"}, + r"Cannot use 'for' with an attribute", + id="attribute", + ), + pytest.param( + {"state": ["100", "200"]}, + r"Cannot use 'for' with a list of states", + id="list_of_states", + ), + pytest.param( + {"state": []}, + r"Cannot use 'for' with a list of states", + id="empty_list", + ), + pytest.param( + {"state": "input_number.threshold"}, + r"Cannot use 'for' with a state referencing an entity", + id="state_from_entity", + ), + pytest.param( + {"state": ["input_number.threshold"]}, + r"Cannot use 'for' with a state referencing an entity", + id="single_item_list_from_entity", + ), + ], +) +def test_state_for_not_allowed(extra_config: dict[str, Any], error: str) -> None: + """Test state condition rejects `for` with unsupported `state`/`attribute`. + + `for` is anchored to the entity's last_changed, which reflects a single + current state. It therefore cannot be combined with an attribute, a list + that is not a single state, or a state resolved from another entity (even as + a single-element list). A single-element literal list behaves like the + scalar form (see `test_state_for`). + """ + config = { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + "for": {"seconds": 5}, + **extra_config, + } + with pytest.raises(vol.Invalid, match=error): + cv.CONDITION_SCHEMA(config) + + async def test_state_unknown_attribute(hass: HomeAssistant) -> None: """Test that state returns False on unknown attribute.""" # Unknown attribute @@ -1494,6 +1554,44 @@ async def test_state_unknown_attribute(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("req_state", "attribute_value", "expected"), + [ + # A list `state` is matched as alternatives, so the attribute value must + # equal one of the items; the list itself is never compared as a whole. + pytest.param(["a", "b"], "a", True, id="item_in_list"), + pytest.param(["a", "b"], ["a", "b"], False, id="list_is_not_an_item"), + # Nesting the list makes the list value itself one of the items to match. + pytest.param([["a", "b"]], ["a", "b"], True, id="list_in_list_of_lists"), + pytest.param([["a", "b"]], "a", False, id="scalar_not_in_list_of_lists"), + ], +) +async def test_state_attribute_list_matching( + hass: HomeAssistant, + req_state: list[Any], + attribute_value: str | list[str], + expected: bool, +) -> None: + """Test how a state-attribute condition matches against a list `state`. + + A list `state` is treated as alternatives (match any item), so a list-valued + attribute only matches when the list is nested as an item of `state`. This + documents the current behavior; the implementation is unchanged. + """ + config = { + "condition": "state", + "entity_id": "sensor.test", + "attribute": "options", + "state": req_state, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.test", "on", {"options": attribute_value}) + assert test.async_check() is expected + + async def test_state_multiple_entities(hass: HomeAssistant) -> None: """Test with multiple entities in condition.""" config = { diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 9d654bd7440f67..7ec8327ffdac33 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -49,9 +49,11 @@ def _downgrade_device_registry_deprecation_reports( """Keep the deprecated device registry APIs from raising in tests. async_get_device, async_is_composite_device_id, the config entry parameters and - merge_connections/merge_identifiers parameters of async_update_device, and via_device - on async_get_or_create are deprecated and raise for core and core integration callers, - disable them here so we can run tests without triggering deprecation errors. + merge_connections/merge_identifiers parameters of async_update_device, via_device + on async_get_or_create, and the config_entries, config_entries_subentries and + primary_config_entry properties are deprecated and raise for core and core + integration callers, disable them here so we can run tests without triggering + deprecation errors. Tests which use `mock_integration_frame` will not be affected by this fixture, so they can test the deprecation. @@ -8621,6 +8623,155 @@ async def test_single_config_entry_and_compat_properties( assert device.primary_config_entry == entry.entry_id +_DEPRECATED_CONFIG_ENTRIES_PROPERTIES = [ + "config_entries", + "config_entries_subentries", + "primary_config_entry", +] + + +@pytest.mark.parametrize("property_name", _DEPRECATED_CONFIG_ENTRIES_PROPERTIES) +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deprecated_config_entries_properties( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + property_name: str, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test the multi-config-entry compatibility properties are deprecated. + + They log for custom integrations and raise for core and core integrations. Use + config_entry_id and config_subentry_id instead. + """ + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, identifiers={("test", "1")} + ) + + what = f"accesses `DeviceEntry.{property_name}`" + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + getattr(device, property_name) + + assert caplog.text.count(what) == expected_log + + +@pytest.mark.parametrize("property_name", _DEPRECATED_CONFIG_ENTRIES_PROPERTIES) +@pytest.mark.parametrize( + "integration_frame_path", + [ + pytest.param("homeassistant/test_core", id="core"), + pytest.param( + "homeassistant/components/test_integration", id="core integration" + ), + pytest.param("custom_components/test_integration", id="custom integration"), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deprecated_config_entries_properties_composite_exempt( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + property_name: str, +) -> None: + """Test a restored composite device is exempt from the deprecation. + + A composite really does span several config entries, so the properties are the + correct API for it and reading them must not report, for any caller. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry._devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry._devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + composite = device_registry.async_get(old_id) + assert composite.is_composite_device is True + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + getattr(composite, property_name) + + assert f"DeviceEntry.{property_name}" not in caplog.text + + +@pytest.mark.parametrize( + "property_name", ["config_entries", "config_entries_subentries"] +) +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deprecated_config_entries_properties_deleted_device( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + property_name: str, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test the compatibility properties are deprecated on a deleted device too.""" + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, identifiers={("test", "1")} + ) + device_registry.async_remove_device(device.id) + deleted_device = device_registry._deleted_devices[device.id] + + what = f"accesses `DeletedDeviceEntry.{property_name}`" + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + getattr(deleted_device, property_name) + + assert caplog.text.count(what) == expected_log + + async def test_identifiers_unique_per_config_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -12446,6 +12597,48 @@ async def test_child_device_config_entry_compat_shims( assert child_device.primary_config_entry == mock_config_entry.entry_id +@pytest.mark.parametrize("property_name", _DEPRECATED_CONFIG_ENTRIES_PROPERTIES) +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deprecated_config_entries_properties_child_device( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + property_name: str, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test the compatibility properties are deprecated on a child device too.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + what = f"accesses `ChildDeviceEntry.{property_name}`" + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + getattr(child_device, property_name) + + assert caplog.text.count(what) == expected_log + + @pytest.mark.usefixtures("hass") async def test_deleted_child_device_restored_as_device_clears_device_disable( device_registry: dr.DeviceRegistry,