From 25026554bd083e85ea8ba340ef7b5c08acf8eba7 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Tue, 14 Jul 2026 17:36:53 +0200 Subject: [PATCH 01/10] Bump PyViCare to 2.61.0 (#176499) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/vicare/conftest.py | 29 +++++++++++-------- .../vicare/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 55ba55642566c..78e66edf31af1 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.60.2"] + "requirements": ["PyViCare==2.61.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f8d4e733c57c..aa4cf64c0ab95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -99,7 +99,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.60.2 +PyViCare==2.61.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 90dc29f255330..4cb265422f3a7 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -38,33 +38,37 @@ def __init__(self, fixtures: list[Fixture]) -> None: """Init a single device from json dump.""" self.devices = [] for idx, fixture in enumerate(fixtures): + service = MockViCareService( + f"installation{idx}", f"gateway{idx}", f"deviceId{idx}", fixture + ) self.devices.append( PyViCareDeviceConfig( - MockViCareService( - f"installation{idx}", f"gateway{idx}", f"device{idx}", fixture - ), - f"deviceId{idx}", + service.accessor, + service, "Vitovalor" if fixture.data_file.endswith("VitoValor.json") else f"model{idx}", "Online", + roles=list(fixture.roles), ) ) # Simulate a device with an unsupported deviceType that PyViCare's # `devices` filter would drop but should still appear in `all_devices` # (used by diagnostics). + unsupported_service = MockViCareService( + "installation_unsupported", + "gateway_unsupported", + "deviceId_unsupported", + Fixture(set(), "vicare/dummy-device-no-serial.json"), + ) self.all_devices = [ *self.devices, PyViCareDeviceConfig( - MockViCareService( - "installation_unsupported", - "gateway_unsupported", - "device_unsupported", - Fixture(set(), "vicare/dummy-device-no-serial.json"), - ), - "deviceId_unsupported", + unsupported_service.accessor, + unsupported_service, "unsupported_model", "Online", + roles=[], ), ] @@ -88,6 +92,7 @@ def __init__( """Initialize the mock from a json dump.""" self._test_data = load_json_object_fixture(fixture.data_file) self.fetch_all_features = Mock(return_value=self._test_data) + self.setProperty = Mock() self.roles = fixture.roles self.accessor = ViCareDeviceAccessor(installation_id, gateway_id, device_id) @@ -95,7 +100,7 @@ def hasRoles(self, requested_roles: list[str]) -> bool: """Return true if requested roles are assigned.""" return requested_roles and set(requested_roles).issubset(self.roles) - def getProperty(self, property_name: str): + def getProperty(self, accessor: ViCareDeviceAccessor, property_name: str): """Read a property from json dump.""" return readFeature(self._test_data["data"], property_name) diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index fb27ef68d284b..4f189f5bc56be 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4715,6 +4715,7 @@ 'id': 'deviceId0', 'modelId': 'model0', 'roles': list([ + 'type:boiler', ]), 'status': 'Online', 'type': None, From 445643e90473f450792a2139228fa80d5d0c1325 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Tue, 14 Jul 2026 17:46:03 +0200 Subject: [PATCH 02/10] Fix untranslated BleBox button and input binary sensor names (#176497) --- .../components/blebox/binary_sensor.py | 1 + homeassistant/components/blebox/button.py | 2 -- homeassistant/components/blebox/strings.json | 10 +++++++ tests/components/blebox/test_binary_sensor.py | 4 +-- tests/components/blebox/test_button.py | 30 +++++++++---------- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index ba7c768f24aa7..aca1b550eadaa 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -29,6 +29,7 @@ ), BinarySensorEntityDescription( key="input", + translation_key="input", ), ) diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index fd277810369fc..16ab7b4493d71 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -43,8 +43,6 @@ async def async_setup_entry( class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity): """Representation of BleBox buttons.""" - _attr_name = None - def __init__( self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button ) -> None: diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index 382d6c34ebe03..82f9cb4944f6a 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -70,6 +70,16 @@ } }, "entity": { + "binary_sensor": { + "input": { "name": "Input" } + }, + "button": { + "close": { "name": "Close" }, + "down": { "name": "Down" }, + "fav": { "name": "Favorite" }, + "open": { "name": "Open" }, + "up": { "name": "Up" } + }, "light": { "channel": { "name": "Channel {index}" } }, "sensor": { "active_power": { "name": "Active power" }, diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index ea9585f0a7464..1ba01a7ef02a5 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -62,7 +62,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: product = feature.product type(product).name = PropertyMock(return_value="My input sensor") type(product).model = PropertyMock(return_value="inputSensorD") - return feature, "binary_sensor.my_input_sensor" + return feature, "binary_sensor.my_input_sensor_input" @pytest.mark.parametrize( @@ -87,7 +87,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: pytest.param( "inputsensor", "BleBox-inputSensorD-aa11bb22cc33-0.input", - "My input sensor", + "My input sensor Input", None, STATE_ON, "My input sensor", diff --git a/tests/components/blebox/test_button.py b/tests/components/blebox/test_button.py index 6e9a5c3323bb5..1ec63623141bc 100644 --- a/tests/components/blebox/test_button.py +++ b/tests/components/blebox/test_button.py @@ -7,17 +7,16 @@ import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .conftest import async_setup_entity, mock_feature query_translation_key_matching = [ - ("up", "up"), - ("down", "down"), - ("fav", "fav"), - ("open", "open"), - ("close", "close"), - ("unknown_action", None), + ("up", "up", "button.my_tvliftbox_up", "My tvLiftBox Up"), + ("down", "down", "button.my_tvliftbox_down", "My tvLiftBox Down"), + ("fav", "fav", "button.my_tvliftbox_favorite", "My tvLiftBox Favorite"), + ("open", "open", "button.my_tvliftbox_open", "My tvLiftBox Open"), + ("close", "close", "button.my_tvliftbox_close", "My tvLiftBox Close"), + ("unknown_action", None, "button.my_tvliftbox", "My tvLiftBox"), ] @@ -58,13 +57,15 @@ async def test_tvliftbox_init( @pytest.mark.parametrize( - ("query_string", "expected_translation_key"), + ("query_string", "expected_translation_key", "expected_entity_id", "expected_name"), query_translation_key_matching, ids=[q[0] for q in query_translation_key_matching], ) async def test_button_translation_key( query_string: str, expected_translation_key: str | None, + expected_entity_id: str, + expected_name: str, tvliftbox: tuple[blebox_uniapi.button.Button, str], hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -72,13 +73,12 @@ async def test_button_translation_key( """Test that the correct translation_key is assigned based on query_string.""" caplog.set_level(logging.ERROR) - feature_mock, entity_id = tvliftbox + feature_mock, _ = tvliftbox feature_mock.query_string = query_string - await async_setup_entity(hass, entity_id) - - state = hass.states.get(entity_id) - assert state is not None - - entity = er.async_get(hass).async_get(entity_id) + entity = await async_setup_entity(hass, expected_entity_id) assert entity is not None assert entity.translation_key == expected_translation_key + + state = hass.states.get(expected_entity_id) + assert state is not None + assert state.name == expected_name From 5b8c8578e430aa61ccdb389b1488acf3820682f3 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:46:20 +0200 Subject: [PATCH 03/10] Update syrupy to 5.5.1 (#176489) --- requirements_test.txt | 2 +- tests/conftest.py | 8 +- tests/syrupy.py | 169 ------------------------------------------ 3 files changed, 2 insertions(+), 177 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 8873e7986966d..d6cabd86c687f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.3.4 +syrupy==5.5.1 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 diff --git a/tests/conftest.py b/tests/conftest.py index 5fba335c33a77..f8e7e37bc53cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ import requests_mock import respx from syrupy.assertion import SnapshotAssertion -from syrupy.session import SnapshotSession # Setup patching of JSON functions before any other Home Assistant imports from . import patch_json # isort:skip @@ -108,7 +107,7 @@ from homeassistant.util.json import json_loads from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS -from .syrupy import HomeAssistantSnapshotExtension, override_syrupy_finish +from .syrupy import HomeAssistantSnapshotExtension from .typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -173,11 +172,6 @@ def pytest_configure(config: pytest.Config) -> None: if config.getoption("verbose") > 0: logging.getLogger().setLevel(logging.DEBUG) - # Override default finish to detect unused snapshots despite xdist - # Temporary workaround until it is finalised inside syrupy - # See https://github.com/syrupy-project/syrupy/pull/901 - SnapshotSession.finish = override_syrupy_finish - class HASocketBlockedError(pytest_socket.SocketBlockedError): """SocketBlockedError variant which counts instances.""" diff --git a/tests/syrupy.py b/tests/syrupy.py index a877996310980..253ebea3f247e 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -3,22 +3,14 @@ from contextlib import suppress import dataclasses from enum import IntFlag -import json -import os from pathlib import Path from typing import Any import attr import attrs -import pytest -from syrupy.constants import EXIT_STATUS_FAIL_UNUSED -from syrupy.data import Snapshot, SnapshotCollection, SnapshotCollections from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension from syrupy.location import PyTestLocation -from syrupy.report import SnapshotReport -from syrupy.session import ItemStatus, SnapshotSession from syrupy.types import PropertyFilter, PropertyMatcher, PropertyPath, SerializableData -from syrupy.utils import is_xdist_controller, is_xdist_worker import voluptuous as vol import voluptuous_serialize @@ -272,164 +264,3 @@ def dirname(cls, *, test_location: PyTestLocation) -> str: """ test_dir = Path(test_location.filepath).parent return str(test_dir.joinpath("snapshots")) - - -# Classes and Methods to override default finish behavior in syrupy -# This is needed to handle the xdist plugin in pytest -# The default implementation does not handle the xdist plugin -# and will not work correctly when running tests in parallel -# with pytest-xdist. -# Temporary workaround until it is finalised inside syrupy -# See https://github.com/syrupy-project/syrupy/pull/901 - - -class _FakePytestObject: - """Fake object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake object.""" - self.__module__ = collected_item["modulename"] - self.__name__ = collected_item["methodname"] - - -class _FakePytestItem: - """Fake pytest.Item object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake pytest.Item object.""" - self.nodeid = collected_item["nodeid"] - self.name = collected_item["name"] - self.path = Path(collected_item["path"]) - self.obj = _FakePytestObject(collected_item) - - -def _serialize_collections(collections: SnapshotCollections) -> dict[str, Any]: - return { - k: [c.name for c in v] for k, v in collections._snapshot_collections.items() - } - - -def _serialize_report( - report: SnapshotReport, - collected_items: set[pytest.Item], - selected_items: dict[str, ItemStatus], -) -> dict[str, Any]: - return { - "discovered": _serialize_collections(report.discovered), - "created": _serialize_collections(report.created), - "failed": _serialize_collections(report.failed), - "matched": _serialize_collections(report.matched), - "updated": _serialize_collections(report.updated), - "used": _serialize_collections(report.used), - "_collected_items": [ - { - "nodeid": c.nodeid, - "name": c.name, - "path": str(c.path), - "modulename": c.obj.__module__, - "methodname": c.obj.__name__, - } - for c in list(collected_items) - ], - "_selected_items": { - key: status.value for key, status in selected_items.items() - }, - } - - -def _merge_serialized_collections( - collections: SnapshotCollections, json_data: dict[str, list[str]] -) -> None: - if not json_data: - return - for location, names in json_data.items(): - snapshot_collection = SnapshotCollection(location=location) - for name in names: - snapshot_collection.add(Snapshot(name)) - collections.update(snapshot_collection) - - -def _merge_serialized_report(report: SnapshotReport, json_data: dict[str, Any]) -> None: - _merge_serialized_collections(report.discovered, json_data["discovered"]) - _merge_serialized_collections(report.created, json_data["created"]) - _merge_serialized_collections(report.failed, json_data["failed"]) - _merge_serialized_collections(report.matched, json_data["matched"]) - _merge_serialized_collections(report.updated, json_data["updated"]) - _merge_serialized_collections(report.used, json_data["used"]) - for collected_item in json_data["_collected_items"]: - custom_item = _FakePytestItem(collected_item) - if not any( - t.nodeid == custom_item.nodeid and t.name == custom_item.nodeid - for t in report.collected_items - ): - report.collected_items.add(custom_item) - for key, selected_item in json_data["_selected_items"].items(): - if key in report.selected_items: - status = ItemStatus(selected_item) - if status is not ItemStatus.NOT_RUN: - report.selected_items[key] = status - else: - report.selected_items[key] = ItemStatus(selected_item) - - -def override_syrupy_finish(self: SnapshotSession) -> int: - """Override the finish method to allow for custom handling.""" - exitstatus = 0 - self.flush_snapshot_write_queue() - self.report = SnapshotReport( - base_dir=self.pytest_session.config.rootpath, - collected_items=self._collected_items, - selected_items=self._selected_items, - assertions=self._assertions, - options=self.pytest_session.config.option, - ) - - needs_xdist_merge = self.update_snapshots or bool( - self.pytest_session.config.option.include_snapshot_details - ) - - if is_xdist_worker(): - if not needs_xdist_merge: - return exitstatus - with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: - f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) - with open( - f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", - "w", - encoding="utf-8", - ) as f: - json.dump( - _serialize_report( - self.report, self._collected_items, self._selected_items - ), - f, - indent=2, - ) - return exitstatus - if is_xdist_controller(): - return exitstatus - - if needs_xdist_merge: - worker_count = None - try: - with open(".pytest_syrupy_worker_count", encoding="utf-8") as f: - worker_count = f.read() - os.remove(".pytest_syrupy_worker_count") - except FileNotFoundError: - pass - - if worker_count: - for i in range(int(worker_count)): - with open(f".pytest_syrupy_gw{i}_result", encoding="utf-8") as f: - _merge_serialized_report(self.report, json.load(f)) - os.remove(f".pytest_syrupy_gw{i}_result") - - if self.report.num_unused: - if self.update_snapshots: - self.remove_unused_snapshots( - unused_snapshot_collections=self.report.unused, - used_snapshot_collections=self.report.used, - ) - elif not self.warn_unused_snapshots: - exitstatus |= EXIT_STATUS_FAIL_UNUSED - return exitstatus From 46f82fd3ca39069c992b580a771e67e70e6b7679 Mon Sep 17 00:00:00 2001 From: Hamish Date: Wed, 15 Jul 2026 01:16:29 +0930 Subject: [PATCH 04/10] Add Gatus Integration (#175085) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/gatus/__init__.py | 25 +++ .../components/gatus/binary_sensor.py | 105 +++++++++++ homeassistant/components/gatus/config_flow.py | 87 ++++++++++ homeassistant/components/gatus/const.py | 3 + homeassistant/components/gatus/coordinator.py | 48 +++++ homeassistant/components/gatus/manifest.json | 12 ++ .../components/gatus/quality_scale.yaml | 88 ++++++++++ homeassistant/components/gatus/strings.json | 28 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + tests/components/gatus/__init__.py | 15 ++ tests/components/gatus/conftest.py | 58 +++++++ tests/components/gatus/fixtures/group.json | 8 + tests/components/gatus/fixtures/no_group.json | 7 + .../gatus/snapshots/test_binary_sensor.ambr | 52 ++++++ tests/components/gatus/test_binary_sensor.py | 164 ++++++++++++++++++ tests/components/gatus/test_config_flow.py | 154 ++++++++++++++++ tests/components/gatus/test_init.py | 46 +++++ 22 files changed, 923 insertions(+) create mode 100644 homeassistant/components/gatus/__init__.py create mode 100644 homeassistant/components/gatus/binary_sensor.py create mode 100644 homeassistant/components/gatus/config_flow.py create mode 100644 homeassistant/components/gatus/const.py create mode 100644 homeassistant/components/gatus/coordinator.py create mode 100644 homeassistant/components/gatus/manifest.json create mode 100644 homeassistant/components/gatus/quality_scale.yaml create mode 100644 homeassistant/components/gatus/strings.json create mode 100644 tests/components/gatus/__init__.py create mode 100644 tests/components/gatus/conftest.py create mode 100644 tests/components/gatus/fixtures/group.json create mode 100644 tests/components/gatus/fixtures/no_group.json create mode 100644 tests/components/gatus/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/gatus/test_binary_sensor.py create mode 100644 tests/components/gatus/test_config_flow.py create mode 100644 tests/components/gatus/test_init.py diff --git a/.strict-typing b/.strict-typing index e3629e702389a..400f8d1f32ed0 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.* homeassistant.components.fully_kiosk.* homeassistant.components.fumis.* homeassistant.components.fyta.* +homeassistant.components.gatus.* homeassistant.components.generic_hygrostat.* homeassistant.components.generic_thermostat.* homeassistant.components.geo_location.* diff --git a/CODEOWNERS b/CODEOWNERS index 8d02a119003da..ccb837bedb740 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,6 +625,8 @@ CLAUDE.md @home-assistant/core /tests/components/gardena_bluetooth/ @elupus /homeassistant/components/gate/ @home-assistant/core /tests/components/gate/ @home-assistant/core +/homeassistant/components/gatus/ @TN-1 +/tests/components/gatus/ @TN-1 /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 diff --git a/homeassistant/components/gatus/__init__.py b/homeassistant/components/gatus/__init__.py new file mode 100644 index 0000000000000..93cbcfc5999a4 --- /dev/null +++ b/homeassistant/components/gatus/__init__.py @@ -0,0 +1,25 @@ +"""The Gatus integration.""" + +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Set up Gatus from a config entry.""" + coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL]) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/gatus/binary_sensor.py b/homeassistant/components/gatus/binary_sensor.py new file mode 100644 index 0000000000000..f35d8815e42d1 --- /dev/null +++ b/homeassistant/components/gatus/binary_sensor.py @@ -0,0 +1,105 @@ +"""Support for Gatus binary sensors.""" + +from typing import override + +from gatus_api import EndpointStatus, Result + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GatusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Gatus binary sensor platform.""" + coordinator = entry.runtime_data + + async_add_entities( + GatusEndpointBinarySensor(coordinator, entry, endpoint_key) + for endpoint_key in coordinator.data + ) + + +class GatusEndpointBinarySensor( + CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Gatus endpoint status.""" + + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: GatusDataUpdateCoordinator, + entry: GatusConfigEntry, + endpoint_key: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._endpoint_key = endpoint_key + + endpoint_data = self.endpoint_data + + endpoint_name = endpoint_data.name + if endpoint_data.group is not None: + device_name = f"{endpoint_data.group} {endpoint_name}" + else: + device_name = endpoint_name + + self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")}, + name=device_name, + manufacturer="Gatus", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + @override + def is_on(self) -> bool | None: + """Return true if the endpoint is up and healthy.""" + latest_result = self.latest_result + if latest_result is None: + return None + + return latest_result.success + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + data = self.coordinator.data + # Guard for empty results list, which could imply a brand new endpoint + return ( + super().available + and self._endpoint_key in data + and bool(data[self._endpoint_key].results) + ) + + @property + def endpoint_data(self) -> EndpointStatus: + """Return this specific endpoint's data from the coordinator.""" + return self.coordinator.data[self._endpoint_key] + + @property + def latest_result(self) -> Result | None: + """Return the most recent monitoring result (Gatus appends newest last).""" + results = self.endpoint_data.results + if not results: + return None + return results[-1] diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py new file mode 100644 index 0000000000000..972f200abae7e --- /dev/null +++ b/homeassistant/components/gatus/config_flow.py @@ -0,0 +1,87 @@ +"""Config flow for the Gatus integration.""" + +import logging +from typing import Any, override + +from gatus_api import GatusClient, GatusClientError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate that the user input allows us to connect to Gatus and return data.""" + client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass)) + + try: + await client.get_endpoints_statuses() + except GatusClientError as err: + _LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err) + raise CannotConnect from err + + +class GatusConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Gatus.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial setup step when adding the integration via the UI.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + url = URL(user_input[CONF_URL]) + except ValueError: + errors["base"] = "invalid_url" + else: + if url.scheme not in {"http", "https"} or not url.host: + errors["base"] = "invalid_url" + else: + normalized_url = str( + url.with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + user_input[CONF_URL] = normalized_url + + self._async_abort_entries_match({CONF_URL: normalized_url}) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus setup") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Gatus", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the server.""" diff --git a/homeassistant/components/gatus/const.py b/homeassistant/components/gatus/const.py new file mode 100644 index 0000000000000..89ac9ee41fff3 --- /dev/null +++ b/homeassistant/components/gatus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Gatus integration.""" + +DOMAIN = "gatus" diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py new file mode 100644 index 0000000000000..37739f2ff6f3a --- /dev/null +++ b/homeassistant/components/gatus/coordinator.py @@ -0,0 +1,48 @@ +"""DataUpdateCoordinator for the Gatus integration.""" + +from datetime import timedelta +import logging +from typing import override + +from gatus_api import EndpointStatus, GatusClient, GatusClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type GatusConfigEntry = ConfigEntry[GatusDataUpdateCoordinator] + + +class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]]): + """Class to manage fetching Gatus data from the API via third-party library.""" + + def __init__(self, hass: HomeAssistant, entry: GatusConfigEntry, url: str) -> None: + """Initialize the coordinator.""" + self.url = url.rstrip("/") + self.client = GatusClient(url=self.url, session=async_get_clientsession(hass)) + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @override + async def _async_update_data(self) -> dict[str, EndpointStatus]: + """Fetch endpoint statuses from the Gatus API.""" + try: + raw_endpoints = await self.client.get_endpoints_statuses() + except GatusClientError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from err + + return {ep.key: ep for ep in raw_endpoints} diff --git a/homeassistant/components/gatus/manifest.json b/homeassistant/components/gatus/manifest.json new file mode 100644 index 0000000000000..53fddeab56ed0 --- /dev/null +++ b/homeassistant/components/gatus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gatus", + "name": "Gatus", + "codeowners": ["@TN-1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gatus", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["gatus_api"], + "quality_scale": "silver", + "requirements": ["gatus-api==1.0.3"] +} diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml new file mode 100644 index 0000000000000..3d9207ece6b97 --- /dev/null +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: + status: exempt + comment: Integration does not register custom events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not use authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Integration does not support discovery. + 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: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities represent monitored services and should be enabled by default. + entity-translations: + status: exempt + comment: Entity names are dynamically provided by the Gatus service. + exception-translations: done + icon-translations: + status: exempt + comment: Entities use the connectivity device class for their icon and define no custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not require user intervention repairs. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json new file mode 100644 index 0000000000000..6f6610ddbb017 --- /dev/null +++ b/homeassistant/components/gatus/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_url": "Please enter a valid absolute URL (e.g., http://192.168.1.50:8080)", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The full base URL of your Gatus status page instance including protocol and port." + }, + "description": "Enter the network details for your Gatus status page instance. Make sure to include the protocol (e.g., `http://` or `https://`) and the port number if you are not using a standard port." + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error communicating with Gatus API" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 83f559cf28118..da9a9ef06b1b1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,6 +264,7 @@ "fyta", "garages_amsterdam", "gardena_bluetooth", + "gatus", "gdacs", "generic", "geniushub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2387a9de906f0..b676f78a5c2aa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2388,6 +2388,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "gatus": { + "name": "Gatus", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "gaviota": { "name": "Gaviota", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 73645a4a23603..2da3ccca92c70 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2037,6 +2037,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gatus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.generic_hygrostat.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index aa4cf64c0ab95..ab54a315cbee5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1077,6 +1077,9 @@ gardena-bluetooth==2.8.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 +# homeassistant.components.gatus +gatus-api==1.0.3 + # homeassistant.components.google gcal-sync==8.0.0 diff --git a/tests/components/gatus/__init__.py b/tests/components/gatus/__init__.py new file mode 100644 index 0000000000000..26e3d9d4d9b17 --- /dev/null +++ b/tests/components/gatus/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Gatus integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Gatus integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/gatus/conftest.py b/tests/components/gatus/conftest.py new file mode 100644 index 0000000000000..1e557575e259e --- /dev/null +++ b/tests/components/gatus/conftest.py @@ -0,0 +1,58 @@ +"""Common fixtures for the Gatus tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from gatus_api import EndpointStatus, Result +import pytest + +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gatus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_gatus_client() -> Generator[AsyncMock]: + """Mock the third-party Gatus API client wrapper globally across coordinator and config flow.""" + with ( + patch( + "homeassistant.components.gatus.coordinator.GatusClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.gatus.config_flow.GatusClient", + new=mock_client, + ), + ): + client_instance = mock_client.return_value + client_instance.get_endpoints_statuses = AsyncMock( + return_value=[ + EndpointStatus( + key="backend_service", + name="Backend Service", + group="Core", + results=[Result(success=True, status=200)], + ) + ] + ) + yield client_instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Fixture to cleanly create a Gatus configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example.com:8080"}, + entry_id="1234567890abcdef1234567890abcdef", + ) diff --git a/tests/components/gatus/fixtures/group.json b/tests/components/gatus/fixtures/group.json new file mode 100644 index 0000000000000..8c7c032441e28 --- /dev/null +++ b/tests/components/gatus/fixtures/group.json @@ -0,0 +1,8 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "group": "Core", + "results": [{ "success": false, "status": 500 }] + } +] diff --git a/tests/components/gatus/fixtures/no_group.json b/tests/components/gatus/fixtures/no_group.json new file mode 100644 index 0000000000000..c582a4eb75357 --- /dev/null +++ b/tests/components/gatus/fixtures/no_group.json @@ -0,0 +1,7 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "results": [{ "success": true, "status": 200 }] + } +] diff --git a/tests/components/gatus/snapshots/test_binary_sensor.ambr b/tests/components/gatus/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..56d2fc37d30b5 --- /dev/null +++ b/tests/components/gatus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.core_backend_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'gatus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef_backend_service', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'Core Backend Service', + }), + 'context': , + 'entity_id': 'binary_sensor.core_backend_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/gatus/test_binary_sensor.py b/tests/components/gatus/test_binary_sensor.py new file mode 100644 index 0000000000000..761a2c757a0fa --- /dev/null +++ b/tests/components/gatus/test_binary_sensor.py @@ -0,0 +1,164 @@ +"""Tests for the Gatus binary sensor platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from gatus_api import EndpointStatus, GatusClientError, Result +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) + + +async def test_binary_sensor_setup_and_states( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +def _to_endpoint_statuses(raw_data: list[dict[str, Any]]) -> list[EndpointStatus]: + return [ + EndpointStatus( + key=ep["key"], + name=ep["name"], + group=ep.get("group"), + results=[ + Result(success=r["success"], status=r["status"]) + for r in ep.get("results", []) + ], + ) + for ep in raw_data + ] + + +async def test_binary_sensor_dynamic_update( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the binary sensor entity updates when the mock client returns new data.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_data = await async_load_json_array_fixture(hass, "gatus/group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + freezer.tick(300) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "off" + + +async def test_binary_sensor_no_group( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the binary sensor entity is created correctly when an endpoint has no group.""" + mock_data = await async_load_json_array_fixture(hass, "gatus/no_group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "on" + + +async def test_binary_sensor_client_error( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a client exception cleanly marks entities as unavailable.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError + + freezer.tick(30) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "unavailable" + + +async def test_binary_sensor_empty_results( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with empty results is treated as unavailable.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "unavailable" + + # Verify underlying properties return None directly on empty results + entity = hass.data["binary_sensor"].get_entity("binary_sensor.backend_service") + assert entity is not None + assert entity.latest_result is None + assert entity.is_on is None + + +async def test_binary_sensor_missing_status( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with a result missing a status code is handled correctly.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[Result(success=False, status=None)], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "off" diff --git a/tests/components/gatus/test_config_flow.py b/tests/components/gatus/test_config_flow.py new file mode 100644 index 0000000000000..45fbc09afe8c1 --- /dev/null +++ b/tests/components/gatus/test_config_flow.py @@ -0,0 +1,154 @@ +"""Test the Gatus Config flow.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant import config_entries +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form, validate the client, and create a successful entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success_with_path( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form, validate the client, and create a successful entry with a sub-path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080/gatus-instance/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080/gatus-instance", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_gatus_client: AsyncMock +) -> None: + """Test handling of a malformed URL and subsequent recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "gatus.example.com"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:abc"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_form_failures_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test handling validation failures and ensuring the flow can completely recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate configurations for the same base URL abort early.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/gatus/test_init.py b/tests/components/gatus/test_init.py new file mode 100644 index 0000000000000..ebb0f2d772c3b --- /dev/null +++ b/tests/components/gatus/test_init.py @@ -0,0 +1,46 @@ +"""Tests for the Gatus integration setup and unload lifecycle.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant.components.gatus.coordinator import GatusDataUpdateCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_setup_and_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test standard successful setup and unload cycle of the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + assert isinstance(mock_config_entry.runtime_data, GatusDataUpdateCoordinator) + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_failure_retry( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an API connection failure during initial setup places the entry in retry state.""" + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError( + "Cannot connect to Gatus API during initial setup" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 1bc5926ff181981e33f64532722a25b27d043c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 14 Jul 2026 17:10:42 +0100 Subject: [PATCH 05/10] Use gh actions service containers instead of direct docker commands (#176502) --- .github/workflows/e2e-tests.yml | 52 ++++++++++++--------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d46a16ed9bd6f..b3784dca600af 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -9,9 +9,6 @@ on: default: "dev" required: true -env: - STARTUP_TIMEOUT_SECONDS: 300 - permissions: {} concurrency: @@ -33,35 +30,20 @@ jobs: - arch: aarch64 runs-on: ubuntu-24.04-arm env: - IMAGE: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} BASE_URL: http://localhost:8123 CURL_OPTS: --silent --max-time 10 + services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images] + ports: + - 8123:8123 + # Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget) + options: >- + --health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/" + --health-start-period=10s + --health-interval=5s + --health-retries=60 steps: - - name: Pull image - id: pull - run: | - docker pull "$IMAGE" - docker image inspect -f 'Testing {{index .RepoDigests 0}} ({{.Os}}/{{.Architecture}}), created {{.Created}}' "$IMAGE" - - - name: Start container - run: | - docker run -d --name homeassistant -p 8123:8123 "$IMAGE" - - - name: Wait for Home Assistant to start - run: | - timeout=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) - while ! curl $CURL_OPTS --fail --output /dev/null "$BASE_URL/"; do - if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then - echo "::error::Container exited before Home Assistant started" - exit 1 - fi - if [ "$SECONDS" -ge "$timeout" ]; then - echo "::error::Home Assistant did not respond on port 8123 within ${STARTUP_TIMEOUT_SECONDS}s" - exit 1 - fi - sleep 5 - done - - name: Check frontend is served run: | # Pre-onboarding, / redirects to /onboarding.html; --location follows it @@ -77,18 +59,22 @@ jobs: | jq -e 'type == "array" and length > 0' - name: Check container is still running + env: + CONTAINER: ${{ job.services.homeassistant.id }} run: | - if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then + if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then echo "::error::Container is no longer running after checks" exit 1 fi - name: Dump container logs - if: always() && steps.pull.outcome == 'success' - run: docker logs homeassistant > homeassistant.log 2>&1 || true + if: always() + env: + CONTAINER: ${{ job.services.homeassistant.id }} + run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true - name: Upload container logs - if: always() && steps.pull.outcome == 'success' + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: container-logs-${{ matrix.arch }} From de252d4b0db0570c69159fd576d6ae750004476f Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 14 Jul 2026 20:14:37 +0200 Subject: [PATCH 06/10] Fix ProxmoxVE entities allowed without permissions (#176360) --- .../components/proxmoxve/binary_sensor.py | 13 + homeassistant/components/proxmoxve/button.py | 70 +-- homeassistant/components/proxmoxve/const.py | 2 + homeassistant/components/proxmoxve/sensor.py | 12 + .../components/proxmoxve/strings.json | 9 - tests/components/proxmoxve/__init__.py | 7 + tests/components/proxmoxve/conftest.py | 9 +- .../proxmoxve/snapshots/test_button.ambr | 401 ------------------ .../proxmoxve/test_binary_sensor.py | 47 +- tests/components/proxmoxve/test_button.py | 58 +-- tests/components/proxmoxve/test_sensor.py | 25 +- 11 files changed, 141 insertions(+), 512 deletions(-) diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 1dba1d6985edc..69f814a97c11d 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -20,6 +20,7 @@ STORAGE_ENABLED, STORAGE_SHARED, VM_CONTAINER_RUNNING, + ProxmoxPermission, ) from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( @@ -28,6 +29,7 @@ ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -51,6 +53,8 @@ class ProxmoxNodeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class to hold Proxmox node binary sensor description.""" state_fn: Callable[[ProxmoxNodeData], bool | None] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -67,6 +71,8 @@ class ProxmoxStorageBinarySensorEntityDescription(BinarySensorEntityDescription) state_fn=lambda data: data.node["status"] == NODE_ONLINE, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, + permission=ProxmoxPermission.VMAUDIT, # PVEVMUsers are allowed this node, through "/vms" + permission_target="vms", ), ProxmoxNodeBinarySensorEntityDescription( key="node_backup_status", @@ -132,10 +138,17 @@ async def async_setup_entry( def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: """Add new node binary sensors.""" + async_add_entities( ProxmoxNodeBinarySensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 5c5bdda0f1144..b93e455dccab4 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -17,7 +17,7 @@ ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -28,8 +28,6 @@ PARALLEL_UPDATES = 1 -NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power" - @dataclass(frozen=True, kw_only=True) class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): @@ -37,7 +35,6 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str], None] permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER - permission_raise: str = "no_permission_node_power" permission_target: str = "nodes" @@ -47,7 +44,6 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -57,7 +53,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -82,7 +77,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): key="start_all", translation_key="start_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -93,7 +87,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): key="stop_all", translation_key="stop_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -104,7 +97,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): key="suspend_all", translation_key="suspend_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -185,7 +177,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -230,7 +221,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -250,6 +240,12 @@ def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: ProxmoxNodeButtonEntity(coordinator, entity_description, node) for node in nodes for entity_description in NODE_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( @@ -260,6 +256,12 @@ def _async_add_new_vms( ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data) for (node_data, vm) in vms for entity_description in VM_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=vm["vmid"], + permission=entity_description.permission, + ) ) def _async_add_new_containers( @@ -272,6 +274,12 @@ def _async_add_new_containers( ) for (node_data, container) in containers for entity_description in CONTAINER_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=container["vmid"], + permission=entity_description.permission, + ) ) coordinator.new_nodes_callbacks.append(_async_add_new_nodes) @@ -351,21 +359,10 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the node button action via executor.""" - node_id = self._node_data.node["node"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=node_id, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, - node_id, + self._node_data.node["node"], ) @@ -377,22 +374,11 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the VM button action via executor.""" - vmid = self.vm_data["vmid"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.vm_data["vmid"], ) @@ -404,21 +390,9 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the container button action via executor.""" - vmid = self.container_data["vmid"] - # Container power actions fall under vms - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.container_data["vmid"], ) diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index bfd944612a0db..8985a2a77ec99 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -41,4 +41,6 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" + SYSAUDIT = "Sys.Audit" SYSPOWER = "Sys.PowerMgmt" + VMAUDIT = "VM.Audit" diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index 5701473fc8c44..d4140fc13d5ef 100644 --- a/homeassistant/components/proxmoxve/sensor.py +++ b/homeassistant/components/proxmoxve/sensor.py @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from .const import ProxmoxPermission from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( ProxmoxContainerEntity, @@ -25,6 +26,7 @@ ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -34,6 +36,8 @@ class ProxmoxNodeSensorEntityDescription(SensorEntityDescription): """Class to hold Proxmox node sensor description.""" value_fn: Callable[[ProxmoxNodeData], StateType | datetime] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -147,6 +151,8 @@ class ProxmoxStorageSensorEntityDescription(SensorEntityDescription): value_fn=lambda data: data.node["status"], device_class=SensorDeviceClass.ENUM, options=["online", "offline"], + permission=ProxmoxPermission.VMAUDIT, + permission_target="vms", ), ProxmoxNodeSensorEntityDescription( key="node_backup_last_backup", @@ -474,6 +480,12 @@ def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: ProxmoxNodeSensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index fd35574b8fc29..904b88f894def 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -308,15 +308,6 @@ "no_nodes_found": { "message": "No active nodes were found on the Proxmox VE server." }, - "no_permission_node_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again." - }, - "no_permission_snapshot": { - "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." - }, - "no_permission_vm_lxc_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." - }, "no_vmlxc_found": { "message": "No LXC or VM were found on the Proxmox VE server." }, diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index 1cf65ea787468..07c70348383c7 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -1,5 +1,7 @@ """Tests for Proxmox VE integration.""" +from copy import deepcopy + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -53,6 +55,11 @@ | set(SNAPSHOT_PERMISSIONS) } +PVEVMUSER_PERMISSIONS = deepcopy(MERGED_PERMISSIONS) +# Remove node-level and root-level scopes entirely +PVEVMUSER_PERMISSIONS.pop("/", None) +PVEVMUSER_PERMISSIONS.pop("/nodes", None) + async def setup_integration( hass: HomeAssistant, diff --git a/tests/components/proxmoxve/conftest.py b/tests/components/proxmoxve/conftest.py index 1decc74ac46c2..8eab09af90952 100644 --- a/tests/components/proxmoxve/conftest.py +++ b/tests/components/proxmoxve/conftest.py @@ -15,6 +15,7 @@ CONF_TOKEN_SECRET, CONF_VMS, DOMAIN, + ProxmoxPermission, ) from homeassistant.const import ( CONF_HOST, @@ -124,8 +125,12 @@ def mock_proxmox_client(): node_mock.storage.get.return_value = load_json_array_fixture( "nodes/storage.json", DOMAIN ) - node_mock.tasks.get.return_value = load_json_array_fixture( - "nodes/tasks.json", DOMAIN + + node_mock.tasks.get.side_effect = lambda **kwargs: ( + [] + if ProxmoxPermission.SYSAUDIT + not in mock_instance.access.permissions.get.return_value.get("/nodes", []) + else load_json_array_fixture("nodes/tasks.json", DOMAIN) ) qemu_by_vmid = {vm["vmid"]: vm for vm in qemu_list} diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index ef752b8a613e0..ff74d97cd4f7a 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -652,407 +652,6 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_db-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.vm_db', - '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': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'resume', - 'unique_id': '1234_101_resume', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db', - }), - 'context': , - 'entity_id': 'button.vm_db', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-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.vm_db_create_snapshot', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Create snapshot', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Create snapshot', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'snapshot_create', - 'unique_id': '1234_101_snapshot_create', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Create snapshot', - }), - 'context': , - 'entity_id': 'button.vm_db_create_snapshot', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-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.vm_db_hibernate', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Hibernate', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hibernate', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hibernate', - 'unique_id': '1234_101_hibernate', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Hibernate', - }), - 'context': , - 'entity_id': 'button.vm_db_hibernate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-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.vm_db_reset', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Reset', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Reset', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'reset', - 'unique_id': '1234_101_reset', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Reset', - }), - 'context': , - 'entity_id': 'button.vm_db_reset', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-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.vm_db_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Restart', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234_101_restart', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'restart', - : 'vm-db Restart', - }), - 'context': , - 'entity_id': 'button.vm_db_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-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.vm_db_shut_down', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Shut down', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Shut down', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'shutdown', - 'unique_id': '1234_101_shutdown', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Shut down', - }), - 'context': , - 'entity_id': 'button.vm_db_shut_down', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_start-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.vm_db_start', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Start', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Start', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'start', - 'unique_id': '1234_101_start', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Start', - }), - 'context': , - 'entity_id': 'button.vm_db_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-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.vm_db_stop', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Stop', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Stop', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'stop', - 'unique_id': '1234_101_stop', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Stop', - }), - 'context': , - 'entity_id': 'button.vm_db_stop', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_button_entities[button.vm_web-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/proxmoxve/test_binary_sensor.py b/tests/components/proxmoxve/test_binary_sensor.py index 4dd60e789f321..d1e2eb5c59833 100644 --- a/tests/components/proxmoxve/test_binary_sensor.py +++ b/tests/components/proxmoxve/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -80,3 +80,48 @@ async def test_refresh_exceptions( state = hass.states.get("binary_sensor.ct_nginx_status") assert state.state == STATE_UNAVAILABLE + + +async def test_binary_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are created when allowed.""" + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" in {e.entity_id for e in entries} + + +async def test_binary_sensors_absent_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" not in {e.entity_id for e in entries} diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index 2b8769949101e..abf2bd171bdcd 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -11,7 +11,7 @@ from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import AUDIT_PERMISSIONS, setup_integration @@ -362,61 +362,19 @@ async def test_container_buttons_exceptions( ) -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.pve1_shut_down", "no_permission_node_power"), - ("button.pve1_start_all", "no_permission_vm_lxc_power"), - ("button.ct_nginx_start", "no_permission_vm_lxc_power"), - ("button.vm_web_start", "no_permission_vm_lxc_power"), - ("button.vm_web_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_node_buttons_permission_denied_for_auditor_role( +async def test_buttons_only_allowed_buttons( hass: HomeAssistant, mock_proxmox_client: MagicMock, mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test that buttons are raising accordingly for Auditor permissions.""" + """Test that ProxmoxVE button is not generated when not allowed.""" mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS await setup_integration(hass, mock_config_entry) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key - - -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.vm_db_start", "no_permission_vm_lxc_power"), - ("button.vm_db_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_vm_buttons_denied_for_specific_vm( - hass: HomeAssistant, - mock_proxmox_client: MagicMock, - mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, -) -> None: - """Test that button only works on actual permissions.""" - await setup_integration(hass, mock_config_entry) - mock_proxmox_client._node_mock.qemu(101) + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key + assert all(not entry.entity_id.startswith("button.") for entry in entries) diff --git a/tests/components/proxmoxve/test_sensor.py b/tests/components/proxmoxve/test_sensor.py index f4fc55cb97e5b..a2109bbd03729 100644 --- a/tests/components/proxmoxve/test_sensor.py +++ b/tests/components/proxmoxve/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import ( MockConfigEntry, @@ -68,3 +68,26 @@ async def test_storage_missing_used_fraction( state = hass.states.get("sensor.storage_local_storage_usage_percentage") assert state.state == STATE_UNKNOWN + + +async def test_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "sensor.pve1_status" in {e.entity_id for e in entries} + assert "sensor.pve1_cpu" not in {e.entity_id for e in entries} From 57f10d2de3309d33f35ca3d9fd39c703bf745847 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 14 Jul 2026 21:03:09 +0200 Subject: [PATCH 07/10] Fix hash-seed dependent flakiness in test_setup_frontend_before_recorder (#176508) Co-authored-by: Claude Fable 5 --- tests/test_bootstrap.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index bc0ced1b5c3be..68c434592c325 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -619,9 +619,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: assert "recorder" in hass.config.components assert "http" in hass.config.components - assert order == [ - "http", - "an_after_dep", + # http (a dependency) and an_after_dep (an after_dependency) are both set + # up in the frontend substage of stage 0; their relative order depends on + # set iteration order and is not guaranteed. + assert set(order[:2]) == {"http", "an_after_dep"} + assert order[2:] == [ "frontend", "recorder", "normal_integration", From d61f3ec6804927894967aa289cb1d1947f400395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 21:25:19 +0200 Subject: [PATCH 08/10] Add diagnostics to nobo_hub (#176514) --- .../components/nobo_hub/diagnostics.py | 52 ++++++++++++++++++ .../components/nobo_hub/quality_scale.yaml | 2 +- tests/components/nobo_hub/conftest.py | 10 ++-- .../nobo_hub/snapshots/test_diagnostics.ambr | 54 +++++++++++++++++++ tests/components/nobo_hub/test_diagnostics.py | 21 ++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/nobo_hub/diagnostics.py create mode 100644 tests/components/nobo_hub/snapshots/test_diagnostics.ambr create mode 100644 tests/components/nobo_hub/test_diagnostics.py diff --git a/homeassistant/components/nobo_hub/diagnostics.py b/homeassistant/components/nobo_hub/diagnostics.py new file mode 100644 index 0000000000000..62adeddc955ca --- /dev/null +++ b/homeassistant/components/nobo_hub/diagnostics.py @@ -0,0 +1,52 @@ +"""Diagnostics support for Nobø Ecohub.""" + +from typing import Any + +from pynobo import ComponentInfo + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant + +from . import NoboHubConfigEntry +from .const import ATTR_SERIAL, CONF_SERIAL + +TO_REDACT_ENTRY = {CONF_IP_ADDRESS, CONF_MAC, CONF_SERIAL} +TO_REDACT_HUB = {ATTR_SERIAL} + +_MODEL_FIELDS = ( + "model_id", + "name", + "type", + "has_temp_sensor", + "requires_control_panel", + "supports_comfort", + "supports_eco", +) + + +def _component_to_dict(component: ComponentInfo) -> dict[str, Any]: + formatted = dict(component) + if (model := formatted.get("model")) is not None: + formatted["model"] = { + field: getattr(model, field, None) for field in _MODEL_FIELDS + } + return formatted + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NoboHubConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hub = entry.runtime_data + return { + "entry_data": async_redact_data(entry.data, TO_REDACT_ENTRY), + "hub_info": async_redact_data(hub.hub_info, TO_REDACT_HUB), + "zones": hub.zones, + "components": async_redact_data( + [_component_to_dict(c) for c in hub.components.values()], + TO_REDACT_HUB, + ), + "week_profiles": hub.week_profiles, + "overrides": hub.overrides, + } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index ce62526480a97..5c5dddba9d9be 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -48,7 +48,7 @@ rules: status: done comment: > Model name "Nobø Ecohub" under review for rename to "Nobø Hub". - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done docs-data-update: done diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 974e803740c34..0cab97fed99c6 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -106,10 +106,12 @@ def mock_nobo_class( "temp_eco_c": "17", }, } - model = MagicMock() - # Direct assignment overrides MagicMock's auto-attr for `.name`. - model.name = "Panel heater" - model.has_temp_sensor = True + model = pynobo_nobo.Model( + model_id="183", + type="THERMOSTAT_FLOOR", + name="Panel heater", + has_temp_sensor=True, + ) hub.components = { "200000059091": { "serial": "200000059091", diff --git a/tests/components/nobo_hub/snapshots/test_diagnostics.ambr b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..72d02fa6421e6 --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'components': list([ + dict({ + 'model': dict({ + 'has_temp_sensor': True, + 'model_id': '183', + 'name': 'Panel heater', + 'requires_control_panel': False, + 'supports_comfort': False, + 'supports_eco': False, + 'type': 'THERMOSTAT_FLOOR', + }), + 'name': 'Floor sensor', + 'serial': '**REDACTED**', + 'zone_id': '1', + }), + ]), + 'entry_data': dict({ + 'ip_address': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'hub_info': dict({ + 'hardware_version': 'hw', + 'name': 'My Eco Hub', + 'serial': '**REDACTED**', + 'software_version': '115', + }), + 'overrides': dict({ + '988': dict({ + 'mode': '0', + 'target_id': '-1', + 'target_type': '0', + }), + }), + 'week_profiles': dict({ + '0': dict({ + 'name': 'Default', + 'profile': '00000', + 'week_profile_id': '0', + }), + }), + 'zones': dict({ + '1': dict({ + 'name': 'Living room', + 'temp_comfort_c': '21', + 'temp_eco_c': '17', + 'week_profile_id': '0', + 'zone_id': '1', + }), + }), + }) +# --- diff --git a/tests/components/nobo_hub/test_diagnostics.py b/tests/components/nobo_hub/test_diagnostics.py new file mode 100644 index 0000000000000..e4cbebf2b716d --- /dev/null +++ b/tests/components/nobo_hub/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Tests for the Nobø Ecohub diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert result == snapshot From 886f76c9c41eb86cd640231b0801f7512fc5a533 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Jul 2026 21:38:44 +0200 Subject: [PATCH 09/10] Bump modbus-connection to 3.7.0 (#176521) Co-authored-by: Claude --- homeassistant/components/modbus_connection/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json index 7d78cd95d247f..156d5f3e45a87 100644 --- a/homeassistant/components/modbus_connection/manifest.json +++ b/homeassistant/components/modbus_connection/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["modbus_connection", "tmodbus"], "quality_scale": "bronze", - "requirements": ["modbus-connection[tmodbus]==3.6.0"] + "requirements": ["modbus-connection[tmodbus]==3.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab54a315cbee5..9422cf251412b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1601,7 +1601,7 @@ mitsubishi-comfort==0.3.2 moat-ble==0.1.1 # homeassistant.components.modbus_connection -modbus-connection[tmodbus]==3.6.0 +modbus-connection[tmodbus]==3.7.0 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 From f37df79107e57922cd320f00087ec75659d809b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 22:26:52 +0200 Subject: [PATCH 10/10] Redact serial from unknown component model name in diagnostics (#176524) --- .../components/nobo_hub/diagnostics.py | 15 +++++---- tests/components/nobo_hub/test_diagnostics.py | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nobo_hub/diagnostics.py b/homeassistant/components/nobo_hub/diagnostics.py index 62adeddc955ca..7916848774cdc 100644 --- a/homeassistant/components/nobo_hub/diagnostics.py +++ b/homeassistant/components/nobo_hub/diagnostics.py @@ -2,9 +2,9 @@ from typing import Any -from pynobo import ComponentInfo +from pynobo import ComponentInfo, nobo -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import HomeAssistant @@ -26,11 +26,12 @@ def _component_to_dict(component: ComponentInfo) -> dict[str, Any]: - formatted = dict(component) - if (model := formatted.get("model")) is not None: - formatted["model"] = { - field: getattr(model, field, None) for field in _MODEL_FIELDS - } + model = component["model"] + formatted: dict[str, Any] = dict(component) + formatted["model"] = {field: getattr(model, field, None) for field in _MODEL_FIELDS} + if model.type == nobo.Model.UNKNOWN: + # Unknown models carry the serial number in the name. + formatted["model"]["name"] = REDACTED return formatted diff --git a/tests/components/nobo_hub/test_diagnostics.py b/tests/components/nobo_hub/test_diagnostics.py index e4cbebf2b716d..2fcdeb95328cd 100644 --- a/tests/components/nobo_hub/test_diagnostics.py +++ b/tests/components/nobo_hub/test_diagnostics.py @@ -1,7 +1,11 @@ """Tests for the Nobø Ecohub diagnostics.""" +from unittest.mock import MagicMock + +from pynobo import nobo as pynobo_nobo from syrupy.assertion import SnapshotAssertion +from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -19,3 +23,31 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) assert result == snapshot + + +async def test_entry_diagnostics_redacts_unknown_model_name( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """An unknown model's name embeds the serial, so it is dropped; model_id is kept.""" + mock_nobo_hub.components = { + "999000012345": { + "serial": "999000012345", + "name": "Mystery device", + "zone_id": "1", + "model": pynobo_nobo.Model( + model_id="999", + type=pynobo_nobo.Model.UNKNOWN, + name="Unknown (serial number: 999 000 012 345)", + ), + }, + } + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + component = result["components"][0] + assert component["serial"] == REDACTED + assert component["model"]["model_id"] == "999" + assert component["model"]["name"] == REDACTED