Skip to content
24 changes: 24 additions & 0 deletions docs/migration-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
25 changes: 18 additions & 7 deletions pyoverkiz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import logging
import ssl
import urllib.parse
Expand Down Expand Up @@ -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, ...): <gateways/gateway>
-> setup location: <location>
-> house places (rooms and floors): <place>
-> setup devices: <devices>.
-> setup devices: <devices>
-> action groups: <actionGroups>

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]:
Expand Down
13 changes: 9 additions & 4 deletions pyoverkiz/obfuscate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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():
Expand Down
106 changes: 96 additions & 10 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,30 +303,65 @@ 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):
"""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:
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
Comment thread
iMicknl marked this conversation as resolved.

@pytest.mark.asyncio
async def test_get_diagnostic_data_without_masking(self, client: OverkizClient):
Expand All @@ -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"),
[
Expand Down
14 changes: 14 additions & 0 deletions tests/test_obfuscate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading