From e9070478632aa594c513c68c6202f4b4c70c61fd Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 24 Apr 2026 18:19:34 +0200 Subject: [PATCH 1/9] Add design spec for diagnostics action groups support --- .../specs/2026-04-24-diagnostics-design.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-24-diagnostics-design.md diff --git a/docs/superpowers/specs/2026-04-24-diagnostics-design.md b/docs/superpowers/specs/2026-04-24-diagnostics-design.md new file mode 100644 index 00000000..ffe8866a --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-diagnostics-design.md @@ -0,0 +1,62 @@ +# Diagnostics: Add Action Groups and Structured Return Shape + +## Context + +PR #1476 proposed adding action groups (scenarios) to `get_diagnostic_data()`. The reviewer flagged two issues: action groups weren't being obfuscated despite the method's PII-masking contract, and the return shape change would silently break downstream callers. Since v2 is a breaking release, we can fix both properly. + +## Design + +### Return shape + +`get_diagnostic_data()` returns a dict with named sections: + +```python +{ + "setup": { ... }, # raw JSON from GET /setup + "action_groups": [ ... ] # raw JSON from GET /actionGroups +} +``` + +Keys are snake_case. Each section maps directly to one API endpoint. Adding future sections (execution history, places, setup options) is just adding a key. + +### Method signature + +```python +async def get_diagnostic_data(self, mask_sensitive_data: bool = True) -> dict[str, Any]: +``` + +- `mask_sensitive_data` defaults to `True`, obfuscating each section individually +- Return type narrows from `JSON` (union of dict and list) to `dict[str, Any]` + +### Obfuscation + +`obfuscate_sensitive_data` currently accepts only `dict[str, Any]`. Action groups come back as a JSON array. The function signature is updated to accept `dict[str, Any] | list[dict[str, Any]]`: + +- If given a dict, behaves as before (recursive key-value masking) +- If given a list, iterates and obfuscates each dict element + +This keeps the obfuscation logic centralized rather than scattering list-iteration across callers. + +### Breaking changes + +- Return shape changes from flat setup dict to `{"setup": ..., "action_groups": ...}` +- Downstream callers (e.g. HA integration) accessing `data["gateways"]` must update to `data["setup"]["gateways"]` + +### What stays the same + +- `mask_sensitive_data` parameter with `True` default +- Single method call convenience for getting a complete diagnostic dump +- All existing obfuscation rules (gatewayId, deviceURL, label, city, etc.) + +## Files to modify + +- `pyoverkiz/client.py` - `get_diagnostic_data()`: fetch both endpoints, structure return dict, apply per-section obfuscation +- `pyoverkiz/obfuscate.py` - `obfuscate_sensitive_data()`: accept list in addition to dict +- `tests/test_obfuscate.py` (if exists) or new test - verify list input handling +- `docs/migration-v2.md` - document the return shape change + +## Out of scope + +- Adding execution history, places, or other sections (future work) +- Changes to the HA integration (separate repo, separate PR) +- New obfuscation rules beyond what already exists From ead8bc14797eacaf56750d65f8c207df1dbde6a6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 24 Apr 2026 18:24:19 +0200 Subject: [PATCH 2/9] Add implementation plan for diagnostics action groups --- .../plans/2026-04-24-diagnostics.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-diagnostics.md diff --git a/docs/superpowers/plans/2026-04-24-diagnostics.md b/docs/superpowers/plans/2026-04-24-diagnostics.md new file mode 100644 index 00000000..a412dd1c --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-diagnostics.md @@ -0,0 +1,320 @@ +# Diagnostics: Add Action Groups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand `get_diagnostic_data()` to return a structured dict containing both setup and action group data, with per-section obfuscation. + +**Architecture:** The method fetches two API endpoints (`setup` and `actionGroups`) concurrently, wraps them in a `{"setup": ..., "action_groups": [...]}` dict, and applies `obfuscate_sensitive_data` to each section individually. The obfuscation function is extended to accept lists of dicts in addition to plain dicts. + +**Tech Stack:** Python 3.12+, aiohttp, pytest, pytest-asyncio + +--- + +### Task 1: Extend `obfuscate_sensitive_data` to accept lists + +**Files:** +- Modify: `pyoverkiz/obfuscate.py:27-68` +- Test: `tests/test_obfuscate.py` + +- [ ] **Step 1: Write failing test for list input** + +Add to `tests/test_obfuscate.py`: + +```python +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" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_obfuscate.py::TestObfucscateSensitive::test_obfuscate_list_of_dicts -v` +Expected: FAIL — `TypeError` because `obfuscate_sensitive_data` calls `.items()` on a list. + +- [ ] **Step 3: Implement list support in `obfuscate_sensitive_data`** + +In `pyoverkiz/obfuscate.py`, change the function signature and add a list guard at the top: + +```python +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 [obfuscate_sensitive_data(item) for item in data] + + mask_next_value = False + # ... rest unchanged ... +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_obfuscate.py -v` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pyoverkiz/obfuscate.py tests/test_obfuscate.py +git commit -m "Support list input in obfuscate_sensitive_data" +``` + +--- + +### Task 2: Update `get_diagnostic_data()` to return structured dict + +**Files:** +- Modify: `pyoverkiz/client.py:332-348` +- Test: `tests/test_client.py` + +- [ ] **Step 1: Write failing test for new return shape** + +Add to `tests/test_client.py` in the `TestOverkizClient` class. This test verifies the new return shape contains both `setup` and `action_groups` keys: + +```python +@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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_client.py::TestOverkizClient::test_get_diagnostic_data_returns_structured_dict -v` +Expected: FAIL — current implementation returns flat setup dict without `action_groups` key. + +- [ ] **Step 3: Implement the new `get_diagnostic_data`** + +Replace the method body in `pyoverkiz/client.py`: + +```python +@retry_on_auth_error +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: + -> 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 payloads. + """ + setup = await self._get("setup") + action_groups = await self._get("actionGroups") + + if mask_sensitive_data: + setup = obfuscate_sensitive_data(setup) + action_groups = obfuscate_sensitive_data(action_groups) + + return { + "setup": setup, + "action_groups": action_groups, + } +``` + +Also add `Any` to the `typing` import at the top of `client.py` if not already present. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_client.py::TestOverkizClient::test_get_diagnostic_data_returns_structured_dict -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pyoverkiz/client.py tests/test_client.py +git commit -m "Return structured dict from get_diagnostic_data with action groups" +``` + +--- + +### Task 3: Fix existing diagnostic tests for new return shape + +**Files:** +- Modify: `tests/test_client.py:301-346` + +The three existing diagnostic tests need updating to account for the new return shape (two API calls instead of one, structured dict output). + +- [ ] **Step 1: Update `test_get_diagnostic_data`** + +This parametrized test currently mocks a single `GET` call. It now needs to mock two (setup + actionGroups). Update to: + +```python +@pytest.mark.asyncio +async def test_get_diagnostic_data(self, client: OverkizClient, fixture_name: str): + """Verify that diagnostic data can be fetched and is not empty.""" + with (CURRENT_DIR / "fixtures" / "setup" / fixture_name).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() + assert diagnostics + assert "setup" in diagnostics + assert "action_groups" in diagnostics +``` + +- [ ] **Step 2: Update `test_get_diagnostic_data_redacted_by_default`** + +This test patches `obfuscate_sensitive_data` and needs to account for it being called twice (once for setup, once for action_groups): + +```python +@pytest.mark.asyncio +async def test_get_diagnostic_data_redacted_by_default(self, client: OverkizClient): + """Ensure diagnostics are redacted when no argument is provided.""" + 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)), + patch( + "pyoverkiz.client.obfuscate_sensitive_data", + return_value={"masked": True}, + ) as obfuscate, + ): + diagnostics = await client.get_diagnostic_data() + assert diagnostics == { + "setup": {"masked": True}, + "action_groups": {"masked": True}, + } + assert obfuscate.call_count == 2 +``` + +- [ ] **Step 3: Update `test_get_diagnostic_data_without_masking`** + +```python +@pytest.mark.asyncio +async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): + """Ensure diagnostics can be returned without masking when requested.""" + with (CURRENT_DIR / "fixtures" / "setup" / "setup_tahoma_1.json").open( + encoding="utf-8", + ) as setup_mock: + raw_setup = setup_mock.read() + setup_resp = MockResponse(raw_setup) + + with (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 == { + "setup": json.loads(raw_setup), + "action_groups": json.loads(raw_ag), + } + obfuscate.assert_not_called() +``` + +- [ ] **Step 4: Run all diagnostic tests** + +Run: `pytest tests/test_client.py -k "diagnostic" -v` +Expected: All 20+ parametrized tests PASS. + +- [ ] **Step 5: Run full test suite** + +Run: `pytest tests/ -v` +Expected: All tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/test_client.py +git commit -m "Update diagnostic tests for structured return shape" +``` + +--- + +### Task 4: Document the breaking change in migration guide + +**Files:** +- Modify: `docs/migration-v2.md` + +- [ ] **Step 1: Add diagnostics section to migration guide** + +Add a new section after the "Executing commands" section (around line 100) in `docs/migration-v2.md`: + +```markdown +## 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"] + ``` +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/migration-v2.md +git commit -m "Document get_diagnostic_data breaking change in migration guide" +``` From d243a433aacd4020c6ab7794ff7c076c4522518a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 24 Apr 2026 18:34:55 +0200 Subject: [PATCH 3/9] Support list input in obfuscate_sensitive_data --- pyoverkiz/obfuscate.py | 7 ++++++- tests/test_obfuscate.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/obfuscate.py b/pyoverkiz/obfuscate.py index ca26abd6..5d83b295 100644 --- a/pyoverkiz/obfuscate.py +++ b/pyoverkiz/obfuscate.py @@ -24,8 +24,13 @@ 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 [obfuscate_sensitive_data(item) for item in data] + mask_next_value = False for key, value in data.items(): 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" From 747522d05546077df3a8d5377a980efe858b97ff Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 24 Apr 2026 18:37:39 +0200 Subject: [PATCH 4/9] Remove unused JSON import from obfuscate module --- pyoverkiz/obfuscate.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyoverkiz/obfuscate.py b/pyoverkiz/obfuscate.py index 5d83b295..68ed8d36 100644 --- a/pyoverkiz/obfuscate.py +++ b/pyoverkiz/obfuscate.py @@ -5,8 +5,6 @@ import re from typing import Any -from pyoverkiz.types import JSON - def obfuscate_id(id: str | None) -> str: """Mask id.""" From 2b5fc7823906696d45b971de1db36c6f6929ccc6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 24 Apr 2026 16:41:07 +0000 Subject: [PATCH 5/9] Return structured dict from get_diagnostic_data with action groups - Update get_diagnostic_data() to fetch both /setup and /actionGroups endpoints - Return structured dict with "setup" and "action_groups" keys - Apply obfuscate_sensitive_data to both payloads when mask_sensitive_data=True - Add test_get_diagnostic_data_returns_structured_dict to verify new structure - Update existing diagnostic tests to handle new return shape with dual API calls --- pyoverkiz/client.py | 20 ++++++++----- tests/test_client.py | 70 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 02eb6eb8..adafb21d 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -329,23 +329,29 @@ 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 = await self._get("setup") + action_groups = await 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/tests/test_client.py b/tests/test_client.py index 0d525fda..38636541 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -303,11 +303,20 @@ 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 +324,28 @@ 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 (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", return_value=resp), + patch.object(aiohttp.ClientSession, "get", side_effect=lambda *a, **kw: next(responses)), patch( "pyoverkiz.client.obfuscate_sensitive_data", return_value={"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 +354,49 @@ 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 (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", return_value=resp), + 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"), [ From 7cf2e5cac0fe67b4b7f3476f3d4c5c2a08ef2275 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 24 Apr 2026 18:45:38 +0200 Subject: [PATCH 6/9] Document get_diagnostic_data breaking change in migration guide --- docs/migration-v2.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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 | From 9040cf6db01f25f1075a5e10fd837c31c6da02d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:38:51 +0000 Subject: [PATCH 7/9] Fix linting, type errors, use asyncio.gather, and improve test assertions Agent-Logs-Url: https://github.com/iMicknl/python-overkiz-api/sessions/780a5262-1b4d-44e0-86b6-7720038d3cb7 Co-authored-by: iMicknl <1424596+iMicknl@users.noreply.github.com> --- .../plans/2026-04-24-diagnostics.md | 2 +- pyoverkiz/client.py | 11 +++- pyoverkiz/obfuscate.py | 6 +- tests/test_client.py | 57 +++++++++++++++---- 4 files changed, 59 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-04-24-diagnostics.md b/docs/superpowers/plans/2026-04-24-diagnostics.md index a412dd1c..bc9b79ff 100644 --- a/docs/superpowers/plans/2026-04-24-diagnostics.md +++ b/docs/superpowers/plans/2026-04-24-diagnostics.md @@ -4,7 +4,7 @@ **Goal:** Expand `get_diagnostic_data()` to return a structured dict containing both setup and action group data, with per-section obfuscation. -**Architecture:** The method fetches two API endpoints (`setup` and `actionGroups`) concurrently, wraps them in a `{"setup": ..., "action_groups": [...]}` dict, and applies `obfuscate_sensitive_data` to each section individually. The obfuscation function is extended to accept lists of dicts in addition to plain dicts. +**Architecture:** The method fetches two API endpoints (`setup` and `actionGroups`) concurrently using `asyncio.gather`, wraps them in a `{"setup": ..., "action_groups": [...]}` dict, and applies `obfuscate_sensitive_data` to each section individually. The obfuscation function is extended to accept lists of dicts in addition to plain dicts. **Tech Stack:** Python 3.12+, aiohttp, pytest, pytest-asyncio diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index adafb21d..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,7 +330,9 @@ 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) -> dict[str, Any]: + 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, ...): @@ -341,8 +344,10 @@ async def get_diagnostic_data(self, mask_sensitive_data: bool = True) -> dict[st By default, this data is masked to not return confidential or PII data. Set `mask_sensitive_data` to `False` to return the raw payloads. """ - setup = await self._get("setup") - action_groups = await self._get("actionGroups") + setup, action_groups = await asyncio.gather( + self._get("setup"), + self._get("actionGroups"), + ) if mask_sensitive_data: setup = obfuscate_sensitive_data(setup) diff --git a/pyoverkiz/obfuscate.py b/pyoverkiz/obfuscate.py index 68ed8d36..53b3506d 100644 --- a/pyoverkiz/obfuscate.py +++ b/pyoverkiz/obfuscate.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from typing import Any +from typing import Any, cast def obfuscate_id(id: str | None) -> str: @@ -27,7 +27,9 @@ def obfuscate_sensitive_data( ) -> dict[str, Any] | list[dict[str, Any]]: """Mask Overkiz JSON data to remove sensitive data.""" if isinstance(data, list): - return [obfuscate_sensitive_data(item) for item in data] + return cast( + list[dict[str, Any]], [obfuscate_sensitive_data(item) for item in data] + ) mask_next_value = False diff --git a/tests/test_client.py b/tests/test_client.py index 38636541..5a4b0a2b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -305,14 +305,21 @@ async def test_get_diagnostic_data(self, client: OverkizClient, fixture_name: st ) as setup_mock: setup_resp = MockResponse(setup_mock.read()) - with (CURRENT_DIR / "fixtures" / "action_groups" / "action-group-tahoma-switch.json").open( + 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)): + 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 @@ -326,7 +333,12 @@ async def test_get_diagnostic_data_redacted_by_default(self, client: OverkizClie ) as setup_mock: setup_resp = MockResponse(setup_mock.read()) - with (CURRENT_DIR / "fixtures" / "action_groups" / "action-group-tahoma-switch.json").open( + with ( + CURRENT_DIR + / "fixtures" + / "action_groups" + / "action-group-tahoma-switch.json" + ).open( encoding="utf-8", ) as ag_mock: ag_resp = MockResponse(ag_mock.read()) @@ -334,18 +346,23 @@ async def test_get_diagnostic_data_redacted_by_default(self, client: OverkizClie responses = iter([setup_resp, ag_resp]) with ( - patch.object(aiohttp.ClientSession, "get", side_effect=lambda *a, **kw: next(responses)), + 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 == { "setup": {"masked": True}, - "action_groups": {"masked": True}, + "action_groups": [{"masked": True}], } assert obfuscate.call_count == 2 + assert isinstance(diagnostics["action_groups"], list) @pytest.mark.asyncio async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): @@ -356,7 +373,12 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): raw_setup = setup_mock.read() setup_resp = MockResponse(raw_setup) - with (CURRENT_DIR / "fixtures" / "action_groups" / "action-group-tahoma-switch.json").open( + with ( + CURRENT_DIR + / "fixtures" + / "action_groups" + / "action-group-tahoma-switch.json" + ).open( encoding="utf-8", ) as ag_mock: raw_ag = ag_mock.read() @@ -365,7 +387,11 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): responses = iter([setup_resp, ag_resp]) with ( - patch.object(aiohttp.ClientSession, "get", side_effect=lambda *a, **kw: next(responses)), + 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) @@ -376,21 +402,30 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): obfuscate.assert_not_called() @pytest.mark.asyncio - async def test_get_diagnostic_data_returns_structured_dict(self, client: OverkizClient): + 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( + 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)): + 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 From f0be21d9ef044852347116f37fd42400adf517ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:40:10 +0000 Subject: [PATCH 8/9] Remove redundant isinstance assertion in diagnostic test Agent-Logs-Url: https://github.com/iMicknl/python-overkiz-api/sessions/780a5262-1b4d-44e0-86b6-7720038d3cb7 Co-authored-by: iMicknl <1424596+iMicknl@users.noreply.github.com> --- tests/test_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index 5a4b0a2b..f7cece70 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -362,7 +362,6 @@ async def test_get_diagnostic_data_redacted_by_default(self, client: OverkizClie "action_groups": [{"masked": True}], } assert obfuscate.call_count == 2 - assert isinstance(diagnostics["action_groups"], list) @pytest.mark.asyncio async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): From 160bf66346cbdeaa187a71da96aed3cda4b77188 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 25 Apr 2026 10:23:36 +0200 Subject: [PATCH 9/9] Remove internal planning docs from repository --- .../plans/2026-04-24-diagnostics.md | 320 ------------------ .../specs/2026-04-24-diagnostics-design.md | 62 ---- 2 files changed, 382 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-24-diagnostics.md delete mode 100644 docs/superpowers/specs/2026-04-24-diagnostics-design.md diff --git a/docs/superpowers/plans/2026-04-24-diagnostics.md b/docs/superpowers/plans/2026-04-24-diagnostics.md deleted file mode 100644 index bc9b79ff..00000000 --- a/docs/superpowers/plans/2026-04-24-diagnostics.md +++ /dev/null @@ -1,320 +0,0 @@ -# Diagnostics: Add Action Groups Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Expand `get_diagnostic_data()` to return a structured dict containing both setup and action group data, with per-section obfuscation. - -**Architecture:** The method fetches two API endpoints (`setup` and `actionGroups`) concurrently using `asyncio.gather`, wraps them in a `{"setup": ..., "action_groups": [...]}` dict, and applies `obfuscate_sensitive_data` to each section individually. The obfuscation function is extended to accept lists of dicts in addition to plain dicts. - -**Tech Stack:** Python 3.12+, aiohttp, pytest, pytest-asyncio - ---- - -### Task 1: Extend `obfuscate_sensitive_data` to accept lists - -**Files:** -- Modify: `pyoverkiz/obfuscate.py:27-68` -- Test: `tests/test_obfuscate.py` - -- [ ] **Step 1: Write failing test for list input** - -Add to `tests/test_obfuscate.py`: - -```python -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" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pytest tests/test_obfuscate.py::TestObfucscateSensitive::test_obfuscate_list_of_dicts -v` -Expected: FAIL — `TypeError` because `obfuscate_sensitive_data` calls `.items()` on a list. - -- [ ] **Step 3: Implement list support in `obfuscate_sensitive_data`** - -In `pyoverkiz/obfuscate.py`, change the function signature and add a list guard at the top: - -```python -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 [obfuscate_sensitive_data(item) for item in data] - - mask_next_value = False - # ... rest unchanged ... -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pytest tests/test_obfuscate.py -v` -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add pyoverkiz/obfuscate.py tests/test_obfuscate.py -git commit -m "Support list input in obfuscate_sensitive_data" -``` - ---- - -### Task 2: Update `get_diagnostic_data()` to return structured dict - -**Files:** -- Modify: `pyoverkiz/client.py:332-348` -- Test: `tests/test_client.py` - -- [ ] **Step 1: Write failing test for new return shape** - -Add to `tests/test_client.py` in the `TestOverkizClient` class. This test verifies the new return shape contains both `setup` and `action_groups` keys: - -```python -@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) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pytest tests/test_client.py::TestOverkizClient::test_get_diagnostic_data_returns_structured_dict -v` -Expected: FAIL — current implementation returns flat setup dict without `action_groups` key. - -- [ ] **Step 3: Implement the new `get_diagnostic_data`** - -Replace the method body in `pyoverkiz/client.py`: - -```python -@retry_on_auth_error -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: - -> 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 payloads. - """ - setup = await self._get("setup") - action_groups = await self._get("actionGroups") - - if mask_sensitive_data: - setup = obfuscate_sensitive_data(setup) - action_groups = obfuscate_sensitive_data(action_groups) - - return { - "setup": setup, - "action_groups": action_groups, - } -``` - -Also add `Any` to the `typing` import at the top of `client.py` if not already present. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pytest tests/test_client.py::TestOverkizClient::test_get_diagnostic_data_returns_structured_dict -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add pyoverkiz/client.py tests/test_client.py -git commit -m "Return structured dict from get_diagnostic_data with action groups" -``` - ---- - -### Task 3: Fix existing diagnostic tests for new return shape - -**Files:** -- Modify: `tests/test_client.py:301-346` - -The three existing diagnostic tests need updating to account for the new return shape (two API calls instead of one, structured dict output). - -- [ ] **Step 1: Update `test_get_diagnostic_data`** - -This parametrized test currently mocks a single `GET` call. It now needs to mock two (setup + actionGroups). Update to: - -```python -@pytest.mark.asyncio -async def test_get_diagnostic_data(self, client: OverkizClient, fixture_name: str): - """Verify that diagnostic data can be fetched and is not empty.""" - with (CURRENT_DIR / "fixtures" / "setup" / fixture_name).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() - assert diagnostics - assert "setup" in diagnostics - assert "action_groups" in diagnostics -``` - -- [ ] **Step 2: Update `test_get_diagnostic_data_redacted_by_default`** - -This test patches `obfuscate_sensitive_data` and needs to account for it being called twice (once for setup, once for action_groups): - -```python -@pytest.mark.asyncio -async def test_get_diagnostic_data_redacted_by_default(self, client: OverkizClient): - """Ensure diagnostics are redacted when no argument is provided.""" - 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)), - patch( - "pyoverkiz.client.obfuscate_sensitive_data", - return_value={"masked": True}, - ) as obfuscate, - ): - diagnostics = await client.get_diagnostic_data() - assert diagnostics == { - "setup": {"masked": True}, - "action_groups": {"masked": True}, - } - assert obfuscate.call_count == 2 -``` - -- [ ] **Step 3: Update `test_get_diagnostic_data_without_masking`** - -```python -@pytest.mark.asyncio -async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): - """Ensure diagnostics can be returned without masking when requested.""" - with (CURRENT_DIR / "fixtures" / "setup" / "setup_tahoma_1.json").open( - encoding="utf-8", - ) as setup_mock: - raw_setup = setup_mock.read() - setup_resp = MockResponse(raw_setup) - - with (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 == { - "setup": json.loads(raw_setup), - "action_groups": json.loads(raw_ag), - } - obfuscate.assert_not_called() -``` - -- [ ] **Step 4: Run all diagnostic tests** - -Run: `pytest tests/test_client.py -k "diagnostic" -v` -Expected: All 20+ parametrized tests PASS. - -- [ ] **Step 5: Run full test suite** - -Run: `pytest tests/ -v` -Expected: All tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add tests/test_client.py -git commit -m "Update diagnostic tests for structured return shape" -``` - ---- - -### Task 4: Document the breaking change in migration guide - -**Files:** -- Modify: `docs/migration-v2.md` - -- [ ] **Step 1: Add diagnostics section to migration guide** - -Add a new section after the "Executing commands" section (around line 100) in `docs/migration-v2.md`: - -```markdown -## 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"] - ``` -``` - -- [ ] **Step 2: Commit** - -```bash -git add docs/migration-v2.md -git commit -m "Document get_diagnostic_data breaking change in migration guide" -``` diff --git a/docs/superpowers/specs/2026-04-24-diagnostics-design.md b/docs/superpowers/specs/2026-04-24-diagnostics-design.md deleted file mode 100644 index ffe8866a..00000000 --- a/docs/superpowers/specs/2026-04-24-diagnostics-design.md +++ /dev/null @@ -1,62 +0,0 @@ -# Diagnostics: Add Action Groups and Structured Return Shape - -## Context - -PR #1476 proposed adding action groups (scenarios) to `get_diagnostic_data()`. The reviewer flagged two issues: action groups weren't being obfuscated despite the method's PII-masking contract, and the return shape change would silently break downstream callers. Since v2 is a breaking release, we can fix both properly. - -## Design - -### Return shape - -`get_diagnostic_data()` returns a dict with named sections: - -```python -{ - "setup": { ... }, # raw JSON from GET /setup - "action_groups": [ ... ] # raw JSON from GET /actionGroups -} -``` - -Keys are snake_case. Each section maps directly to one API endpoint. Adding future sections (execution history, places, setup options) is just adding a key. - -### Method signature - -```python -async def get_diagnostic_data(self, mask_sensitive_data: bool = True) -> dict[str, Any]: -``` - -- `mask_sensitive_data` defaults to `True`, obfuscating each section individually -- Return type narrows from `JSON` (union of dict and list) to `dict[str, Any]` - -### Obfuscation - -`obfuscate_sensitive_data` currently accepts only `dict[str, Any]`. Action groups come back as a JSON array. The function signature is updated to accept `dict[str, Any] | list[dict[str, Any]]`: - -- If given a dict, behaves as before (recursive key-value masking) -- If given a list, iterates and obfuscates each dict element - -This keeps the obfuscation logic centralized rather than scattering list-iteration across callers. - -### Breaking changes - -- Return shape changes from flat setup dict to `{"setup": ..., "action_groups": ...}` -- Downstream callers (e.g. HA integration) accessing `data["gateways"]` must update to `data["setup"]["gateways"]` - -### What stays the same - -- `mask_sensitive_data` parameter with `True` default -- Single method call convenience for getting a complete diagnostic dump -- All existing obfuscation rules (gatewayId, deviceURL, label, city, etc.) - -## Files to modify - -- `pyoverkiz/client.py` - `get_diagnostic_data()`: fetch both endpoints, structure return dict, apply per-section obfuscation -- `pyoverkiz/obfuscate.py` - `obfuscate_sensitive_data()`: accept list in addition to dict -- `tests/test_obfuscate.py` (if exists) or new test - verify list input handling -- `docs/migration-v2.md` - document the return shape change - -## Out of scope - -- Adding execution history, places, or other sections (future work) -- Changes to the HA integration (separate repo, separate PR) -- New obfuscation rules beyond what already exists