From d1bdd6eeeb8c38ef1336248e4a1f39bd4ab23de9 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:44:37 +0200 Subject: [PATCH 01/15] Upgrade UniFi Network integration quality scale to Silver (#168736) Co-authored-by: RaHehl --- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/quality_scale.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 4e7d4f41b20000..c88eb3de1cd91d 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aiounifi==90"], "ssdp": [ { diff --git a/homeassistant/components/unifi/quality_scale.yaml b/homeassistant/components/unifi/quality_scale.yaml index 9636dce2df4050..0478c6103c3ac4 100644 --- a/homeassistant/components/unifi/quality_scale.yaml +++ b/homeassistant/components/unifi/quality_scale.yaml @@ -22,8 +22,8 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -36,13 +36,13 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done From 9ccc2e747372c8442cf2c70d5b63df63cecad446 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Sat, 25 Apr 2026 23:49:17 +0200 Subject: [PATCH 02/15] Add temperature sensor to Duco integration (#169021) --- homeassistant/components/duco/__init__.py | 3 +- homeassistant/components/duco/config_flow.py | 3 +- homeassistant/components/duco/sensor.py | 20 ++ homeassistant/components/duco/strings.json | 3 + tests/components/duco/conftest.py | 4 + .../duco/snapshots/test_diagnostics.ambr | 8 +- .../duco/snapshots/test_sensor.ambr | 232 ++++++++++++++++++ tests/components/duco/test_sensor.py | 6 +- 8 files changed, 272 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py index 39975c0163ece2..035b95cb90f73a 100644 --- a/homeassistant/components/duco/__init__.py +++ b/homeassistant/components/duco/__init__.py @@ -15,8 +15,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: """Set up Duco from a config entry.""" client = DucoClient( - session=async_get_clientsession(hass), + session=async_get_clientsession(hass, verify_ssl=False), host=entry.data[CONF_HOST], + scheme="https", ) coordinator = DucoCoordinator(hass, entry, client) diff --git a/homeassistant/components/duco/config_flow.py b/homeassistant/components/duco/config_flow.py index 036ba4ca98e386..8faf0645566cca 100644 --- a/homeassistant/components/duco/config_flow.py +++ b/homeassistant/components/duco/config_flow.py @@ -161,8 +161,9 @@ async def _validate_input(self, host: str) -> tuple[str, str]: Returns a tuple of (box_name, mac_address). """ client = DucoClient( - session=async_get_clientsession(self.hass), + session=async_get_clientsession(self.hass, verify_ssl=False), host=host, + scheme="https", ) board_info = await client.async_get_board_info() lan_info = await client.async_get_lan_info() diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index 35206cdf386369..a08ba23ddcd341 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -19,6 +19,7 @@ PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -59,6 +60,25 @@ class DucoBoxSensorEntityDescription(SensorEntityDescription): ), node_types=(NodeType.BOX,), ), + DucoSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda node: node.sensor.temp if node.sensor else None, + node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH), + ), + DucoSensorEntityDescription( + key="box_temperature", + translation_key="box_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda node: node.sensor.temp if node.sensor else None, + node_types=(NodeType.BOX,), + ), DucoSensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index de81a2568136cc..af8f86f39798b0 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -47,6 +47,9 @@ } }, "sensor": { + "box_temperature": { + "name": "Box temperature" + }, "iaq_co2": { "name": "CO2 air quality index" }, diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 00c793ea14a3d8..755897e4a2f440 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -96,6 +96,7 @@ def mock_nodes() -> list[Node]: iaq_co2=None, rh=None, iaq_rh=None, + temp=27.9, ), ), Node( @@ -121,6 +122,7 @@ def mock_nodes() -> list[Node]: iaq_co2=80, rh=None, iaq_rh=None, + temp=19.8, ), ), Node( @@ -146,6 +148,7 @@ def mock_nodes() -> list[Node]: iaq_co2=None, rh=42.0, iaq_rh=85, + temp=27.9, ), ), Node( @@ -171,6 +174,7 @@ def mock_nodes() -> list[Node]: iaq_co2=None, rh=61.0, iaq_rh=90, + temp=22.5, ), ), ] diff --git a/tests/components/duco/snapshots/test_diagnostics.ambr b/tests/components/duco/snapshots/test_diagnostics.ambr index fa1cea8cfc4eb5..029af1a1798c41 100644 --- a/tests/components/duco/snapshots/test_diagnostics.ambr +++ b/tests/components/duco/snapshots/test_diagnostics.ambr @@ -45,7 +45,7 @@ 'iaq_co2': None, 'iaq_rh': None, 'rh': None, - 'temp': None, + 'temp': 27.9, }), 'ventilation': dict({ 'flow_lvl_tgt': 0, @@ -71,7 +71,7 @@ 'iaq_co2': None, 'iaq_rh': 85, 'rh': 42.0, - 'temp': None, + 'temp': 27.9, }), 'ventilation': dict({ 'flow_lvl_tgt': None, @@ -97,7 +97,7 @@ 'iaq_co2': 80, 'iaq_rh': None, 'rh': None, - 'temp': None, + 'temp': 19.8, }), 'ventilation': dict({ 'flow_lvl_tgt': None, @@ -123,7 +123,7 @@ 'iaq_co2': None, 'iaq_rh': 90, 'rh': 61.0, - 'temp': None, + 'temp': 22.5, }), 'ventilation': dict({ 'flow_lvl_tgt': None, diff --git a/tests/components/duco/snapshots/test_sensor.ambr b/tests/components/duco/snapshots/test_sensor.ambr index 273138ad354df2..99816c86d8ce49 100644 --- a/tests/components/duco/snapshots/test_sensor.ambr +++ b/tests/components/duco/snapshots/test_sensor.ambr @@ -108,6 +108,64 @@ 'state': '85', }) # --- +# name: test_sensor_entities_state[sensor.bathroom_rh_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bathroom_rh_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_113_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.bathroom_rh_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bathroom RH Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bathroom_rh_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.9', + }) +# --- # name: test_sensor_entities_state[sensor.kitchen_rh_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -217,6 +275,122 @@ 'state': '90', }) # --- +# name: test_sensor_entities_state[sensor.kitchen_rh_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_rh_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_50_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.kitchen_rh_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen RH Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_rh_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_entities_state[sensor.living_box_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_box_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Box temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Box temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'box_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_box_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_box_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Box temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_box_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.9', + }) +# --- # name: test_sensor_entities_state[sensor.living_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -471,3 +645,61 @@ 'state': '80', }) # --- +# name: test_sensor_entities_state[sensor.office_co2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_co2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_2_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.office_co2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office CO2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_co2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.8', + }) +# --- diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index b4933b0bc11454..2a55672504eaf6 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -71,7 +71,10 @@ async def test_diagnostic_sensor_entities_disabled_by_default( entity_registry: er.EntityRegistry, ) -> None: """Test that diagnostic sensor entities are disabled by default.""" - for entity_id in ("sensor.living_signal_strength",): + for entity_id in ( + "sensor.living_signal_strength", + "sensor.living_box_temperature", + ): entry = entity_registry.async_get(entity_id) assert entry is not None assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION @@ -168,6 +171,7 @@ async def test_new_node_added_dynamically( iaq_co2=None, rh=55.0, iaq_rh=70, + temp=21.0, ), ) mock_duco_client.async_get_nodes.return_value = [*mock_nodes, new_node] From e9ca9254df1cdaa543a82880ce0b1c6769e26c82 Mon Sep 17 00:00:00 2001 From: mayerwin Date: Sat, 25 Apr 2026 11:51:56 -1000 Subject: [PATCH 03/15] Preserve sub-meter GPS accuracy in mobile_app webhooks (#169144) Co-authored-by: mayerwin <2272127+mayerwin@users.noreply.github.com> --- .../components/mobile_app/webhook.py | 2 +- tests/components/mobile_app/test_webhook.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 32694f4727d525..232c4c50c6c336 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -414,7 +414,7 @@ async def webhook_render_template( { vol.Optional(ATTR_LOCATION_NAME): cv.string, vol.Optional(ATTR_GPS): cv.gps, - vol.Optional(ATTR_GPS_ACCURACY): cv.positive_int, + vol.Optional(ATTR_GPS_ACCURACY): cv.positive_float, vol.Optional(ATTR_BATTERY): cv.positive_int, vol.Optional(ATTR_SPEED): cv.positive_int, vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 7fd0cbda8a6d9d..b7a247bc9736d5 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -738,6 +738,34 @@ async def test_webhook_update_location_with_gps_without_accuracy( assert state.state == STATE_UNKNOWN +async def test_webhook_update_location_preserves_float_gps_accuracy( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that sub-meter ``gps_accuracy`` is not floored to an integer. + + Android's fused location provider reports accuracy as a float in metres. + The zone-containment predicate (``zone_dist - zone_radius < accuracy``) + can flip its result over a sub-metre difference at zone boundaries - + so flooring 6.938 to 6 has been observed to drop inner-zone transitions + in nested same-centre zones, with no automatic retry. + """ + resp = await webhook_client.post( + f"/api/webhook/{create_registrations[1]['webhook_id']}", + json={ + "type": "update_location", + "data": {"gps": [1, 2], "gps_accuracy": 6.938}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state is not None + assert state.attributes["gps_accuracy"] == 6.938 + + async def test_webhook_update_location_with_location_name( hass: HomeAssistant, create_registrations: tuple[dict[str, Any], dict[str, Any]], From c1894eda8383bdec4201e83349e5cd240f228a7a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:55:47 +0200 Subject: [PATCH 04/15] Detect .start entry point files in hassfest check (#169135) --- script/hassfest/requirements.py | 2 +- tests/hassfest/test_requirements.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 5ab54be3ac9ad0..d8d31899eedf6c 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -729,7 +729,7 @@ def check_dependency_files( if not (top := file.parts[0].lower()).endswith((".dist-info", ".py")): top_level.add(top) if (name := str(file).lower()) in FORBIDDEN_FILE_NAMES or ( - name.endswith(".pth") and len(file.parts) == 1 + name.endswith((".pth", ".start")) and len(file.parts) == 1 ): file_names.add(str(file)) results = _PackageFilesCheckResult( diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index ac95ce1e4d123b..c91dddfbd94aff 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -276,6 +276,7 @@ def test_check_dependency_file_names(integration: Integration) -> None: PackagePath("py.typed"), PackagePath("my_package.py"), PackagePath("some_script.Pth"), + PackagePath("entry_point.start"), PackagePath("my_package-1.0.0.dist-info/METADATA"), ] with ( @@ -289,20 +290,25 @@ def test_check_dependency_file_names(integration: Integration) -> None: assert _packages_checked_files_cache[pkg]["file_names"] == { "py.typed", "some_script.Pth", + "entry_point.start", } - assert len(integration.errors) == 2 + assert len(integration.errors) == 3 assert f"Package {pkg} has a forbidden file 'py.typed' in {package}" in [ x.error for x in integration.errors ] assert f"Package {pkg} has a forbidden file 'some_script.Pth' in {package}" in [ x.error for x in integration.errors ] + assert ( + f"Package {pkg} has a forbidden file 'entry_point.start' in {package}" + in [x.error for x in integration.errors] + ) integration.errors.clear() # Repeated call should use cache assert check_dependency_files(integration, package, pkg, ()) is False assert mock_files.call_count == 1 - assert len(integration.errors) == 2 + assert len(integration.errors) == 3 integration.errors.clear() # All good From 306fc529f28b0b298e95577f12853cf488d96457 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:01:14 +0800 Subject: [PATCH 05/15] Switchbot_BLE: bump PySwitchbot to 2.2.0 (#169119) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 098e30d6d2d117..d346058b1dfc05 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -42,5 +42,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==2.1.0"] + "requirements": ["PySwitchbot==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1889db0d43a228..222dc2fc2c0f92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,7 +83,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.20.4 # homeassistant.components.switchbot -PySwitchbot==2.1.0 +PySwitchbot==2.2.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f9154f94e07b9..5a22eaff295da9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -83,7 +83,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.20.4 # homeassistant.components.switchbot -PySwitchbot==2.1.0 +PySwitchbot==2.2.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From e19d0e75c3415ec0f086438f3c2bd978a326cbf7 Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+EnjoyingM@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:14:11 +0200 Subject: [PATCH 06/15] Wolflink: Fixing Codeowner (#169171) --- CODEOWNERS | 4 ++-- homeassistant/components/wolflink/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f6ffb7ad5cbb5f..4a852cf07c46ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1987,8 +1987,8 @@ CLAUDE.md @home-assistant/core /tests/components/wled/ @frenck @mik-laj /homeassistant/components/wmspro/ @mback2k /tests/components/wmspro/ @mback2k -/homeassistant/components/wolflink/ @adamkrol93 @mtielen -/tests/components/wolflink/ @adamkrol93 @mtielen +/homeassistant/components/wolflink/ @adamkrol93 @EnjoyingM +/tests/components/wolflink/ @adamkrol93 @EnjoyingM /homeassistant/components/workday/ @fabaff @gjohansson-ST /tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 0d8e6603602029..e85d20e3931e14 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -1,7 +1,7 @@ { "domain": "wolflink", "name": "Wolf SmartSet Service", - "codeowners": ["@adamkrol93", "@mtielen"], + "codeowners": ["@adamkrol93", "@EnjoyingM"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wolflink", "integration_type": "device", From e7dae028ba82fd279fb91116159f53b5a7a003b7 Mon Sep 17 00:00:00 2001 From: Jordi Date: Sun, 26 Apr 2026 00:15:01 +0200 Subject: [PATCH 07/15] Bump aioaquacell to 1.0.0 (#169166) --- homeassistant/components/aquacell/entity.py | 2 +- homeassistant/components/aquacell/manifest.json | 2 +- homeassistant/components/aquacell/sensor.py | 14 +++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aquacell/conftest.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/aquacell/entity.py b/homeassistant/components/aquacell/entity.py index 6c746ded24cb69..8d4fea5d39d09e 100644 --- a/homeassistant/components/aquacell/entity.py +++ b/homeassistant/components/aquacell/entity.py @@ -28,7 +28,7 @@ def __init__( self._attr_unique_id = f"{softener_key}-{entity_key}" self._attr_device_info = DeviceInfo( name=self.softener.name, - hw_version=self.softener.fwVersion, + hw_version=self.softener.diagnostics.fw_version, identifiers={(DOMAIN, str(softener_key))}, manufacturer=self.softener.brand, model=self.softener.ssn, diff --git a/homeassistant/components/aquacell/manifest.json b/homeassistant/components/aquacell/manifest.json index 2d8b80f4488c73..41dff9b9f6826d 100644 --- a/homeassistant/components/aquacell/manifest.json +++ b/homeassistant/components/aquacell/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aioaquacell"], - "requirements": ["aioaquacell==0.2.0"] + "requirements": ["aioaquacell==1.0.0"] } diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py index 58d3548284e3b6..0571736fdbe8bf 100644 --- a/homeassistant/components/aquacell/sensor.py +++ b/homeassistant/components/aquacell/sensor.py @@ -38,39 +38,39 @@ class SoftenerSensorEntityDescription(SensorEntityDescription): translation_key="salt_left_side_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.salt.leftPercent, + value_fn=lambda softener: softener.salt.left_percent, ), SoftenerSensorEntityDescription( key="salt_right_side_percentage", translation_key="salt_right_side_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.salt.rightPercent, + value_fn=lambda softener: softener.salt.right_percent, ), SoftenerSensorEntityDescription( key="salt_left_side_time_remaining", translation_key="salt_left_side_time_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, - value_fn=lambda softener: softener.salt.leftDays, + value_fn=lambda softener: softener.salt.left_days, ), SoftenerSensorEntityDescription( key="salt_right_side_time_remaining", translation_key="salt_right_side_time_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, - value_fn=lambda softener: softener.salt.rightDays, + value_fn=lambda softener: softener.salt.right_days, ), SoftenerSensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.battery, + value_fn=lambda softener: softener.diagnostics.battery, ), SoftenerSensorEntityDescription( key="wi_fi_strength", translation_key="wi_fi_strength", - value_fn=lambda softener: softener.wifiLevel, + value_fn=lambda softener: softener.diagnostics.wifi_level, device_class=SensorDeviceClass.ENUM, options=[ "high", @@ -82,7 +82,7 @@ class SoftenerSensorEntityDescription(SensorEntityDescription): key="last_update", translation_key="last_update", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda softener: softener.lastUpdate, + value_fn=lambda softener: softener.diagnostics.last_update, ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 222dc2fc2c0f92..c75147c7216c07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -200,7 +200,7 @@ aioambient==2024.08.0 aioapcaccess==1.0.0 # homeassistant.components.aquacell -aioaquacell==0.2.0 +aioaquacell==1.0.0 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a22eaff295da9..be5bdd3ae27d95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,7 +191,7 @@ aioambient==2024.08.0 aioapcaccess==1.0.0 # homeassistant.components.aquacell -aioaquacell==0.2.0 +aioaquacell==1.0.0 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 diff --git a/tests/components/aquacell/conftest.py b/tests/components/aquacell/conftest.py index 443f7da77cef4f..49d11537c6dc4a 100644 --- a/tests/components/aquacell/conftest.py +++ b/tests/components/aquacell/conftest.py @@ -47,7 +47,7 @@ def mock_aquacell_api() -> Generator[MagicMock]: "aquacell/get_all_softeners_one_softener.json" ) - softeners = [Softener(softener) for softener in softeners_dict] + softeners = [Softener.from_dict(softener) for softener in softeners_dict] mock_aquacell_api.get_all_softeners.return_value = softeners yield mock_aquacell_api From d832abc5fcd52a8e324626e165917b69e22299b1 Mon Sep 17 00:00:00 2001 From: Andres Ruiz Date: Sat, 25 Apr 2026 18:17:12 -0400 Subject: [PATCH 08/15] Add climate entity to Waterfurnace (#168729) --- .../components/waterfurnace/__init__.py | 2 +- .../components/waterfurnace/climate.py | 212 +++++++++++ .../components/waterfurnace/coordinator.py | 2 +- .../components/waterfurnace/entity.py | 33 ++ .../components/waterfurnace/sensor.py | 25 +- tests/components/waterfurnace/conftest.py | 16 +- .../waterfurnace/fixtures/device_data.json | 20 +- .../waterfurnace/snapshots/test_climate.ambr | 80 ++++ .../waterfurnace/snapshots/test_sensor.ambr | 6 +- tests/components/waterfurnace/test_climate.py | 357 ++++++++++++++++++ tests/components/waterfurnace/test_sensor.py | 10 +- 11 files changed, 728 insertions(+), 35 deletions(-) create mode 100644 homeassistant/components/waterfurnace/climate.py create mode 100644 homeassistant/components/waterfurnace/entity.py create mode 100644 tests/components/waterfurnace/snapshots/test_climate.ambr create mode 100644 tests/components/waterfurnace/test_climate.py diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index 03bc7727963675..e2f874a4e9888a 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/waterfurnace/climate.py b/homeassistant/components/waterfurnace/climate.py new file mode 100644 index 00000000000000..e765dd130d8306 --- /dev/null +++ b/homeassistant/components/waterfurnace/climate.py @@ -0,0 +1,212 @@ +"""Support for WaterFurnace climate entity.""" + +from __future__ import annotations + +from typing import Any + +from waterfurnace.waterfurnace import WFException + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WaterFurnaceConfigEntry +from .coordinator import WaterFurnaceCoordinator +from .entity import WaterFurnaceEntity + +PARALLEL_UPDATES = 0 + +# Maps ActiveSettings.mode string to HVACMode +ACTIVE_MODE_TO_HVAC: dict[str, HVACMode] = { + "Off": HVACMode.OFF, + "Auto": HVACMode.HEAT_COOL, + "Cool": HVACMode.COOL, + "Heat": HVACMode.HEAT, + "E-Heat": HVACMode.HEAT, +} + +# Maps HVACMode to library's integer mode +HVAC_TO_WF_MODE: dict[HVACMode, int] = { + HVACMode.OFF: 0, + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 2, + HVACMode.HEAT: 3, +} + +# Maps WFReading.mode string to HVACAction +FURNACE_MODE_TO_ACTION: dict[str, HVACAction] = { + "Standby": HVACAction.IDLE, + "Fan Only": HVACAction.FAN, + "Cooling 1": HVACAction.COOLING, + "Cooling 2": HVACAction.COOLING, + "Reheat": HVACAction.HEATING, + "Heating 1": HVACAction.HEATING, + "Heating 2": HVACAction.HEATING, + "E-Heat": HVACAction.HEATING, + "Aux Heat": HVACAction.HEATING, + "Lockout": HVACAction.OFF, +} + +# Library temperature limits (Fahrenheit) +HEATING_MIN = 40 +HEATING_MAX = 80 +COOLING_MIN = 60 +COOLING_MAX = 90 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WaterFurnaceConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up WaterFurnace climate from a config entry.""" + async_add_entities( + WaterFurnaceClimate(device_data.realtime) + for device_data in config_entry.runtime_data.values() + ) + + +class WaterFurnaceClimate(WaterFurnaceEntity, ClimateEntity): + """Climate entity for WaterFurnace geothermal systems.""" + + _attr_name = None + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_humidity = 15 + _attr_max_humidity = 95 + + def __init__(self, coordinator: WaterFurnaceCoordinator) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.unit + + @property + def min_temp(self) -> float: + """Return the minimum temperature based on current mode.""" + if self.hvac_mode == HVACMode.COOL: + return COOLING_MIN + return HEATING_MIN + + @property + def max_temp(self) -> float: + """Return the maximum temperature based on current mode.""" + if self.hvac_mode == HVACMode.HEAT: + return HEATING_MAX + return COOLING_MAX + + @property + def current_temperature(self) -> float | None: + """Return the current room temperature.""" + return self.coordinator.data.tstatroomtemp + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + return self.coordinator.data.tstatrelativehumidity + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + return ACTIVE_MODE_TO_HVAC.get(self.coordinator.data.activesettings.mode) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return FURNACE_MODE_TO_ACTION.get(self.coordinator.data.mode) + + @property + def target_temperature(self) -> float | None: + """Return the target temperature (single setpoint modes).""" + if self.hvac_mode == HVACMode.COOL: + return self.coordinator.data.tstatcoolingsetpoint + if self.hvac_mode == HVACMode.HEAT: + return self.coordinator.data.tstatheatingsetpoint + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature (Heat/Cool mode).""" + if self.hvac_mode == HVACMode.HEAT_COOL: + return self.coordinator.data.tstatcoolingsetpoint + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature (Heat/Cool mode).""" + if self.hvac_mode == HVACMode.HEAT_COOL: + return self.coordinator.data.tstatheatingsetpoint + return None + + @property + def target_humidity(self) -> float | None: + """Return the target humidity.""" + return self.coordinator.data.tstathumidsetpoint + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.client.set_mode, HVAC_TO_WF_MODE[hvac_mode] + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set HVAC mode: {err}") from err + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature(s).""" + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(hvac_mode) + + low = kwargs.get(ATTR_TARGET_TEMP_LOW) + high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + current_mode = hvac_mode if hvac_mode is not None else self.hvac_mode + try: + await self.hass.async_add_executor_job( + self._set_temperature, low, high, temp, current_mode + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set temperature: {err}") from err + + def _set_temperature( + self, + low: float | None, + high: float | None, + temp: float | None, + current_mode: HVACMode | None, + ) -> None: + """Send temperature setpoint(s) to the device.""" + client = self.coordinator.client + if low is not None and high is not None: + client.set_heating_setpoint(low) + client.set_cooling_setpoint(high) + elif temp is not None: + if current_mode == HVACMode.COOL: + client.set_cooling_setpoint(temp) + else: + client.set_heating_setpoint(temp) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.client.set_humidity, humidity + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set humidity: {err}") from err diff --git a/homeassistant/components/waterfurnace/coordinator.py b/homeassistant/components/waterfurnace/coordinator.py index bbea161457fe59..daac61974df695 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -90,7 +90,7 @@ def __init__( (device for device in client.devices if device.gwid == self.unit), None ) - async def _async_update_data(self): + async def _async_update_data(self) -> WFReading: """Fetch data from WaterFurnace API with built-in retry logic.""" try: return await self.hass.async_add_executor_job(self.client.read_with_retry) diff --git a/homeassistant/components/waterfurnace/entity.py b/homeassistant/components/waterfurnace/entity.py new file mode 100644 index 00000000000000..176351dca07623 --- /dev/null +++ b/homeassistant/components/waterfurnace/entity.py @@ -0,0 +1,33 @@ +"""Base entity for WaterFurnace.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WaterFurnaceCoordinator + + +class WaterFurnaceEntity(CoordinatorEntity[WaterFurnaceCoordinator]): + """Base entity for WaterFurnace.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: WaterFurnaceCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.unit)}, + manufacturer="WaterFurnace", + name="WaterFurnace System", + ) + + if coordinator.device_metadata: + if coordinator.device_metadata.description: + device_info["model"] = coordinator.device_metadata.description + if coordinator.device_metadata.awlabctypedesc: + device_info["name"] = coordinator.device_metadata.awlabctypedesc + + self._attr_device_info = device_info diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 9634baabb51a8e..be0a73ee09eb94 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -15,12 +15,11 @@ UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, WaterFurnaceConfigEntry +from . import WaterFurnaceConfigEntry from .coordinator import WaterFurnaceCoordinator +from .entity import WaterFurnaceEntity SENSORS = [ SensorEntityDescription( @@ -162,12 +161,11 @@ async def async_setup_entry( ) -class WaterFurnaceSensor(CoordinatorEntity[WaterFurnaceCoordinator], SensorEntity): +class WaterFurnaceSensor(WaterFurnaceEntity, SensorEntity): """Implementing the Waterfurnace sensor.""" entity_description: SensorEntityDescription _attr_should_poll = False - _attr_has_entity_name = True def __init__( self, coordinator: WaterFurnaceCoordinator, description: SensorEntityDescription @@ -175,25 +173,8 @@ def __init__( """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unit}_{description.key}" - device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.unit)}, - manufacturer="WaterFurnace", - name="WaterFurnace System", - ) - - if coordinator.device_metadata: - if coordinator.device_metadata.description: - # Eg. Series 7 - device_info["model"] = coordinator.device_metadata.description - if coordinator.device_metadata.awlabctypedesc: - # Eg. Series 7, 5 Ton - device_info["name"] = coordinator.device_metadata.awlabctypedesc - - self._attr_device_info = device_info - @property def native_value(self): """Return the native value of the sensor.""" diff --git a/tests/components/waterfurnace/conftest.py b/tests/components/waterfurnace/conftest.py index fac64eb836d51f..a174872082c1e4 100644 --- a/tests/components/waterfurnace/conftest.py +++ b/tests/components/waterfurnace/conftest.py @@ -15,7 +15,7 @@ ) from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.waterfurnace.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter @@ -151,16 +151,24 @@ async def seed_statistics( await async_wait_recording_done(hass) +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE, Platform.SENSOR] + + @pytest.fixture async def init_integration( recorder_mock: Recorder, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_waterfurnace_client: Mock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the WaterFurnace integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.waterfurnace.PLATFORMS", platforms): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/waterfurnace/fixtures/device_data.json b/tests/components/waterfurnace/fixtures/device_data.json index 41fd4a6ec086bd..ebd94eb7740a5b 100644 --- a/tests/components/waterfurnace/fixtures/device_data.json +++ b/tests/components/waterfurnace/fixtures/device_data.json @@ -5,12 +5,30 @@ "leavingairtemp": 110.5, "tstatroomtemp": 70.2, "enteringwatertemp": 42.8, + "tstatdehumidsetpoint": 55, "tstathumidsetpoint": 45, "tstatrelativehumidity": 43, + "humidity_offset_settings": { "fan_mode": 0, "accessory_type": 2 }, "compressorpower": 800, "fanpower": 150, "auxpower": 0, "looppumppower": 50, "actualcompressorspeed": 1200, - "airflowcurrentspeed": 850 + "airflowcurrentspeed": 850, + "tstatheatingsetpoint": 68, + "tstatcoolingsetpoint": 74, + "activesettings": { + "activemode": 3, + "heatingsp_read": 68, + "coolingsp_read": 74, + "fanmode_read": 0, + "temporaryoverride": 0, + "permanenthold": 0, + "vacationhold": 0, + "onpeakhold": 0, + "superboost": 0, + "tstatmode": 3, + "intertimeon_read": 0, + "intertimeoff_read": 0 + } } diff --git a/tests/components/waterfurnace/snapshots/test_climate.ambr b/tests/components/waterfurnace/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..68b674932e79fa --- /dev/null +++ b/tests/components/waterfurnace/snapshots/test_climate.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_climate_snapshot[climate.test_abc_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 95, + 'max_temp': 26.7, + 'min_humidity': 15, + 'min_temp': 4.4, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_abc_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'waterfurnace', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'TEST_GWID_12345', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_abc_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 43, + 'current_temperature': 21.2, + 'friendly_name': 'Test ABC Type', + 'humidity': 45, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 95, + 'max_temp': 26.7, + 'min_humidity': 15, + 'min_temp': 4.4, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.test_abc_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/waterfurnace/snapshots/test_sensor.ambr b/tests/components/waterfurnace/snapshots/test_sensor.ambr index 1a9b0d2f76de13..dece10773f7ee8 100644 --- a/tests/components/waterfurnace/snapshots/test_sensor.ambr +++ b/tests/components/waterfurnace/snapshots/test_sensor.ambr @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '23.3333333333333', }) # --- # name: test_sensors[sensor.test_abc_type_dehumidification_setpoint-entry] @@ -333,7 +333,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '55', }) # --- # name: test_sensors[sensor.test_abc_type_fan_power-entry] @@ -549,7 +549,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '20.0', }) # --- # name: test_sensors[sensor.test_abc_type_humidity-entry] diff --git a/tests/components/waterfurnace/test_climate.py b/tests/components/waterfurnace/test_climate.py new file mode 100644 index 00000000000000..c845e13ec59066 --- /dev/null +++ b/tests/components/waterfurnace/test_climate.py @@ -0,0 +1,357 @@ +"""Test climate of WaterFurnace integration.""" + +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from waterfurnace.waterfurnace import WFException + +from homeassistant.components.climate import ( + ATTR_HUMIDITY, + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.waterfurnace.const import UPDATE_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "climate.test_abc_type" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_climate_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity against snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +@pytest.mark.parametrize( + ("active_mode_index", "expected_hvac_mode"), + [ + (0, HVACMode.OFF), + (1, HVACMode.HEAT_COOL), + (2, HVACMode.COOL), + (3, HVACMode.HEAT), + (4, HVACMode.HEAT), + ], + ids=["Off", "Auto", "Cool", "Heat", "E-Heat"], +) +async def test_hvac_mode_mapping( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, + active_mode_index: int, + expected_hvac_mode: HVACMode, +) -> None: + """Test that ActiveSettings.mode maps to the correct HVACMode.""" + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = ( + active_mode_index + ) + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == expected_hvac_mode.value + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +@pytest.mark.parametrize( + ("mode_index", "expected_action"), + [ + (0, HVACAction.IDLE), + (1, HVACAction.FAN), + (2, HVACAction.COOLING), + (3, HVACAction.COOLING), + (4, HVACAction.HEATING), + (5, HVACAction.HEATING), + (6, HVACAction.HEATING), + (7, HVACAction.HEATING), + (8, HVACAction.HEATING), + (9, HVACAction.OFF), + ], + ids=[ + "Standby", + "Fan Only", + "Cooling 1", + "Cooling 2", + "Reheat", + "Heating 1", + "Heating 2", + "E-Heat", + "Aux Heat", + "Lockout", + ], +) +async def test_hvac_action_mapping( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, + mode_index: int, + expected_action: HVACAction, +) -> None: + """Test that WFReading.mode maps to the correct HVACAction.""" + mock_waterfurnace_client.read_with_retry.return_value.modeofoperation = mode_index + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes["hvac_action"] == expected_action + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +@pytest.mark.parametrize( + ("hvac_mode", "expected_wf_mode"), + [ + (HVACMode.OFF, 0), + (HVACMode.HEAT_COOL, 1), + (HVACMode.COOL, 2), + (HVACMode.HEAT, 3), + ], + ids=["Off", "Auto", "Cool", "Heat"], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + hvac_mode: HVACMode, + expected_wf_mode: int, +) -> None: + """Test setting HVAC mode calls the library with the correct integer.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + mock_waterfurnace_client.set_mode.assert_called_once_with(expected_wf_mode) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_single_heat( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test setting temperature in heat mode sets heating setpoint.""" + # Fixture default is activemode=3 (Heat) + # Send 22°C (HA test default unit); entity converts to ~71.6°F + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + mock_waterfurnace_client.set_heating_setpoint.assert_called_once_with( + pytest.approx(71.6, abs=0.1) + ) + mock_waterfurnace_client.set_cooling_setpoint.assert_not_called() + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_single_cool( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting temperature in cool mode sets cooling setpoint.""" + # Switch to Cool mode + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 2 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Send 24°C; entity converts to ~75.2°F + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 24}, + blocking=True, + ) + mock_waterfurnace_client.set_cooling_setpoint.assert_called_once_with( + pytest.approx(75.2, abs=0.1) + ) + mock_waterfurnace_client.set_heating_setpoint.assert_not_called() + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_range( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting temperature range sets both setpoints.""" + # Switch to Auto mode + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 1 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Send 18°C low / 26°C high; entity converts to ~64.4°F / ~78.8°F + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 18, + ATTR_TARGET_TEMP_HIGH: 26, + }, + blocking=True, + ) + mock_waterfurnace_client.set_heating_setpoint.assert_called_once_with( + pytest.approx(64.4, abs=0.1) + ) + mock_waterfurnace_client.set_cooling_setpoint.assert_called_once_with( + pytest.approx(78.8, abs=0.1) + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_humidity( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test setting target humidity.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 50}, + blocking=True, + ) + mock_waterfurnace_client.set_humidity.assert_called_once_with(50) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_target_temperature_cool_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test target_temperature returns cooling setpoint in cool mode.""" + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 2 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + # Fixture: tstatcoolingsetpoint=74°F → 23.3°C + assert state.attributes["temperature"] == pytest.approx(23.3, abs=0.1) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_target_temperature_range_auto_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test target_temperature_high/low in auto mode.""" + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 1 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + # Fixture: tstatheatingsetpoint=68°F → 20°C, tstatcoolingsetpoint=74°F → 23.3°C + assert state.attributes["target_temp_low"] == 20.0 + assert state.attributes["target_temp_high"] == pytest.approx(23.3, abs=0.1) + assert state.attributes["temperature"] is None + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_hvac_mode_error( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that a library error raises HomeAssistantError.""" + mock_waterfurnace_client.set_mode.side_effect = WFException("connection lost") + with pytest.raises(HomeAssistantError, match="Failed to set HVAC mode"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_error( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that a library error raises HomeAssistantError.""" + mock_waterfurnace_client.set_heating_setpoint.side_effect = WFException("timeout") + with pytest.raises(HomeAssistantError, match="Failed to set temperature"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_humidity_error( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that a library error raises HomeAssistantError.""" + mock_waterfurnace_client.set_humidity.side_effect = WFException("timeout") + with pytest.raises(HomeAssistantError, match="Failed to set humidity"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 50}, + blocking=True, + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_with_hvac_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that ATTR_HVAC_MODE in set_temperature switches mode first.""" + # Fixture default is activemode=3 (Heat); send cool mode + temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 24, + ATTR_HVAC_MODE: HVACMode.COOL, + }, + blocking=True, + ) + mock_waterfurnace_client.set_mode.assert_called_once_with(2) + mock_waterfurnace_client.set_cooling_setpoint.assert_called_once_with( + pytest.approx(75.2, abs=0.1) + ) + mock_waterfurnace_client.set_heating_setpoint.assert_not_called() diff --git a/tests/components/waterfurnace/test_sensor.py b/tests/components/waterfurnace/test_sensor.py index c28bbaa7680492..389c295d1f170a 100644 --- a/tests/components/waterfurnace/test_sensor.py +++ b/tests/components/waterfurnace/test_sensor.py @@ -9,23 +9,27 @@ from waterfurnace.waterfurnace import WFException from homeassistant.components.waterfurnace.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + @pytest.mark.usefixtures("seed_statistics", "init_integration") async def test_sensors( hass: HomeAssistant, - mock_waterfurnace_client: Mock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """Test that we create the expected sensors.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 1bf77e095d7f1a34d6366e6b7cb54a1461d6b45d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:18:35 +0200 Subject: [PATCH 09/15] Migrate `refoss` to use `entry.runtime_data` (#169105) --- homeassistant/components/refoss/__init__.py | 27 ++++++--------------- homeassistant/components/refoss/bridge.py | 13 +++++----- homeassistant/components/refoss/sensor.py | 18 +++----------- homeassistant/components/refoss/switch.py | 13 ++++------ 4 files changed, 23 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py index 10a9fd45ad2213..310a8afd284f01 100644 --- a/homeassistant/components/refoss/__init__.py +++ b/homeassistant/components/refoss/__init__.py @@ -5,13 +5,12 @@ from datetime import timedelta from typing import Final -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_interval -from .bridge import DiscoveryService -from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DOMAIN +from .bridge import DiscoveryService, RefossConfigEntry +from .const import DISCOVERY_SCAN_INTERVAL from .util import refoss_discovery_server PLATFORMS: Final = [ @@ -20,14 +19,11 @@ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: """Set up Refoss from a config entry.""" - hass.data.setdefault(DOMAIN, {}) discover = await refoss_discovery_server(hass) refoss_discovery = DiscoveryService(hass, entry, discover) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery + entry.runtime_data = refoss_discovery await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -45,16 +41,7 @@ async def _async_scan_update(_=None): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN].get(DATA_DISCOVERY_SERVICE) is not None: - refoss_discovery: DiscoveryService = hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] - refoss_discovery.discovery.clean_up() - hass.data[DOMAIN].pop(DATA_DISCOVERY_SERVICE) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN].pop(COORDINATORS) - - return unload_ok + entry.runtime_data.discovery.clean_up() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py index 278b31d30a3016..ec5ae20deb9c8f 100644 --- a/homeassistant/components/refoss/bridge.py +++ b/homeassistant/components/refoss/bridge.py @@ -1,5 +1,4 @@ """Refoss integration.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -11,15 +10,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .const import _LOGGER, DISPATCH_DEVICE_DISCOVERED from .coordinator import RefossDataUpdateCoordinator +type RefossConfigEntry = ConfigEntry[DiscoveryService] + class DiscoveryService(Listener): """Discovery event handler for refoss devices.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, discovery: Discovery + self, hass: HomeAssistant, config_entry: RefossConfigEntry, discovery: Discovery ) -> None: """Init discovery service.""" self.hass = hass @@ -28,7 +29,7 @@ def __init__( self.discovery = discovery self.discovery.add_listener(self) - hass.data[DOMAIN].setdefault(COORDINATORS, []) + self.coordinators: list[RefossDataUpdateCoordinator] = [] async def device_found(self, device_info: DeviceInfo) -> None: """Handle new device found on the network.""" @@ -38,7 +39,7 @@ async def device_found(self, device_info: DeviceInfo) -> None: return coordo = RefossDataUpdateCoordinator(self.hass, self.config_entry, device) - self.hass.data[DOMAIN][COORDINATORS].append(coordo) + self.coordinators.append(coordo) await coordo.async_refresh() _LOGGER.debug( @@ -50,7 +51,7 @@ async def device_found(self, device_info: DeviceInfo) -> None: async def device_update(self, device_info: DeviceInfo) -> None: """Handle updates in device information, update if ip has changed.""" - for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + for coordinator in self.coordinators: if coordinator.device.device_info.mac == device_info.mac: _LOGGER.debug( "Update device %s ip to %s", diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 60219b24b44cf6..b7be46a649c683 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, @@ -25,15 +24,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .bridge import RefossDataUpdateCoordinator -from .const import ( - _LOGGER, - CHANNEL_DISPLAY_NAME, - COORDINATORS, - DISPATCH_DEVICE_DISCOVERED, - DOMAIN, - SENSOR_EM, -) +from .bridge import RefossConfigEntry, RefossDataUpdateCoordinator +from .const import _LOGGER, CHANNEL_DISPLAY_NAME, DISPATCH_DEVICE_DISCOVERED, SENSOR_EM from .entity import RefossEntity @@ -116,7 +108,7 @@ class RefossSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RefossConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" @@ -146,9 +138,7 @@ def init_device(coordinator: RefossDataUpdateCoordinator) -> None: ) _LOGGER.debug("Device %s add sensor entity success", device.dev_name) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in config_entry.runtime_data.coordinators: init_device(coordinator) config_entry.async_on_unload( diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py index 73a26d51401ad1..348851b9cc0fd0 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -7,25 +7,24 @@ from refoss_ha.controller.toggle import ToggleXMix from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import RefossDataUpdateCoordinator -from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .bridge import RefossConfigEntry, RefossDataUpdateCoordinator +from .const import _LOGGER, DISPATCH_DEVICE_DISCOVERED from .entity import RefossEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RefossConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" @callback - def init_device(coordinator): + def init_device(coordinator: RefossDataUpdateCoordinator) -> None: """Register the device.""" device = coordinator.device if not isinstance(device, ToggleXMix): @@ -39,9 +38,7 @@ def init_device(coordinator): async_add_entities(new_entities) _LOGGER.debug("Device %s add switch entity success", device.dev_name) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in config_entry.runtime_data.coordinators: init_device(coordinator) config_entry.async_on_unload( From 2c4f598c0687edee724e1ef9d7120595e67558d5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 26 Apr 2026 00:35:48 +0200 Subject: [PATCH 10/15] Add button platform to Fumis integration (#169095) --- homeassistant/components/fumis/__init__.py | 2 +- homeassistant/components/fumis/button.py | 71 +++++++++++++++++++ homeassistant/components/fumis/icons.json | 5 ++ homeassistant/components/fumis/strings.json | 5 ++ .../fumis/snapshots/test_button.ambr | 51 +++++++++++++ tests/components/fumis/test_button.py | 67 +++++++++++++++++ 6 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/fumis/button.py create mode 100644 tests/components/fumis/snapshots/test_button.ambr create mode 100644 tests/components/fumis/test_button.py diff --git a/homeassistant/components/fumis/__init__.py b/homeassistant/components/fumis/__init__.py index 0ae417b6603215..c0e112aeb9ce42 100644 --- a/homeassistant/components/fumis/__init__.py +++ b/homeassistant/components/fumis/__init__.py @@ -7,7 +7,7 @@ from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool: diff --git a/homeassistant/components/fumis/button.py b/homeassistant/components/fumis/button.py new file mode 100644 index 00000000000000..c6fa30223a687a --- /dev/null +++ b/homeassistant/components/fumis/button.py @@ -0,0 +1,71 @@ +"""Support for Fumis button entities.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisButtonEntityDescription(ButtonEntityDescription): + """Describes a Fumis button entity.""" + + press_fn: Callable[[Fumis], Awaitable[Any]] + + +BUTTONS: tuple[FumisButtonEntityDescription, ...] = ( + FumisButtonEntityDescription( + key="sync_clock", + translation_key="sync_clock", + entity_category=EntityCategory.DIAGNOSTIC, + press_fn=lambda client: client.set_clock(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis button entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisButtonEntity(coordinator=coordinator, description=description) + for description in BUTTONS + ) + + +class FumisButtonEntity(FumisEntity, ButtonEntity): + """Defines a Fumis button entity.""" + + entity_description: FumisButtonEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisButtonEntityDescription, + ) -> None: + """Initialize the Fumis button entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @fumis_exception_handler + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self.coordinator.client) diff --git a/homeassistant/components/fumis/icons.json b/homeassistant/components/fumis/icons.json index 40c6bab93c22e1..6cfb39c3911618 100644 --- a/homeassistant/components/fumis/icons.json +++ b/homeassistant/components/fumis/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "sync_clock": { + "default": "mdi:clock-sync" + } + }, "sensor": { "combustion_chamber_temperature": { "default": "mdi:thermometer-high" diff --git a/homeassistant/components/fumis/strings.json b/homeassistant/components/fumis/strings.json index 91ea585d9d0cde..85b11a82be3f35 100644 --- a/homeassistant/components/fumis/strings.json +++ b/homeassistant/components/fumis/strings.json @@ -53,6 +53,11 @@ } }, "entity": { + "button": { + "sync_clock": { + "name": "Sync clock" + } + }, "sensor": { "combustion_chamber_temperature": { "name": "Combustion chamber" diff --git a/tests/components/fumis/snapshots/test_button.ambr b/tests/components/fumis/snapshots/test_button.ambr new file mode 100644 index 00000000000000..56b9a91f1891e6 --- /dev/null +++ b/tests/components/fumis/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_buttons[button][button.clou_duo_sync_clock-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.clou_duo_sync_clock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync clock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync clock', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_clock', + 'unique_id': 'aa:bb:cc:dd:ee:ff_sync_clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button][button.clou_duo_sync_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Sync clock', + }), + 'context': , + 'entity_id': 'button.clou_duo_sync_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fumis/test_button.py b/tests/components/fumis/test_button.py new file mode 100644 index 00000000000000..5d8151cbba7974 --- /dev/null +++ b/tests/components/fumis/test_button.py @@ -0,0 +1,67 @@ +"""Tests for the Fumis button entities.""" + +from unittest.mock import MagicMock + +from fumis import FumisConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.fumis.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.parametrize( + "init_integration", [Platform.BUTTON], indirect=True +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Fumis button entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_sync_clock( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test pressing the sync clock button.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.clou_duo_sync_clock"}, + blocking=True, + ) + + mock_fumis.set_clock.assert_called_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_sync_clock_error_handling( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test error handling for button press.""" + mock_fumis.set_clock.side_effect = FumisConnectionError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.clou_duo_sync_clock"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "communication_error" From 595f0411437a1ee9351d0bc7ffc3a9467f0345ef Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 26 Apr 2026 00:45:11 +0200 Subject: [PATCH 11/15] Add MQTT datetime platform (#169091) --- .../components/mqtt/abbreviations.py | 1 + .../components/mqtt/config_integration.py | 1 + homeassistant/components/mqtt/const.py | 2 + homeassistant/components/mqtt/datetime.py | 202 +++++ tests/components/mqtt/test_datetime.py | 704 ++++++++++++++++++ 5 files changed, 910 insertions(+) create mode 100644 homeassistant/components/mqtt/datetime.py create mode 100644 tests/components/mqtt/test_datetime.py diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 6bd2bb4792311a..3ef84762be7540 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -257,6 +257,7 @@ "tit": "title", "t": "topic", "trns": "transition", + "tz": "timezone", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", "url_t": "url_topic", diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 7a445a815a4858..c342747deff65d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -38,6 +38,7 @@ Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), Platform.COVER.value: vol.All(cv.ensure_list, [dict]), Platform.DATE.value: vol.All(cv.ensure_list, [dict]), + Platform.DATETIME.value: vol.All(cv.ensure_list, [dict]), Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), Platform.EVENT.value: vol.All(cv.ensure_list, [dict]), Platform.FAN.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index be02529a87f6fe..1e163c6d41cc4d 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -402,6 +402,7 @@ Platform.CLIMATE, Platform.COVER, Platform.DATE, + Platform.DATETIME, Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, @@ -435,6 +436,7 @@ "climate", "cover", "date", + "datetime", "device_automation", "device_tracker", "event", diff --git a/homeassistant/components/mqtt/datetime.py b/homeassistant/components/mqtt/datetime.py new file mode 100644 index 00000000000000..271fcb07a13f5d --- /dev/null +++ b/homeassistant/components/mqtt/datetime.py @@ -0,0 +1,202 @@ +"""Support for MQTT datetime platform.""" + +from __future__ import annotations + +from collections.abc import Callable +import datetime as datetime_library +import logging +from typing import Any +from zoneinfo import ZoneInfo + +from dateutil.parser import ParserError, parse +from dateutil.tz import UTC +import voluptuous as vol + +from homeassistant.components import datetime +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, VolSchemaType +from homeassistant.util.dt import async_get_time_zone + +from . import subscription +from .config import MQTT_RW_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) +from .entity import MqttEntity, async_setup_entity_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, +) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +CONF_TIMEZONE = "timezone" + +PARALLEL_UPDATES = 0 + +DEFAULT_NAME = "MQTT Date/Time" + +MQTT_DATETIME_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + + +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_TIMEZONE): str, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MQTT datetime through YAML and through MQTT discovery.""" + async_setup_entity_entry_helper( + hass, + config_entry, + MqttDateTime, + datetime.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttDateTime(MqttEntity, DateTimeEntity): + """Representation of the MQTT datetime entity.""" + + _attr_native_value: datetime_library.datetime | None = None + _attributes_extra_blocked = MQTT_DATETIME_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format = datetime.ENTITY_ID_FORMAT + _zone_info: ZoneInfo | None = None + _time_zone_delta: datetime_library.timedelta | None + + _optimistic: bool + _command_template: Callable[ + [PublishPayloadType, dict[str, Any]], PublishPayloadType + ] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + + @staticmethod + def config_schema() -> VolSchemaType: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._zone_info = None + + async def async_set_zone_info(timezone: str) -> None: + self._zone_info = await async_get_time_zone(timezone) + if self._zone_info: + return + _LOGGER.warning( + "Ignoring invalid timezone identifier for entity %s, got '%s'", + self.entity_id, + timezone, + ) + + if timezone := config.get(CONF_TIMEZONE): + self.hass.async_create_task(async_set_zone_info(timezone)) + + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value + optimistic: bool = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) + + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if payload == "": + _LOGGER.debug( + "Ignoring empty state payload on topic %s for entity %s", + msg.topic, + self.entity_id, + ) + return + try: + value = parse(payload) + except ParserError: + _LOGGER.warning( + "Invalid received date/time expression on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + + if self._zone_info is not None: + if value.tzinfo is None: + # Convert to UTC + value = value.replace(tzinfo=self._zone_info).astimezone(UTC) + else: + _LOGGER.warning( + "Date/time expression on topic %s for entity %s was not expected " + "to have timezone info, as this is configured explicitly, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + elif value.tzinfo is None: + _LOGGER.warning( + "Date/time expression without required timezone info received " + "on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + self._attr_native_value = value + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + + async def async_set_value(self, value: datetime_library.datetime) -> None: + """Change the date and time.""" + payload = self._command_template(value.isoformat(), {"value": value}) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) + if self._optimistic: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/tests/components/mqtt/test_datetime.py b/tests/components/mqtt/test_datetime.py new file mode 100644 index 00000000000000..b984843e0e0472 --- /dev/null +++ b/tests/components/mqtt/test_datetime.py @@ -0,0 +1,704 @@ +"""The tests for the MQTT datetime platform.""" + +from __future__ import annotations + +import datetime as datetime_lib +from typing import Any +from unittest.mock import patch + +from dateutil.tz import UTC +from freezegun import freeze_time +import pytest + +from homeassistant.components import datetime, mqtt +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {datetime.DOMAIN: {"name": "test", "command_topic": "test-topic"}} +} + + +async def async_set_value( + hass: HomeAssistant, entity_id: str, value: datetime_lib.datetime | None +) -> None: + """Set date and time value.""" + await hass.services.async_call( + datetime.DOMAIN, + datetime.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, datetime.ATTR_DATETIME: value}, + blocking=True, + ) + + +@freeze_time("2026-04-24T12:52:00+00:00") +@pytest.mark.parametrize( + ("hass_config", "update_state"), + [ + ( + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + }, + ( + ("1/12/2025 3:00 +00:00", "2025-01-12T03:00:00+00:00"), + ("2025-12-02 03:12:10 +00:00", "2025-12-02T03:12:10+00:00"), + ("2025-05-02 03:12:10 +0000", "2025-05-02T03:12:10+00:00"), + ), + ), + ( + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Europe/Amsterdam", + } + } + }, + ( + ("1/12/2025 4:00", "2025-01-12T03:00:00+00:00"), + ("2025-04-02 04:12:10", "2025-04-02T02:12:10+00:00"), + ("2025-05-02 05:12:10", "2025-05-02T03:12:10+00:00"), + ), + ), + ], + ids=["update_with_tz", "tz_offset_7200"], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + update_state: tuple[tuple[str, str],], +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + for update, state_update in update_state: + async_fire_mqtt_message(hass, "state-topic", update) + state = hass.states.get("datetime.test") + assert state.state == state_update + + async_fire_mqtt_message(hass, "state-topic", "None") + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + # Empty string should be ignored + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "") + assert "Ignoring empty state payload" in caplog.text + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + # Invalid value should show warning + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "No valid date/time") + assert "Invalid received date/time expression" in caplog.text + + +@freeze_time("2026-04-01T10:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Europe/London", + } + } + } + ], +) +@pytest.mark.parametrize( + ("received_state", "expected_state"), + [ + ("1 March 2025", "2025-03-01T00:00:00+00:00"), + ("2025.03.01", "2025-03-01T00:00:00+00:00"), + # If only time is parsed the current data is attached + ("00:05:10", "2026-03-31T23:05:10+00:00"), + ], +) +async def test_controlling_validation_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + received_state: str, + expected_state: str, +) -> None: + """Test the validation of a received state.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", received_state) + state = hass.states.get("datetime.test") + assert state.state == expected_state + + +@freeze_time("2026-04-01T10:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Europe/London", + } + } + } + ], +) +@pytest.mark.parametrize( + "received_state", + [ + "2025-03-01T00:00:00+00:00", + "2025-03-01 00:00:00 +0000", + "1 March 2025 00:00:00 +0000", + ], +) +async def test_ambiguous_date_time_state_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, received_state: str +) -> None: + """Test the where the state has a timezone and a timezone is defined.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", received_state) + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Invalid", + } + } + } + ], +) +async def test_date_time_with_invalid_timezone_identifier( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test config with an invalid zimezone identifier.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + assert ( + "Ignoring invalid timezone identifier for entity datetime.test, got 'Invalid'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "qos": "2", + "timezone": "Europe/Amsterdam", + } + } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_value( + hass, + "datetime.test", + datetime_lib.datetime( + year=2025, month=12, day=1, hour=10, minute=12, tzinfo=UTC + ), + ) + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "2025-12-01T10:12:00+00:00", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("datetime.test") + assert state.state == "2025-12-01T10:12:00+00:00" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, datetime.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + config = { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + await help_test_default_availability_payload( + hass, + mqtt_mock_entry, + datetime.DOMAIN, + config, + True, + "state-topic", + "2025-10-01 10:12:00", + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + config = { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + + await help_test_custom_availability_payload( + hass, + mqtt_mock_entry, + datetime.DOMAIN, + config, + True, + "state-topic", + "2025-10-01 10:12:00", + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry, caplog, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry, caplog, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one datetime entity per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, datetime.DOMAIN) + + +async def test_discovery_removal_time( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test removal of discovered datetime entity.""" + data = ( + '{ "name": "test",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_removal(hass, mqtt_mock_entry, datetime.DOMAIN, data) + + +async def test_discovery_datetime_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered datetime entity.""" + config1 = { + "name": "Beer", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + config2 = { + "name": "Milk", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + + await help_test_discovery_update( + hass, mqtt_mock_entry, datetime.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered update.""" + data1 = '{ "name": "Beer", "state_topic": "state-topic", "command_topic": "command-topic"}' + with patch( + "homeassistant.components.mqtt.datetime.MqttDateTime.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock_entry, datetime.DOMAIN, data1, discovery_update + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_broken( + hass, mqtt_mock_entry, datetime.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT date device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT date device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_reloadable( + hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient +) -> None: + """Test reloading the MQTT platform.""" + domain = datetime.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "2025-12-01 10:12:00 +0000", None, "2025-12-01T10:12:00+00:00"), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + datetime.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][datetime.DOMAIN], + topic, + value, + attribute, + attribute_value, + ) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = datetime.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unloading the config entry.""" + domain = datetime.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + datetime.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "2025-12-01 10:12:00 +0000", "2025-12-01 10:12:01 +0000"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + datetime.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "value_template": "{{ value_json.some_var * 1 }}", + }, + ), + ) + ], +) +async def test_value_template_fails( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the rendering of MQTT value template fails.""" + await mqtt_mock_entry() + async_fire_mqtt_message(hass, "test-topic", '{"some_var": null }') + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" + in caplog.text + ) From 77c7225750b945775aef14a573262a1b3df5d708 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 26 Apr 2026 00:47:34 +0200 Subject: [PATCH 12/15] Fix None is not and allowed Unit of Measurement during MQTT Device setup via the UI (#169173) --- homeassistant/components/mqtt/config_flow.py | 21 ++++++--- homeassistant/components/mqtt/entity.py | 5 ++- tests/components/mqtt/common.py | 45 +++++++++++++++++++- tests/components/mqtt/test_config_flow.py | 43 +++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8cbc9e1625a52e..4d013f0dc11508 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -477,7 +477,7 @@ "remote_code": REMOTE_CODE, "remote_code_text": REMOTE_CODE_TEXT, } -EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} +EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY, CONF_UNIT_OF_MEASUREMENT} PWD_NOT_CHANGED = "__**password_not_changed**__" DEVELOPER_DOCUMENTATION_URL = "https://developers.home-assistant.io/" @@ -1133,11 +1133,13 @@ def validate_number_platform_config(config: dict[str, Any]) -> dict[str, str]: errors[CONF_MIN] = "max_below_min" errors[CONF_MAX] = "max_below_min" + if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) == "None": + unit_of_measurement = None + if ( (device_class := config.get(CONF_DEVICE_CLASS)) is not None and device_class in NUMBER_DEVICE_CLASS_UNITS - and config.get(CONF_UNIT_OF_MEASUREMENT) - not in NUMBER_DEVICE_CLASS_UNITS[device_class] + and unit_of_measurement not in NUMBER_DEVICE_CLASS_UNITS[device_class] ): errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" @@ -1166,6 +1168,7 @@ def validate_sensor_platform_config( ): errors[CONF_OPTIONS] = "options_with_enum_device_class" + unit_of_measurement: str | None = None if ( device_class in DEVICE_CLASS_UNITS and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None @@ -1175,6 +1178,10 @@ def validate_sensor_platform_config( errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class" return errors + if unit_of_measurement == "None": + unit_of_measurement = None + config.pop(CONF_UNIT_OF_MEASUREMENT) + if ( device_class is not None and device_class in DEVICE_CLASS_UNITS @@ -4984,7 +4991,9 @@ async def async_step_export_yaml( self._subentry_data["device"].get("mqtt_settings", {}).copy() ) for field in EXCLUDE_FROM_CONFIG_IF_NONE: - if field in component_config and component_config[field] is None: + if field in component_config and ( + component_config[field] is None or component_config[field] == "None" + ): component_config.pop(field) mqtt_yaml_config.append({platform: component_config}) @@ -5033,7 +5042,9 @@ async def async_step_export_discovery( self._subentry_data["device"].get("mqtt_settings", {}).copy() ) for field in EXCLUDE_FROM_CONFIG_IF_NONE: - if field in component_config and component_config[field] is None: + if field in component_config and ( + component_config[field] is None or component_config[field] == "None" + ): component_config.pop(field) discovery_payload["cmps"][component_id] = component_config diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index c43f11d845ce16..0b1b80fc4507a6 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -29,6 +29,7 @@ CONF_MODEL_ID, CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import Event, HassJobType, HomeAssistant, callback @@ -242,7 +243,7 @@ async def _async_setup_non_entity_entry_from_discovery( @callback -def async_setup_entity_entry_helper( +def async_setup_entity_entry_helper( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry, entity_class: type[MqttEntity] | None, @@ -391,6 +392,8 @@ def _async_setup_entities() -> None: and component_config[CONF_ENTITY_CATEGORY] is None ): component_config.pop(CONF_ENTITY_CATEGORY) + if component_config.get(CONF_UNIT_OF_MEASUREMENT) == "None": + component_config.pop(CONF_UNIT_OF_MEASUREMENT) try: config = platform_schema_modern(component_config) diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 19d4992a24073e..0473281b6c437c 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -517,6 +517,26 @@ "entity_picture": "https://example.com/f9261f6feed443e7b7d5f3fbe2a47414", }, } +MOCK_SUBENTRY_NUMBER_COMPONENT_NONE_UNIT = { + "a9261f6feed443e7b7d5f3fbe2a47414": { + "platform": "number", + "name": "Purifier", + "entity_category": None, + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0.0, + "max": 10.0, + "step": 2.0, + "mode": "auto", + "device_class": "aqi", + "unit_of_measurement": "None", + "value_template": "{{ value_json.value }}", + "payload_reset": "None", + "retain": False, + "entity_picture": "https://example.com/a9261f6feed443e7b7d5f3fbe2a47414", + }, +} MOCK_SUBENTRY_SELECT_COMPONENT = { "fa261f6feed443e7b7d5f3fbe2a47414": { "platform": "select", @@ -544,6 +564,20 @@ "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", }, } +MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL = { + "b0f85790a95d4889924602effff06b6e": { + "platform": "sensor", + "name": "Air quality", + "device_class": "aqi", + "entity_category": None, + "state_class": "measurement", + "state_topic": "test-topic", + # `unit_of_measurement` is stored as a string; + # it will be filtered from the config when exported or when set up. + "unit_of_measurement": "None", + "entity_picture": "https://example.com/b0f85790a95d4889924602effff06b6e", + }, +} MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { "a0f85790a95d4889924602effff06b6e": { "platform": "sensor", @@ -793,6 +827,10 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NUMBER_COMPONENT_NO_UNIT, } +MOCK_NUMBER_SUBENTRY_DATA_NONE_UNIT = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NUMBER_COMPONENT_NONE_UNIT, +} MOCK_SELECT_SUBENTRY_DATA = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SELECT_COMPONENT, @@ -805,6 +843,10 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, } +MOCK_SENSOR_SUBENTRY_DATA_UOM_NONE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL, +} MOCK_SENSOR_SUBENTRY_DATA_LAST_RESET_TEMPLATE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, @@ -842,7 +884,8 @@ "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 | MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT - | MOCK_SUBENTRY_SWITCH_COMPONENT, + | MOCK_SUBENTRY_SWITCH_COMPONENT + | MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL, } | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 50aef011f59b95..e6204b02633dd2 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -56,10 +56,12 @@ MOCK_NUMBER_SUBENTRY_DATA_CUSTOM_UNIT, MOCK_NUMBER_SUBENTRY_DATA_DEVICE_CLASS_UNIT, MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT, + MOCK_NUMBER_SUBENTRY_DATA_NONE_UNIT, MOCK_SELECT_SUBENTRY_DATA, MOCK_SENSOR_SUBENTRY_DATA, MOCK_SENSOR_SUBENTRY_DATA_LAST_RESET_TEMPLATE, MOCK_SENSOR_SUBENTRY_DATA_STATE_CLASS, + MOCK_SENSOR_SUBENTRY_DATA_UOM_NONE, MOCK_SIREN_SUBENTRY_DATA, MOCK_SWITCH_SUBENTRY_DATA, MOCK_TEXT_SUBENTRY_DATA, @@ -3557,6 +3559,30 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Speed", id="number_no_unit", ), + pytest.param( + MOCK_NUMBER_SUBENTRY_DATA_NONE_UNIT, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Purifier"}, + { + "device_class": "aqi", + "unit_of_measurement": "None", + }, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0, + "max": 10, + "step": 2, + "mode": "auto", + "value_template": "{{ value_json.value }}", + "retain": False, + }, + (), + "Milk notifier Purifier", + id="number_None_unit", + ), pytest.param( MOCK_SELECT_SUBENTRY_DATA, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, @@ -3632,6 +3658,23 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Energy", id="sensor_options", ), + pytest.param( + MOCK_SENSOR_SUBENTRY_DATA_UOM_NONE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Air quality"}, + { + "state_class": "measurement", + "device_class": "aqi", + "unit_of_measurement": "None", + }, + (), + { + "state_topic": "test-topic", + }, + (), + "Milk notifier Air quality", + id="sensor_aqi", + ), pytest.param( MOCK_SENSOR_SUBENTRY_DATA_STATE_CLASS, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, From e8e9914ef5585de41d07b2ae6598770c09d02192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustav=20=C3=85kerstr=C3=B6m?= <23389010+gustavakerstrom@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:33:41 +0200 Subject: [PATCH 13/15] Template vacuum segments (#167805) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/template/vacuum.py | 123 +++++++- tests/components/template/test_helpers.py | 17 +- tests/components/template/test_vacuum.py | 320 +++++++++++++++++++- 3 files changed, 450 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f06ae13141b324..d5f141b0c90b11 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any @@ -10,6 +11,7 @@ from homeassistant.components.vacuum import ( ATTR_FAN_SPEED, DOMAIN as VACUUM_DOMAIN, + SERVICE_CLEAN_AREA, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -17,6 +19,7 @@ SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, @@ -65,6 +68,7 @@ CONF_FAN_SPEED_LIST = "fan_speeds" CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" +CONF_SEGMENTS_TEMPLATE = "segments_template" DEFAULT_NAME = "Template Vacuum" @@ -77,6 +81,7 @@ } SCRIPT_FIELDS = ( + SERVICE_CLEAN_AREA, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -86,12 +91,19 @@ SERVICE_STOP, ) +CLEAN_AREA_GROUP = "clean_area_group" + VACUUM_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, vol.Optional(CONF_STATE): cv.template, + vol.Inclusive( + CONF_SEGMENTS_TEMPLATE, + CLEAN_AREA_GROUP, + f"Options `{CONF_SEGMENTS_TEMPLATE}` and `{SERVICE_CLEAN_AREA}` must both exist", + ): cv.template, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, @@ -99,15 +111,23 @@ vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + vol.Inclusive( + SERVICE_CLEAN_AREA, + CLEAN_AREA_GROUP, + f"Options `{CONF_SEGMENTS_TEMPLATE}` and `{SERVICE_CLEAN_AREA}` must both exist", + ): cv.SCRIPT_SCHEMA, } ) -VACUUM_YAML_SCHEMA = VACUUM_COMMON_SCHEMA.extend( - TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA -).extend( - make_template_entity_common_modern_attributes_schema( - VACUUM_DOMAIN, DEFAULT_NAME - ).schema + +VACUUM_YAML_SCHEMA = vol.All( + VACUUM_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_attributes_schema( + VACUUM_DOMAIN, DEFAULT_NAME + ).schema + ), + cv.key_dependency(CONF_SEGMENTS_TEMPLATE, CONF_UNIQUE_ID), + cv.key_dependency(SERVICE_CLEAN_AREA, CONF_UNIQUE_ID), ) VACUUM_LEGACY_YAML_SCHEMA = vol.All( @@ -214,6 +234,59 @@ def create_issue( ) +def validate_segments( + entity: AbstractTemplateVacuum, + option: str, +) -> Callable[[Any], list[Segment] | None]: + """Parse segment template to list of segments.""" + + def parse(result: Any) -> list[Segment] | None: + if template_validators.check_result_for_none(result): + return None + + segments: list[Segment] = [] + + if not isinstance(result, list): + template_validators.log_validation_result_error( + entity, + option, + result, + "expected a list of dictionaries", + ) + return None + + for item in result: + if not isinstance(item, dict): + template_validators.log_validation_result_error( + entity, + option, + item, + "expected dictionary with keys id, name and optional group" + " and string values", + ) + return None + + if ( + not isinstance(item.get("id"), str) + or not isinstance(item.get("name"), str) + or ("group" in item and not isinstance(item["group"], str)) + or not set(item).issubset({"id", "name", "group"}) + ): + template_validators.log_validation_result_error( + entity, + option, + item, + "expected dictionary with keys id, name and optional group" + " and string values", + ) + return None + + segments.append(Segment(**item)) + return segments + + return parse + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" @@ -228,6 +301,7 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl # List of valid fan speeds self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] + self._segments: list[Segment] = [] self.setup_state_template( "_attr_activity", template_validators.strenum(self, CONF_STATE, VacuumActivity), @@ -245,6 +319,13 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl template_validators.number(self, CONF_BATTERY_LEVEL, 0.0, 100.0), ) + self.setup_template( + CONF_SEGMENTS_TEMPLATE, + "_segments", + validate_segments(self, CONF_SEGMENTS_TEMPLATE), + self._update_segments, + ) + self._attr_supported_features = ( VacuumEntityFeature.START | VacuumEntityFeature.STATE ) @@ -260,11 +341,41 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl (SERVICE_CLEAN_SPOT, VacuumEntityFeature.CLEAN_SPOT), (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), + (SERVICE_CLEAN_AREA, VacuumEntityFeature.CLEAN_AREA), ): if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature + @callback + def _update_segments(self, result: list[Segment] | None) -> None: + """Save segment templates and create issue when segments changed.""" + if result is None: + return + + self._segments = result + + if (last_seen := self.last_seen_segments) is not None and { + s.id: s for s in last_seen + } != {s.id: s for s in self._segments}: + self.async_create_segments_issue() + + async def async_get_segments(self) -> list[Segment]: + """Return the available segments.""" + return self._segments + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() + if script := self._action_scripts.get(SERVICE_CLEAN_AREA): + await self.async_run_script( + script, + run_variables={"segment_ids": segment_ids}, + context=self._context, + ) + async def async_start(self) -> None: """Start or resume the cleaning task.""" if self._attr_assumed_state: diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py index e4303af31f8705..b7be50eefdbaa5 100644 --- a/tests/components/template/test_helpers.py +++ b/tests/components/template/test_helpers.py @@ -59,6 +59,7 @@ from homeassistant.components.template.vacuum import ( LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, SCRIPT_FIELDS as VACUUM_SCRIPT_FIELDS, + SERVICE_CLEAN_AREA as VACUUM_SERVICE_CLEAN_AREA, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -578,8 +579,14 @@ async def _setup_and_test_yaml_device_action( ), ( "vacuum", - VACUUM_SCRIPT_FIELDS, - {"fan_speeds": ["low", "medium", "high"]}, + [ + service + for service in VACUUM_SCRIPT_FIELDS + if service != VACUUM_SERVICE_CLEAN_AREA + ], + { + "fan_speeds": ["low", "medium", "high"], + }, ( ("start", {}), ("pause", {}), @@ -773,7 +780,11 @@ async def test_yaml_device_actions_modern_config( ), ( "vacuum", - VACUUM_SCRIPT_FIELDS, + [ + service + for service in VACUUM_SCRIPT_FIELDS + if service != VACUUM_SERVICE_CLEAN_AREA + ], { "fan_speeds": ["low", "medium", "high"], "state": "{{ 'on' }}", diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 5036760ef4541c..8cbc0c1e1e027a 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1,18 +1,30 @@ """The tests for the Template vacuum platform.""" +from dataclasses import asdict from typing import Any import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import template, vacuum +from homeassistant.components.template.vacuum import ( + CONF_SEGMENTS_TEMPLATE, + SERVICE_CLEAN_AREA, +) from homeassistant.components.vacuum import ( ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, + Segment, VacuumActivity, VacuumEntityFeature, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + CONF_UNIQUE_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -60,6 +72,7 @@ ) START_ACTION = make_test_action("start") STOP_ACTION = make_test_action("stop") +CLEAN_AREA_ACTION = make_test_action("clean_area", {"segment_ids": "{{ segment_ids }}"}) TEMPLATE_VACUUM_ACTIONS = { **START_ACTION, @@ -1027,6 +1040,311 @@ async def test_not_optimistic( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "unique_id": TEST_VACUUM.entity_id, + "start": [], + **CLEAN_AREA_ACTION, + "segments_template": "{{ [{'id': '1', 'name': 'Livingroom'}, {'id': '2', 'name': 'Kitchen'}] }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_clean_area( + hass: HomeAssistant, + calls: list[ServiceCall], + entity_registry: er.EntityRegistry, +) -> None: + """Test clean area passes segment IDs to action.""" + entity_registry.async_update_entity_options( + TEST_VACUUM.entity_id, + vacuum.DOMAIN, + { + "area_mapping": {"area_1": ["1", "2"]}, + "last_seen_segments": [ + {"id": "1", "name": "Livingroom"}, + {"id": "2", "name": "Kitchen"}, + ], + }, + ) + + await common.async_clean_area(hass, ["area_1"], TEST_VACUUM.entity_id) + await hass.async_block_till_done() + assert_action(TEST_VACUUM, calls, 1, "clean_area", segment_ids=["1", "2"]) + + state = hass.states.get(TEST_VACUUM.entity_id) + assert state is not None + assert state.attributes["supported_features"] & VacuumEntityFeature.CLEAN_AREA + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "start": [], + **CLEAN_AREA_ACTION, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "expected_segments"), + [ + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [" + "{'id': '1', 'name': 'Kitchen'}, " + "{'id': '2', 'name': 'Bedroom', 'group': 'Upstairs'}" + "] }}", + }, + [ + Segment(id="1", name="Kitchen"), + Segment(id="2", name="Bedroom", group="Upstairs"), + ], + ), + ], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_get_segments( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + expected_segments: list[Segment], +) -> None: + """Test get_segments returns segments from template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, VacuumActivity.DOCKED) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": TEST_VACUUM.entity_id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "segments": [asdict(segment) for segment in expected_segments] + } + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "start": [], + **CLEAN_AREA_ACTION, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "err_msg"), + [ + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'id': '1'} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'name': 'kitchen'} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'id': '1', 'name': 'Kitchen', 'extra_key': 'value'} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + {"unique_id": TEST_VACUUM.entity_id, "segments_template": "{{ [[]] }}"}, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'id': '1', 'name': 'Kitchen'}, [] ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_invalid_segments_template( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog_setup_text, + caplog: pytest.LogCaptureFixture, + err_msg: str, +) -> None: + """Test that errors are logged if parsing segment template fails.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, "Works") + await hass.async_block_till_done() + + assert err_msg in caplog_setup_text or err_msg in caplog.text + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": TEST_VACUUM.entity_id} + ) + msg = await client.receive_json() + assert msg["result"] == {"segments": []} + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "unique_id": TEST_VACUUM.entity_id, + "start": [], + **CLEAN_AREA_ACTION, + "segments_template": "{{ [ {'id': '1', 'name': 'Kitchen'}, {'id': '2', 'name': states('sensor.test_attribute')}] }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_raise_segments_changed_issue( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that issue is raised on segments change.""" + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Bedroom") + await hass.async_block_till_done() + + entity_registry.async_update_entity_options( + TEST_VACUUM.entity_id, + vacuum.DOMAIN, + { + "last_seen_segments": [ + {"id": "1", "name": "Kitchen"}, + {"id": "2", "name": "Bedroom"}, + ], + }, + ) + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Bathroom") + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) != 0 + + +@pytest.mark.parametrize( + ("vacuum_config", "err_msg"), + [ + ( + {**START_ACTION}, + f"Options `{CONF_SEGMENTS_TEMPLATE}` and `{SERVICE_CLEAN_AREA}` must both exist", + ), + ], +) +@pytest.mark.parametrize( + ("count", "extra_config"), + [ + ( + 0, + { + "segments_template": "{{ [{'id': '1', 'name': 'Kitchen'}] }}", + }, + ), + ( + 0, + {**CLEAN_AREA_ACTION}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_segments_part_config( + hass: HomeAssistant, + caplog_setup_text, + caplog: pytest.LogCaptureFixture, + count: int, + err_msg: str, +) -> None: + """Test creating vacuum with segments, missing required options.""" + assert len(hass.states.async_all(vacuum.DOMAIN)) == count + assert err_msg in caplog_setup_text or err_msg in caplog.text + + +@pytest.mark.parametrize( + ("vacuum_config"), + [ + { + **START_ACTION, + **CLEAN_AREA_ACTION, + "segments_template": "{{ [{'id': '1', 'name': 'Kitchen'}] }}", + }, + ], +) +@pytest.mark.parametrize( + ("count", "extra_config", "err_msg"), + [ + ( + 0, + {}, + f'key "{CONF_SEGMENTS_TEMPLATE}" requires key "{CONF_UNIQUE_ID}" to exist', + ), + (1, {"unique_id": TEST_VACUUM.entity_id}, ""), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_segments_unique_id( + hass: HomeAssistant, + caplog_setup_text, + caplog: pytest.LogCaptureFixture, + count: int, + err_msg: str, +) -> None: + """Test creating vacuum with segments, missing required options.""" + assert len(hass.states.async_all(vacuum.DOMAIN)) == count + assert err_msg in caplog_setup_text or err_msg in caplog.text + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, From 8673694f6fe97c2d71c86a9324223b2ccdb9e8de Mon Sep 17 00:00:00 2001 From: javicalle <31999997+javicalle@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:23:56 +0200 Subject: [PATCH 14/15] Try to fix `RFLink` tests broken by Python 3.14.3 asyncio changes (#169157) --- tests/components/rflink/test_binary_sensor.py | 15 +++++++++------ tests/components/rflink/test_init.py | 2 +- tests/components/rflink/test_sensor.py | 15 +++++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 6b8ce286fd9a88..6bce2f6cab25d9 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -87,23 +87,26 @@ async def test_default_setup( assert hass.states.get("binary_sensor.test").state == STATE_OFF -@pytest.mark.xfail( - reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" -) async def test_entity_availability( hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch ) -> None: """If Rflink device is disconnected, entities should become unavailable.""" # Make sure Rflink mock does not 'recover' to quickly from the # disconnect or else the unavailability cannot be measured - config = CONFIG - failures = [True, True] - config[CONF_RECONNECT_INTERVAL] = 60 + config = { + **CONFIG, + "rflink": { + **CONFIG["rflink"], + CONF_RECONNECT_INTERVAL: 60, + }, + } + failures = [False, True] # Create platform and entities event_callback, _, _, disconnect_callback = await mock_rflink( hass, config, DOMAIN, monkeypatch, failures=failures ) + await hass.async_block_till_done() # Entities are unknown by default assert hass.states.get("binary_sensor.test").state == STATE_UNKNOWN diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 736ad4c73cf1db..100ed07adf9cce 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -58,7 +58,7 @@ async def create_rflink_connection(*args, **kwargs): # failures can be a list of booleans indicating in which sequence # creating a connection should success or fail if failures: - fail = failures.pop() + fail = failures.pop(0) # removes from left to right else: fail = False diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py index 96204043298ff9..05655cd52ae413 100644 --- a/tests/components/rflink/test_sensor.py +++ b/tests/components/rflink/test_sensor.py @@ -131,23 +131,26 @@ async def test_disable_automatic_add( assert not hass.states.get("sensor.test2") -@pytest.mark.xfail( - reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" -) async def test_entity_availability( hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch ) -> None: """If Rflink device is disconnected, entities should become unavailable.""" # Make sure Rflink mock does not 'recover' to quickly from the # disconnect or else the unavailability cannot be measured - config = CONFIG - failures = [True, True] - config[CONF_RECONNECT_INTERVAL] = 60 + config = { + **CONFIG, + "rflink": { + **CONFIG["rflink"], + CONF_RECONNECT_INTERVAL: 60, + }, + } + failures = [False, True] # Create platform and entities _, _, _, disconnect_callback = await mock_rflink( hass, config, DOMAIN, monkeypatch, failures=failures ) + await hass.async_block_till_done() # Entities are available by default assert hass.states.get("sensor.test").state == STATE_UNKNOWN From f1fcca2c75837b1da3e02c0fb77e5349e9c9f7b3 Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:19:05 +0200 Subject: [PATCH 15/15] Bump satel_integra to 1.2.2 (#169180) --- homeassistant/components/satel_integra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index 79a56a68ce5670..ab5ed8e2b2b468 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["satel_integra"], "quality_scale": "bronze", - "requirements": ["satel-integra==1.2.1"] + "requirements": ["satel-integra==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c75147c7216c07..bf46e4d05bf182 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2896,7 +2896,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.2.1 +satel-integra==1.2.2 # homeassistant.components.screenlogic screenlogicpy==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be5bdd3ae27d95..07751fea2bc794 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2468,7 +2468,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.2.1 +satel-integra==1.2.2 # homeassistant.components.screenlogic screenlogicpy==0.10.2