diff --git a/homeassistant/components/duco/diagnostics.py b/homeassistant/components/duco/diagnostics.py index 777fda6c70e3e..1cf8bc66b059d 100644 --- a/homeassistant/components/duco/diagnostics.py +++ b/homeassistant/components/duco/diagnostics.py @@ -13,6 +13,9 @@ from .const import DOMAIN from .coordinator import DucoConfigEntry +# MAC addresses and serial numbers are redacted because a Duco installer or +# manufacturer could cross-reference them against an installation registry to +# identify the physical location of the device. TO_REDACT = { CONF_HOST, "mac", @@ -31,9 +34,15 @@ async def async_get_config_entry_diagnostics( coordinator = entry.runtime_data board = asdict(coordinator.board_info) + # `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage. board.pop("time") + if board["public_api_version"] is None: + board.pop("public_api_version") + if board["software_version"] is None: + board.pop("software_version") try: + api_info_obj = await coordinator.client.async_get_api_info() lan_info = await coordinator.client.async_get_lan_info() duco_diags = await coordinator.client.async_get_diagnostics() write_remaining = await coordinator.client.async_get_write_req_remaining() @@ -43,10 +52,15 @@ async def async_get_config_entry_diagnostics( translation_key="connection_error", ) from err + api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version} + if api_info_obj.reported_api_version is not None: + api_info["reported_api_version"] = api_info_obj.reported_api_version + return async_redact_data( { "entry_data": entry.data, "board_info": board, + "api_info": api_info, "lan_info": asdict(lan_info), "nodes": { str(node_id): asdict(node) diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 3487397a53fd2..c525acfffa5f0 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, patch from duco.models import ( + ApiEndpointInfo, + ApiInfo, BoardInfo, DiagComponent, DiagStatus, @@ -49,6 +51,25 @@ def mock_board_info() -> BoardInfo: serial_duco_box="GHI789", serial_duco_comm="JKL012", time=1700000000, + public_api_version="2.5", + software_version="1.2.3", + ) + + +@pytest.fixture +def mock_api_info() -> ApiInfo: + """Return mock API info.""" + return ApiInfo( + api_version="2.5", + reported_api_version="2.5.1", + endpoints=[ + ApiEndpointInfo( + url="/info", + query_parameters=["module", "submodule"], + methods=["GET"], + modules=["General", "Diag"], + ) + ], ) @@ -180,6 +201,7 @@ def mock_nodes() -> list[Node]: @pytest.fixture def mock_duco_client( + mock_api_info: ApiInfo, mock_board_info: BoardInfo, mock_lan_info: LanInfo, mock_nodes: list[Node], @@ -202,6 +224,7 @@ def mock_duco_client( ), ): client = mock_class.return_value + client.async_get_api_info.return_value = mock_api_info client.async_get_board_info.return_value = mock_board_info client.async_get_lan_info.return_value = mock_lan_info client.async_get_nodes.return_value = mock_nodes diff --git a/tests/components/duco/snapshots/test_diagnostics.ambr b/tests/components/duco/snapshots/test_diagnostics.ambr index 76b108b65e3b3..7398883b1625b 100644 --- a/tests/components/duco/snapshots/test_diagnostics.ambr +++ b/tests/components/duco/snapshots/test_diagnostics.ambr @@ -1,15 +1,19 @@ # serializer version: 1 # name: test_diagnostics dict({ + 'api_info': dict({ + 'public_api_version': '2.5', + 'reported_api_version': '2.5.1', + }), 'board_info': dict({ 'box_name': 'SILENT_CONNECT', 'box_sub_type_name': 'Eu', - 'public_api_version': None, + 'public_api_version': '2.5', 'serial_board_box': '**REDACTED**', 'serial_board_comm': '**REDACTED**', 'serial_duco_box': '**REDACTED**', 'serial_duco_comm': '**REDACTED**', - 'software_version': None, + 'software_version': '1.2.3', }), 'duco_diagnostics': list([ dict({ diff --git a/tests/components/duco/test_diagnostics.py b/tests/components/duco/test_diagnostics.py index 3eae0e689e171..87289b1d398cd 100644 --- a/tests/components/duco/test_diagnostics.py +++ b/tests/components/duco/test_diagnostics.py @@ -1,9 +1,11 @@ """Tests for the Duco diagnostics.""" +from dataclasses import replace from http import HTTPStatus from unittest.mock import AsyncMock from duco.exceptions import DucoConnectionError +from duco.models import ApiInfo import pytest from syrupy.assertion import SnapshotAssertion @@ -24,7 +26,7 @@ async def test_diagnostics( mock_duco_client: AsyncMock, snapshot: SnapshotAssertion, ) -> None: - """Test diagnostics.""" + """Test that the full diagnostics payload matches the snapshot.""" assert ( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) == snapshot @@ -34,7 +36,12 @@ async def test_diagnostics( @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "failing_method", - ["async_get_lan_info", "async_get_diagnostics", "async_get_write_req_remaining"], + [ + "async_get_api_info", + "async_get_lan_info", + "async_get_diagnostics", + "async_get_write_req_remaining", + ], ) async def test_diagnostics_connection_error( hass: HomeAssistant, @@ -54,3 +61,46 @@ async def test_diagnostics_connection_error( f"/api/diagnostics/config_entry/{mock_config_entry.entry_id}" ) assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_diagnostics_without_optional_board_metadata( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test that None board fields are omitted from the diagnostics payload.""" + # BoardInfo is a frozen dataclass, so the mock must be updated before + # integration setup — the coordinator stores board_info during async_setup. + mock_duco_client.async_get_board_info.return_value = replace( + mock_duco_client.async_get_board_info.return_value, + public_api_version=None, + software_version=None, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert "public_api_version" not in diagnostics["board_info"] + assert "software_version" not in diagnostics["board_info"] + + +@pytest.mark.usefixtures("init_integration") +async def test_diagnostics_without_optional_api_metadata( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test diagnostics when optional API metadata is absent.""" + mock_duco_client.async_get_api_info.return_value = ApiInfo(api_version="2.5") + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diagnostics["api_info"] == {"public_api_version": "2.5"}