diff --git a/docs/migration-v2.md b/docs/migration-v2.md index cb9e415f..ba93bf34 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -112,6 +112,30 @@ The command execution API has been consolidated into a single method. v2 also supports sending actions to **multiple devices** in a single call and choosing an `ExecutionMode` (`HIGH_PRIORITY`, `GEOLOCATED`, `INTERNAL`). +## Diagnostics + +`get_diagnostic_data()` now returns a structured dict with named sections instead of a flat setup dump. + +| v1 | v2 | +|----|-----| +| `data["gateways"]` | `data["setup"]["gateways"]` | +| (not available) | `data["action_groups"]` | + +=== "v1" + + ```python + diagnostics = await client.get_diagnostic_data() + gateways = diagnostics["gateways"] + ``` + +=== "v2" + + ```python + diagnostics = await client.get_diagnostic_data() + gateways = diagnostics["setup"]["gateways"] + action_groups = diagnostics["action_groups"] + ``` + ## Scenarios → Action groups | v1 | v2 | diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 02eb6eb8..6bc51298 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import ssl import urllib.parse @@ -329,23 +330,33 @@ async def get_setup(self, refresh: bool = False) -> Setup: return setup @retry_on_auth_error - async def get_diagnostic_data(self, mask_sensitive_data: bool = True) -> JSON: - """Get all data about the connected user setup. + async def get_diagnostic_data( + self, mask_sensitive_data: bool = True + ) -> dict[str, Any]: + """Get diagnostic data for the connected user setup. -> gateways data (serial number, activation state, ...): -> setup location: -> house places (rooms and floors): - -> setup devices: . + -> setup devices: + -> action groups: By default, this data is masked to not return confidential or PII data. - Set `mask_sensitive_data` to `False` to return the raw setup payload. + Set `mask_sensitive_data` to `False` to return the raw payloads. """ - response = await self._get("setup") + setup, action_groups = await asyncio.gather( + self._get("setup"), + self._get("actionGroups"), + ) if mask_sensitive_data: - return obfuscate_sensitive_data(response) + setup = obfuscate_sensitive_data(setup) + action_groups = obfuscate_sensitive_data(action_groups) - return response + return { + "setup": setup, + "action_groups": action_groups, + } @retry_on_auth_error async def get_devices(self, refresh: bool = False) -> list[Device]: diff --git a/pyoverkiz/obfuscate.py b/pyoverkiz/obfuscate.py index ca26abd6..53b3506d 100644 --- a/pyoverkiz/obfuscate.py +++ b/pyoverkiz/obfuscate.py @@ -3,9 +3,7 @@ from __future__ import annotations import re -from typing import Any - -from pyoverkiz.types import JSON +from typing import Any, cast def obfuscate_id(id: str | None) -> str: @@ -24,8 +22,15 @@ def obfuscate_string(input: str) -> str: return re.sub(r"[a-zA-Z0-9_.-]*", "*", str(input)) -def obfuscate_sensitive_data(data: dict[str, Any]) -> JSON: +def obfuscate_sensitive_data( + data: dict[str, Any] | list[dict[str, Any]], +) -> dict[str, Any] | list[dict[str, Any]]: """Mask Overkiz JSON data to remove sensitive data.""" + if isinstance(data, list): + return cast( + list[dict[str, Any]], [obfuscate_sensitive_data(item) for item in data] + ) + mask_next_value = False for key, value in data.items(): diff --git a/tests/test_client.py b/tests/test_client.py index 0d525fda..f7cece70 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -303,11 +303,27 @@ async def test_get_diagnostic_data(self, client: OverkizClient, fixture_name: st with (CURRENT_DIR / "fixtures" / "setup" / fixture_name).open( encoding="utf-8", ) as setup_mock: - resp = MockResponse(setup_mock.read()) + setup_resp = MockResponse(setup_mock.read()) - with patch.object(aiohttp.ClientSession, "get", return_value=resp): + with ( + CURRENT_DIR + / "fixtures" + / "action_groups" + / "action-group-tahoma-switch.json" + ).open( + encoding="utf-8", + ) as ag_mock: + ag_resp = MockResponse(ag_mock.read()) + + responses = iter([setup_resp, ag_resp]) + + with patch.object( + aiohttp.ClientSession, "get", side_effect=lambda *a, **kw: next(responses) + ): diagnostics = await client.get_diagnostic_data() assert diagnostics + assert "setup" in diagnostics + assert "action_groups" in diagnostics @pytest.mark.asyncio async def test_get_diagnostic_data_redacted_by_default(self, client: OverkizClient): @@ -315,18 +331,37 @@ async def test_get_diagnostic_data_redacted_by_default(self, client: OverkizClie with (CURRENT_DIR / "fixtures" / "setup" / "setup_tahoma_1.json").open( encoding="utf-8", ) as setup_mock: - resp = MockResponse(setup_mock.read()) + setup_resp = MockResponse(setup_mock.read()) with ( - patch.object(aiohttp.ClientSession, "get", return_value=resp), + CURRENT_DIR + / "fixtures" + / "action_groups" + / "action-group-tahoma-switch.json" + ).open( + encoding="utf-8", + ) as ag_mock: + ag_resp = MockResponse(ag_mock.read()) + + responses = iter([setup_resp, ag_resp]) + + with ( + patch.object( + aiohttp.ClientSession, + "get", + side_effect=lambda *a, **kw: next(responses), + ), patch( "pyoverkiz.client.obfuscate_sensitive_data", - return_value={"masked": True}, + side_effect=[{"masked": True}, [{"masked": True}]], ) as obfuscate, ): diagnostics = await client.get_diagnostic_data() - assert diagnostics == {"masked": True} - obfuscate.assert_called_once() + assert diagnostics == { + "setup": {"masked": True}, + "action_groups": [{"masked": True}], + } + assert obfuscate.call_count == 2 @pytest.mark.asyncio async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): @@ -335,16 +370,67 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): encoding="utf-8", ) as setup_mock: raw_setup = setup_mock.read() - resp = MockResponse(raw_setup) + setup_resp = MockResponse(raw_setup) with ( - patch.object(aiohttp.ClientSession, "get", return_value=resp), + CURRENT_DIR + / "fixtures" + / "action_groups" + / "action-group-tahoma-switch.json" + ).open( + encoding="utf-8", + ) as ag_mock: + raw_ag = ag_mock.read() + ag_resp = MockResponse(raw_ag) + + responses = iter([setup_resp, ag_resp]) + + with ( + patch.object( + aiohttp.ClientSession, + "get", + side_effect=lambda *a, **kw: next(responses), + ), patch("pyoverkiz.client.obfuscate_sensitive_data") as obfuscate, ): diagnostics = await client.get_diagnostic_data(mask_sensitive_data=False) - assert diagnostics == json.loads(raw_setup) + assert diagnostics == { + "setup": json.loads(raw_setup), + "action_groups": json.loads(raw_ag), + } obfuscate.assert_not_called() + @pytest.mark.asyncio + async def test_get_diagnostic_data_returns_structured_dict( + self, client: OverkizClient + ): + """Verify diagnostic data returns a dict with setup and action_groups sections.""" + with (CURRENT_DIR / "fixtures" / "setup" / "setup_tahoma_1.json").open( + encoding="utf-8", + ) as setup_mock: + setup_resp = MockResponse(setup_mock.read()) + + with ( + CURRENT_DIR + / "fixtures" + / "action_groups" + / "action-group-tahoma-switch.json" + ).open( + encoding="utf-8", + ) as ag_mock: + ag_resp = MockResponse(ag_mock.read()) + + responses = iter([setup_resp, ag_resp]) + + with patch.object( + aiohttp.ClientSession, "get", side_effect=lambda *a, **kw: next(responses) + ): + diagnostics = await client.get_diagnostic_data(mask_sensitive_data=False) + + assert "setup" in diagnostics + assert "action_groups" in diagnostics + assert isinstance(diagnostics["action_groups"], list) + @pytest.mark.parametrize( ("fixture_name", "exception", "status_code"), [ diff --git a/tests/test_obfuscate.py b/tests/test_obfuscate.py index 193641a9..d55ca231 100644 --- a/tests/test_obfuscate.py +++ b/tests/test_obfuscate.py @@ -57,3 +57,17 @@ def test_obfuscate_list_with_none(self): ] } assert obfuscate_sensitive_data(data) == data + + def test_obfuscate_list_of_dicts(self): + """Ensure obfuscate_sensitive_data handles a list of dicts.""" + data = [ + {"label": "My Scene", "oid": "abc-123"}, + {"label": "Night Mode", "deviceURL": "io://1234-5678-1234/12345678"}, + ] + result = obfuscate_sensitive_data(data) + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["label"] != "My Scene" + assert result[0]["oid"] == "abc-123" # oid is not a sensitive key + assert result[1]["label"] != "Night Mode" + assert result[1]["deviceURL"] != "io://1234-5678-1234/12345678"