From 09104fca4d3647c6c69c6b93573096079d64b591 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 14 Jul 2025 11:26:37 +0200 Subject: [PATCH 1/8] Fix hide empty sections in mqtt subentry flows (#148692) --- homeassistant/components/mqtt/config_flow.py | 3 ++ tests/components/mqtt/test_config_flow.py | 51 +++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ee451b5f81d24..a3cf2d1d12fe8 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2114,6 +2114,9 @@ def get_default(field_details: PlatformField) -> Any: if schema_section is None: data_schema.update(data_schema_element) continue + if not data_schema_element: + # Do not show empty sections + continue collapsed = ( not any( (default := data_schema_fields[str(option)].default) is vol.UNDEFINED diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 9386f1da32c68..77c740019398b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3220,7 +3220,7 @@ async def test_subentry_configflow( "url": learn_more_url(component["platform"]), } - # Process entity details setep + # Process entity details step assert result["step_id"] == "entity_platform_config" # First test validators if set of test @@ -4212,3 +4212,52 @@ async def test_subentry_reconfigure_availablity( "payload_available": "1", "payload_not_available": "0", } + + +async def test_subentry_configflow_section_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow sections are hidden when they have no configurable options.""" + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Bla", "mqtt_settings": {"qos": 1}}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"platform": "fan"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == { + "mqtt_device": "Bla", + "platform": "fan", + "entity": "Bla", + "url": learn_more_url("fan"), + } + + # Process entity details step + assert result["step_id"] == "entity_platform_config" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"fan_feature_speed": True}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "mqtt_platform_config" + + # Check mqtt platform config flow sections from data schema + data_schema = result["data_schema"].schema + assert "fan_speed_settings" in data_schema + assert "fan_preset_mode_settings" not in data_schema From 21b1122f83996bac4435834620f56930da90f1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20M=C3=A5rtensson?= Date: Mon, 14 Jul 2025 11:43:02 +0200 Subject: [PATCH 2/8] Add test fixture for Tuya cover (#148660) --- tests/components/tuya/__init__.py | 5 ++ .../am43_corded_motor_zigbee_cover.json | 61 +++++++++++++++++++ .../components/tuya/snapshots/test_cover.ambr | 51 ++++++++++++++++ .../tuya/snapshots/test_select.ambr | 57 +++++++++++++++++ tests/components/tuya/test_cover.py | 33 ++++++++++ 5 files changed, 207 insertions(+) create mode 100644 tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 80e21e84c2e68..09606c7e11697 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,6 +13,11 @@ from tests.common import MockConfigEntry DEVICE_MOCKS = { + "am43_corded_motor_zigbee_cover": [ + # https://github.com/home-assistant/core/issues/71242 + Platform.SELECT, + Platform.COVER, + ], "clkg_curtain_switch": [ # https://github.com/home-assistant/core/issues/136055 Platform.COVER, diff --git a/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json b/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json new file mode 100644 index 0000000000000..14d1c39fc949e --- /dev/null +++ b/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json @@ -0,0 +1,61 @@ +{ + "id": "zah67ekd", + "name": "Kitchen Blinds", + "category": "cl", + "product_id": "zah67ekd", + "product_name": "AM43拉绳电机-Zigbee", + "online": true, + "function": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "percent_state": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + }, + "work_state": { + "type": "Enum", + "value": { "range": ["opening", "closing"] } + }, + "situation_set": { + "type": "Enum", + "value": { "range": ["fully_open", "fully_close"] } + }, + "fault": { + "type": "Bitmap", + "value": { "label": ["motor_fault"] } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "percent_state": 52, + "control_back_mode": "forward", + "work_state": "closing", + "situation_set": "fully_open", + "fault": 0 + } +} diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 843ee2db6b0ee..1ab635919caf0 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.kitchen_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.zah67ekdcontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 48, + 'device_class': 'curtain', + 'friendly_name': 'Kitchen Blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 519ac33fb9f27..e8337fb4fbf1a 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'forward', + 'back', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motor mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_motor_mode', + 'unique_id': 'tuya.zah67ekdcontrol_back_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Blinds Motor mode', + 'options': list([ + 'forward', + 'back', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'forward', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 6f94896c8c737..4550ed9d6f435 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -55,3 +55,36 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["am43_corded_motor_zigbee_cover"], +) +@pytest.mark.parametrize( + ("percent_control", "percent_state"), + [ + (100, 52), + (0, 100), + (50, 25), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_percent_state_on_cover( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + percent_control: int, + percent_state: int, +) -> None: + """Test percent_state attribute on the cover entity.""" + mock_device.status["percent_control"] = percent_control + # 100 is closed and 0 is open for Tuya covers + mock_device.status["percent_state"] = 100 - percent_state + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + cover_state = hass.states.get("cover.kitchen_blinds_curtain") + assert cover_state is not None, "cover.kitchen_blinds_curtain does not exist" + assert cover_state.attributes["current_position"] == percent_state From 50047f0a4e67de5285ddc270276d58f4f1923225 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:46:17 +0200 Subject: [PATCH 3/8] Add new device class for absolute humidity (#148567) --- homeassistant/components/number/const.py | 10 ++++++++++ homeassistant/components/number/icons.json | 3 +++ homeassistant/components/number/strings.json | 3 +++ homeassistant/components/sensor/const.py | 14 ++++++++++++++ .../components/sensor/device_condition.py | 3 +++ homeassistant/components/sensor/device_trigger.py | 3 +++ homeassistant/components/sensor/icons.json | 3 +++ homeassistant/components/sensor/strings.json | 5 +++++ homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 7 +++++-- tests/components/sensor/common.py | 2 ++ tests/components/sensor/test_device_condition.py | 2 +- tests/components/sensor/test_device_trigger.py | 2 +- tests/util/test_unit_conversion.py | 8 ++++++++ 14 files changed, 62 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 1b41146cd2a53..bfb74d621c394 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -78,6 +79,11 @@ class NumberDeviceClass(StrEnum): """Device class for numbers.""" # NumberDeviceClass should be aligned with SensorDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ APPARENT_POWER = "apparent_power" """Apparent power. @@ -452,6 +458,10 @@ class NumberDeviceClass(StrEnum): DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { + NumberDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), NumberDeviceClass.AQI: {None}, NumberDeviceClass.AREA: set(UnitOfArea), diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index dcce09984bdea..482b4bc679321 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:ray-vertex" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 998b9ffba3809..1e4290f1d75f6 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -31,6 +31,9 @@ } } }, + "absolute_humidity": { + "name": "[%key:component::sensor::entity_component::absolute_humidity::name%]" + }, "apparent_power": { "name": "[%key:component::sensor::entity_component::apparent_power::name%]" }, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 994c29b6bbf7d..5f9d5ec9ca09d 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -107,6 +108,12 @@ class SensorDeviceClass(StrEnum): """ # Numerical device classes, these should be aligned with NumberDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ + APPARENT_POWER = "apparent_power" """Apparent power. @@ -521,6 +528,7 @@ class SensorStateClass(StrEnum): STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter, SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, @@ -554,6 +562,10 @@ class SensorStateClass(StrEnum): } DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), SensorDeviceClass.AQI: {None}, SensorDeviceClass.AREA: set(UnitOfArea), @@ -651,6 +663,7 @@ class SensorStateClass(StrEnum): # have 0 decimals, that one should be used and not mW, even though mW also should have # 0 decimals. Otherwise the smaller units will have more decimals than expected. UNITS_PRECISION = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: (CONCENTRATION_GRAMS_PER_CUBIC_METER, 1), SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0), SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0), @@ -691,6 +704,7 @@ class SensorStateClass(StrEnum): } DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AREA: set(SensorStateClass), diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 2b1eb350c3eea..1ad5fe12e99da 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -33,6 +33,7 @@ DEVICE_CLASS_NONE = "none" +CONF_IS_ABSOLUTE_HUMIDITY = "is_absolute_humidity" CONF_IS_APPARENT_POWER = "is_apparent_power" CONF_IS_AQI = "is_aqi" CONF_IS_AREA = "is_area" @@ -88,6 +89,7 @@ CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_IS_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}], @@ -159,6 +161,7 @@ vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_IS_ABSOLUTE_HUMIDITY, CONF_IS_APPARENT_POWER, CONF_IS_AQI, CONF_IS_AREA, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d44611a49dbe8..ae2125962e8d4 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -32,6 +32,7 @@ DEVICE_CLASS_NONE = "none" +CONF_ABSOLUTE_HUMIDITY = "absolute_humidity" CONF_APPARENT_POWER = "apparent_power" CONF_AQI = "aqi" CONF_AREA = "area" @@ -87,6 +88,7 @@ CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}], @@ -159,6 +161,7 @@ vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_ABSOLUTE_HUMIDITY, CONF_APPARENT_POWER, CONF_AQI, CONF_AREA, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 05311868fc66d..cea955e061c45 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:eye" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index ecaeb2504d95c..c69bf99eff0c1 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -2,6 +2,7 @@ "title": "Sensor", "device_automation": { "condition_type": { + "is_absolute_humidity": "Current {entity_name} absolute humidity", "is_apparent_power": "Current {entity_name} apparent power", "is_aqi": "Current {entity_name} air quality index", "is_area": "Current {entity_name} area", @@ -57,6 +58,7 @@ "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { + "absolute_humidity": "{entity_name} absolute humidity changes", "apparent_power": "{entity_name} apparent power changes", "aqi": "{entity_name} air quality index changes", "area": "{entity_name} area changes", @@ -148,6 +150,9 @@ "duration": { "name": "Duration" }, + "absolute_humidity": { + "name": "Absolute humidity" + }, "apparent_power": { "name": "Apparent power" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index e6da8ba4a6973..6b4f16c316f1f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -910,6 +910,7 @@ class UnitOfPrecipitationDepth(StrEnum): # Concentration units +CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index d0830d1f8bb51..5bde108dfc159 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -7,6 +7,7 @@ from math import floor, log10 from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -693,12 +694,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter): UNIT_CLASS = "concentration" _UNIT_CONVERSION: dict[str | None, float] = { - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000.0, # 1000 µg/m³ = 1 mg/m³ - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1.0, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.0, # 1000 µg/m³ = 1 mg/m³ + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³ + CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0, } VALID_UNITS = { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_GRAMS_PER_CUBIC_METER, } diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 2df13b697da5d..1b9810a82509b 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -7,6 +7,7 @@ ) from homeassistant.components.sensor.const import DEVICE_CLASS_STATE_CLASSES from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, DEGREE, @@ -44,6 +45,7 @@ from tests.common import MockEntity UNITS_OF_MEASUREMENT = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: CONCENTRATION_GRAMS_PER_CUBIC_METER, SensorDeviceClass.APPARENT_POWER: UnitOfApparentPower.VOLT_AMPERE, SensorDeviceClass.AQI: None, SensorDeviceClass.AREA: UnitOfArea.SQUARE_METERS, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 1c87845c2c7f3..da69610f4c5dc 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -125,7 +125,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert len(conditions) == 54 + assert len(conditions) == 55 assert conditions == unordered(expected_conditions) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index bb57797e6ddc1..c39a5216f0f8f 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -126,7 +126,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 54 + assert len(triggers) == 55 assert triggers == unordered(expected_triggers) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 7d0eb7226a0b9..537cfb33c3142 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -8,6 +8,7 @@ import pytest from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -762,6 +763,13 @@ 2000, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), + # 3 g/m³ = 3000 mg/m³ + ( + 3, + CONCENTRATION_GRAMS_PER_CUBIC_METER, + 3000, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + ), ], VolumeConverter: [ (5, UnitOfVolume.LITERS, 1.32086, UnitOfVolume.GALLONS), From dcbdce4b2b0611439c82e2e8d8cd4743174a682f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 11:57:27 +0200 Subject: [PATCH 4/8] Improve docstrings of event helpers related to state changes (#148722) --- homeassistant/helpers/event.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3b959337b6d25..f2dfb7250f714 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -316,6 +316,10 @@ def async_track_state_change_event( Unlike async_track_state_change, async_track_state_change_event passes the full event to the callback. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + In order to avoid having to iterate a long list of EVENT_STATE_CHANGED and fire and create a job for each one, we keep a dict of entity ids that @@ -866,6 +870,10 @@ def async_track_state_change_filtered( ) -> _TrackStateChangeFiltered: """Track state changes with a TrackStates filter that can be updated. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + Args: hass: Home assistant object. @@ -1348,9 +1356,13 @@ def async_track_template_result( then whenever the output from the template changes. The template will be reevaluated if any states referenced in the last run of the template change, or if manually triggered. If the result of the - evaluation is different from the previous run, the listener is passed + evaluation is different from the previous run, the action is passed the result. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + If the template results in an TemplateError, this will be returned to the listener the first time this happens but not for subsequent errors. Once the template returns to a non-error condition the result is sent From 25f64a2f3698b26023bd477efda5f223f7425306 Mon Sep 17 00:00:00 2001 From: ekutner <5628151+ekutner@users.noreply.github.com> Date: Mon, 14 Jul 2025 13:11:36 +0300 Subject: [PATCH 5/8] Do not specify the code_format when a code is not required (#148698) --- .../components/risco/alarm_control_panel.py | 10 +- .../risco/test_alarm_control_panel.py | 147 +++++++++++++++++- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index 2472baa932efa..f485c92377608 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -82,7 +82,6 @@ async def async_setup_entry( class RiscoAlarm(AlarmControlPanelEntity): """Representation of a Risco cloud partition.""" - _attr_code_format = CodeFormat.NUMBER _attr_has_entity_name = True _attr_name = None @@ -100,8 +99,13 @@ def __init__( self._partition_id = partition_id self._partition = partition self._code = code - self._attr_code_arm_required = options[CONF_CODE_ARM_REQUIRED] - self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] + arm_required = options[CONF_CODE_ARM_REQUIRED] + disarm_required = options[CONF_CODE_DISARM_REQUIRED] + self._attr_code_arm_required = arm_required + self._code_disarm_required = disarm_required + self._attr_code_format = ( + CodeFormat.NUMBER if arm_required or disarm_required else None + ) self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] for state in self._ha_to_risco: diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py index 8caef1fbfc457..d27d39071a0c5 100644 --- a/tests/components/risco/test_alarm_control_panel.py +++ b/tests/components/risco/test_alarm_control_panel.py @@ -35,6 +35,7 @@ SECOND_LOCAL_ENTITY_ID = "alarm_control_panel.name_1" CODES_REQUIRED_OPTIONS = {"code_arm_required": True, "code_disarm_required": True} +CODES_NOT_REQUIRED_OPTIONS = {"code_arm_required": False, "code_disarm_required": False} TEST_RISCO_TO_HA = { "arm": AlarmControlPanelState.ARMED_AWAY, "partial_arm": AlarmControlPanelState.ARMED_HOME, @@ -388,7 +389,8 @@ async def test_cloud_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_correct_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -452,7 +454,58 @@ async def test_cloud_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_cloud_sets_without_code( + hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud +) -> None: + """Test settings the various modes when code is not required.""" + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_CLOUD_ENTITY_ID, 0, "C" + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_CLOUD_ENTITY_ID, 1, "C" + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_CLOUD_ENTITY_ID, + 0, + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_CLOUD_ENTITY_ID, + 1, + ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_incorrect_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -837,7 +890,8 @@ async def test_local_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_correct_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -931,7 +985,8 @@ async def test_local_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_incorrect_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -1020,3 +1075,87 @@ async def test_local_sets_with_incorrect_code( two_part_local_alarm[1], **code, ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_local_sets_without_code( + hass: HomeAssistant, two_part_local_alarm, setup_risco_local +) -> None: + """Test settings the various modes when code is not required.""" + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + "C", + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + "C", + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) From 155fc134b6fabe1ed092d7a434ebd5c5b44af32e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 13:33:00 +0200 Subject: [PATCH 6/8] Do not add derivative config entry to source device (#148674) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/derivative/__init__.py | 26 +++- .../components/derivative/config_flow.py | 2 +- homeassistant/components/derivative/sensor.py | 18 +-- homeassistant/helpers/device.py | 13 ++ homeassistant/helpers/helper_integration.py | 21 ++- tests/components/derivative/test_init.py | 143 ++++++++++++++++-- tests/helpers/test_device.py | 46 +++++- tests/helpers/test_helper_integration.py | 71 ++++++++- 8 files changed, 301 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 8fb614a3de411..8bdf448bfba11 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -11,7 +11,10 @@ async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) _LOGGER = logging.getLogger(__name__) @@ -19,6 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE] ) @@ -29,20 +33,16 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: options={**entry.options, CONF_SOURCE: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE], - source_entity_removed=source_entity_removed, ) ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) @@ -85,6 +85,20 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, options=new_options, version=1, minor_version=2 ) + if config_entry.minor_version < 3: + # Remove the derivative config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_SOURCE] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=3 + ) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index c90631f3aeb7d..b5dee1deee30a 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -142,7 +142,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index bfba2f0023cc8..ab4feabc4ee9c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -34,8 +34,7 @@ callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -118,17 +117,13 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): max_sub_interval = cv.time_period(max_sub_interval_dict) else: max_sub_interval = None derivative_sensor = DerivativeSensor( + hass, name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), source_entity=source_entity_id, @@ -137,7 +132,6 @@ async def async_setup_entry( unit_of_measurement=None, unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -152,6 +146,7 @@ async def async_setup_platform( ) -> None: """Set up the derivative sensor.""" derivative = DerivativeSensor( + hass, name=config.get(CONF_NAME), round_digits=config[CONF_ROUND_DIGITS], source_entity=config[CONF_SOURCE], @@ -174,6 +169,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, *, name: str | None, round_digits: int, @@ -184,11 +180,13 @@ def __init__( unit_time: UnitOfTime, max_sub_interval: timedelta | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the derivative sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._sensor_source_id = source_entity self._round_digits = round_digits self._attr_native_value = round(Decimal(0), round_digits) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index f1404bb068b43..bf0e2ab31be12 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -21,6 +21,19 @@ def async_entity_id_to_device_id( return entity.device_id +@callback +def async_entity_id_to_device( + hass: HomeAssistant, + entity_id_or_uuid: str, +) -> dr.DeviceEntry | None: + """Resolve the device entry for the entity id or entity uuid.""" + + if (device_id := async_entity_id_to_device_id(hass, entity_id_or_uuid)) is None: + return None + + return dr.async_get(hass).async_get(device_id) + + @callback def async_device_info_to_link_from_entity( hass: HomeAssistant, diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index d43c1b22a25bf..04a1d2cca766b 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -19,13 +19,15 @@ def async_handle_source_entity_changes( set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, - source_entity_removed: Callable[[], Coroutine[Any, Any, None]], + source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. The following changes are handled: - - Entity removal: If the source entity is removed, the helper config entry - is removed, and the helper entity is cleaned up. + - Entity removal: If the source entity is removed: + - If source_entity_removed is provided, it is called to handle the removal. + - If source_entity_removed is not provided, The helper entity is updated to + not link to any device. - Entity ID changed: If the source entity's entity ID changes and the source entity is identified by an entity ID, the set_source_entity_id_or_uuid is called. If the source entity is identified by a UUID, the helper config entry @@ -52,7 +54,18 @@ async def async_registry_updated( data = event.data if data["action"] == "remove": - await source_entity_removed() + if source_entity_removed: + await source_entity_removed() + else: + for ( + helper_entity_entry + ) in entity_registry.entities.get_entries_for_config_entry_id( + helper_config_entry_id + ): + # Update the helper entity to link to the new device (or no device) + entity_registry.async_update_entity( + helper_entity_entry.entity_id, device_id=None + ) if data["action"] != "update": return diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 1f7d051d27edd..abe90e72b5688 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -82,6 +82,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -214,7 +215,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(derivative_config_entry.entry_id) @@ -229,7 +230,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -240,6 +241,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the derivative config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the derivative config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -256,7 +305,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -273,7 +322,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -300,7 +353,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -315,7 +368,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -348,7 +405,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert derivative_config_entry.entry_id not in sensor_device_2.config_entries @@ -365,11 +422,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is moved to the other device + # Check that the entity is linked to the other device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert derivative_config_entry.entry_id in sensor_device_2.config_entries + assert derivative_config_entry.entry_id not in sensor_device_2.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -394,7 +455,7 @@ async def test_async_handle_source_entity_new_entity_id( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -412,9 +473,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the derivative config entry is updated with the new entity ID assert derivative_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -431,7 +492,7 @@ async def test_async_handle_source_entity_new_entity_id( ({"unit_prefix": "none"}, None), ], ) -async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: +async def test_migration_1_1(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: """Test migration from v1.1 deletes "none" unit_prefix.""" config_entry = MockConfigEntry( @@ -457,6 +518,60 @@ async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> Non assert config_entry.options["unit_time"] == "min" assert config_entry.options.get("unit_prefix") == expect_prefix + assert config_entry.version == 1 + assert config_entry.minor_version == 3 + + +async def test_migration_1_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.2 removes derivative config entry from device.""" + + derivative_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.test_unique", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=2, + ) + derivative_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + assert derivative_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + assert derivative_config_entry.version == 1 + assert derivative_config_entry.minor_version == 3 + async def test_migration_from_future_version( hass: HomeAssistant, diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 266435ef05d03..262e700c29edf 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -8,6 +8,7 @@ from homeassistant.helpers.device import ( async_device_info_to_link_from_device_id, async_device_info_to_link_from_entity, + async_entity_id_to_device, async_entity_id_to_device_id, async_remove_stale_devices_links_keep_current_device, async_remove_stale_devices_links_keep_entity_device, @@ -16,12 +17,12 @@ from tests.common import MockConfigEntry -async def test_entity_id_to_device_id( +async def test_entity_id_to_device_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test returning an entity's device ID.""" + """Test returning an entity's device / device ID.""" config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -48,6 +49,41 @@ async def test_entity_id_to_device_id( entity_id_or_uuid=entity.entity_id, ) assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.entity_id, + ) + == device + ) + + assert ( + async_entity_id_to_device_id( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + + device_id = async_entity_id_to_device_id( + hass, + entity_id_or_uuid=entity.id, + ) + assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.id, + ) + == device + ) with pytest.raises(vol.Invalid): async_entity_id_to_device_id( @@ -55,6 +91,12 @@ async def test_entity_id_to_device_id( entity_id_or_uuid="unknown_uuid", ) + with pytest.raises(vol.Invalid): + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown_uuid", + ) + async def test_device_info_to_link( hass: HomeAssistant, diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 91932a51ac21b..640b2ff011af1 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -155,7 +155,7 @@ def mock_helper_integration( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, - source_entity_removed: AsyncMock, + source_entity_removed: AsyncMock | None, ) -> None: """Mock the helper integration.""" @@ -197,7 +197,9 @@ def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: return events -def listen_entity_registry_events(hass: HomeAssistant) -> list[str]: +def listen_entity_registry_events( + hass: HomeAssistant, +) -> list[er.EventEntityRegistryUpdatedData]: """Track entity registry actions for an entity.""" events: list[er.EventEntityRegistryUpdatedData] = [] @@ -211,6 +213,7 @@ def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: return events +@pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") async def test_async_handle_source_entity_changes_source_entity_removed( @@ -225,6 +228,70 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the helper config entry is removed when the source entity is removed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entitys's config entry from the device, this removes the + # source entity + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None + async_unload_entry.assert_not_called() + async_remove_entry.assert_not_called() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is not removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed_custom_handler( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" From 5e4ce46daea65301fa1e2cd6547d81d997222c8c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:38:33 +0200 Subject: [PATCH 7/8] Use absolute humidity device class in Airq (#148568) --- homeassistant/components/airq/const.py | 1 - homeassistant/components/airq/icons.json | 3 --- homeassistant/components/airq/sensor.py | 8 +++----- homeassistant/components/airq/strings.json | 3 --- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 7a5abe47a8d9c..3e5c736c8c5cb 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -6,6 +6,5 @@ CONF_CLIP_NEGATIVE: Final = "clip_negatives" DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/icons.json b/homeassistant/components/airq/icons.json index fec6eb8dd867b..09f262aeaaf33 100644 --- a/homeassistant/components/airq/icons.json +++ b/homeassistant/components/airq/icons.json @@ -4,9 +4,6 @@ "health_index": { "default": "mdi:heart-pulse" }, - "absolute_humidity": { - "default": "mdi:water" - }, "oxygen": { "default": "mdi:leaf" }, diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 08a344ae9f42d..516114840d354 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -14,6 +14,7 @@ SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -28,10 +29,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirQConfigEntry, AirQCoordinator -from .const import ( - ACTIVITY_BECQUEREL_PER_CUBIC_METER, - CONCENTRATION_GRAMS_PER_CUBIC_METER, -) +from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER _LOGGER = logging.getLogger(__name__) @@ -195,7 +193,7 @@ class AirQEntityDescription(SensorEntityDescription): ), AirQEntityDescription( key="humidity_abs", - translation_key="absolute_humidity", + device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY, native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 9c16975a3aba6..de8c7d86b09f4 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -93,9 +93,6 @@ "health_index": { "name": "Health index" }, - "absolute_humidity": { - "name": "Absolute humidity" - }, "hydrogen": { "name": "Hydrogen" }, From 14ff04200e051f6eb0a65e4ee9d9856eca7486e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Jul 2025 16:24:44 +0200 Subject: [PATCH 8/8] Make AI Task instructions multiline (#148606) --- homeassistant/components/ai_task/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 4298ab62a072b..194c0e07bc3fe 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -10,6 +10,7 @@ generate_data: required: true selector: text: + multiline: true entity_id: required: false selector: