Skip to content

Commit 8c0c39d

Browse files
authored
feat: add include_knx_project to ha_get_integration for KNX group addresses
Adds an opt-in `include_knx_project` flag to `ha_get_integration` that attaches the parsed ETS project (group-address table, group-range hierarchy, project metadata) for a KNX config entry, read via the `knx/get_knx_project` WebSocket command. Per-entity GA assignments remain available via `include_diagnostics`.
1 parent caee1d5 commit 8c0c39d

4 files changed

Lines changed: 322 additions & 1 deletion

File tree

site/src/data/tools.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1913,7 +1913,7 @@
19131913
{
19141914
"name": "ha_get_integration",
19151915
"title": "Get Integration",
1916-
"description": "Get integration (config entry) information with pagination.\n\nWithout an entry_id: Lists all configured integrations with optional filters.\nWith an entry_id: Returns detailed information including full options/configuration.\n\nEXAMPLES:\n- List all integrations: ha_get_integration()\n- Paginate: ha_get_integration(offset=50)\n- Search: ha_get_integration(query=\"zigbee\")\n- Get specific entry: ha_get_integration(entry_id=\"abc123\")\n- Get entry with editable fields: ha_get_integration(entry_id=\"abc123\", include_schema=True)\n- Get entry with diagnostics dump: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True)\n- Get device-scoped diagnostics: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True, device_id=\"dev123\")\n- Walk a sub-tree: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True, diagnostics_data_path=\"<dotted-path>\")\n- Paginate a large list: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True, diagnostics_data_path=\"<list-valued path>\", diagnostics_data_limit=10, diagnostics_data_offset=20)\n- List config subentries: ha_get_integration(entry_id=\"abc123\", include_subentries=True)\n- Inspect subentry create schema: ha_get_integration(entry_id=\"abc123\", include_subentry_schema=True, subentry_type=\"conversation\")\n- Inspect subentry reconfigure schema: ha_get_integration(entry_id=\"abc123\", include_subentry_schema=True, subentry_type=\"conversation\", subentry_id=\"sub123\")\n- List template entries: ha_get_integration(domain=\"template\")\n\nSTATES: 'loaded', 'setup_error', 'setup_retry', 'not_loaded',\n'failed_unload', 'migration_error'.\n\nEach entry carries:\n\n- ``log_level``: the canonical Python logger level name\n (``DEBUG``/``INFO``/``WARNING``/``ERROR``/``CRITICAL``) when the\n integration has a ``logger.set_level`` override, or ``\"DEFAULT\"``\n (uppercase sentinel) when no override is set.\n- ``log_level_raw``: the original numeric level (e.g. ``10`` for DEBUG)\n when HA returned an int, ``None`` otherwise (no override set, or HA\n provided a level name as a string).\n\nThis is distinct from the add-on side, where ``ha_get_addon`` returns\nSupervisor's lowercase ``\"default\"`` literal — do not cross-compare.",
1916+
"description": "Get integration (config entry) information with pagination.\n\nWithout an entry_id: Lists all configured integrations with optional filters.\nWith an entry_id: Returns detailed information including full options/configuration.\n\nEXAMPLES:\n- List all integrations: ha_get_integration()\n- Paginate: ha_get_integration(offset=50)\n- Search: ha_get_integration(query=\"zigbee\")\n- Get specific entry: ha_get_integration(entry_id=\"abc123\")\n- Get entry with editable fields: ha_get_integration(entry_id=\"abc123\", include_schema=True)\n- Get entry with diagnostics dump: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True)\n- Get device-scoped diagnostics: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True, device_id=\"dev123\")\n- Get the parsed KNX ETS project (group-address table): ha_get_integration(entry_id=\"<knx entry>\", include_knx_project=True)\n- Walk a sub-tree: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True, diagnostics_data_path=\"<dotted-path>\")\n- Paginate a large list: ha_get_integration(entry_id=\"abc123\", include_diagnostics=True, diagnostics_data_path=\"<list-valued path>\", diagnostics_data_limit=10, diagnostics_data_offset=20)\n- List config subentries: ha_get_integration(entry_id=\"abc123\", include_subentries=True)\n- Inspect subentry create schema: ha_get_integration(entry_id=\"abc123\", include_subentry_schema=True, subentry_type=\"conversation\")\n- Inspect subentry reconfigure schema: ha_get_integration(entry_id=\"abc123\", include_subentry_schema=True, subentry_type=\"conversation\", subentry_id=\"sub123\")\n- List template entries: ha_get_integration(domain=\"template\")\n\nSTATES: 'loaded', 'setup_error', 'setup_retry', 'not_loaded',\n'failed_unload', 'migration_error'.\n\nEach entry carries:\n\n- ``log_level``: the canonical Python logger level name\n (``DEBUG``/``INFO``/``WARNING``/``ERROR``/``CRITICAL``) when the\n integration has a ``logger.set_level`` override, or ``\"DEFAULT\"``\n (uppercase sentinel) when no override is set.\n- ``log_level_raw``: the original numeric level (e.g. ``10`` for DEBUG)\n when HA returned an int, ``None`` otherwise (no override set, or HA\n provided a level name as a string).\n\nThis is distinct from the add-on side, where ``ha_get_addon`` returns\nSupervisor's lowercase ``\"default\"`` literal — do not cross-compare.",
19171917
"inputSchema": {
19181918
"properties": {
19191919
"entry_id": {
@@ -1972,6 +1972,10 @@
19721972
"type": "Annotated[bool, Field(description=\"When entry_id is set, also fetch the integration's diagnostics dump — integration-defined JSON (commonly includes redacted config, device list, state snapshots; exact top-level keys vary by integration). The canonical artifact users grab via Settings → Devices & Services → [integration] → ⋯ → Download diagnostics. Use when triaging integration bugs or filing ha_report_issue for a specific integration. Payloads can be large (Hue ~290 KB, ZHA/MQTT/ESPHome several MB) — pair with diagnostics_fields or diagnostics_truncate_at_bytes to fit the LLM context budget.\", default=False)]",
19731973
"default": false
19741974
},
1975+
"include_knx_project": {
1976+
"type": "Annotated[bool, Field(description='When entry_id is a KNX config entry, also return the parsed ETS project: the full group-address table (address, name, DPT, description) under knx_project.group_addresses, plus the group-range hierarchy and project metadata. This is the parsed-project GA table that is NOT in the diagnostics dump; per-entity GA assignments are already covered by include_diagnostics (config_store / configuration_yaml). Ignored (with a warning) when the entry is not a KNX integration. The KNX integration exposes a single project, so the result is the same regardless of which KNX entry_id is used.', default=False)]",
1977+
"default": false
1978+
},
19751979
"device_id": {
19761980
"type": "Annotated[str | None, Field(description='Optional. When set with include_diagnostics=True, returns the device-scoped diagnostics dump for that specific device under the integration (rather than the full integration dump). Some integrations only expose config-entry-level dumps; others expose both.', default=None)]",
19771981
"default": null

src/ha_mcp/tools/tools_integrations.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,25 @@ async def ha_get_integration(
445445
default=False,
446446
),
447447
] = False,
448+
include_knx_project: Annotated[
449+
bool,
450+
Field(
451+
description=(
452+
"When entry_id is a KNX config entry, also return the parsed "
453+
"ETS project: the full group-address table (address, name, "
454+
"DPT, description) under knx_project.group_addresses, plus the "
455+
"group-range hierarchy and project metadata. This is the "
456+
"parsed-project GA table that is NOT in the diagnostics dump; "
457+
"per-entity GA assignments are already covered by "
458+
"include_diagnostics (config_store / configuration_yaml). "
459+
"Ignored (with a warning) when the entry is not a KNX "
460+
"integration. The KNX integration exposes a single project, "
461+
"so the result is the same regardless of which KNX entry_id "
462+
"is used."
463+
),
464+
default=False,
465+
),
466+
] = False,
448467
device_id: Annotated[
449468
str | None,
450469
Field(
@@ -553,6 +572,7 @@ async def ha_get_integration(
553572
- Get entry with editable fields: ha_get_integration(entry_id="abc123", include_schema=True)
554573
- Get entry with diagnostics dump: ha_get_integration(entry_id="abc123", include_diagnostics=True)
555574
- Get device-scoped diagnostics: ha_get_integration(entry_id="abc123", include_diagnostics=True, device_id="dev123")
575+
- Get the parsed KNX ETS project (group-address table): ha_get_integration(entry_id="<knx entry>", include_knx_project=True)
556576
- Walk a sub-tree: ha_get_integration(entry_id="abc123", include_diagnostics=True, diagnostics_data_path="<dotted-path>")
557577
- Paginate a large list: ha_get_integration(entry_id="abc123", include_diagnostics=True, diagnostics_data_path="<list-valued path>", diagnostics_data_limit=10, diagnostics_data_offset=20)
558578
- List config subentries: ha_get_integration(entry_id="abc123", include_subentries=True)
@@ -580,6 +600,7 @@ async def ha_get_integration(
580600
include_opts = include_options
581601
include_schema_bool = include_schema
582602
include_diagnostics_bool = include_diagnostics
603+
include_knx_project_bool = include_knx_project
583604
include_subentries_bool = include_subentries
584605
include_subentry_schema_bool = include_subentry_schema
585606
show_advanced_options_bool = show_advanced_options
@@ -639,6 +660,8 @@ async def ha_get_integration(
639660
"device_id was provided but ignored because "
640661
"include_diagnostics=False"
641662
)
663+
if include_knx_project_bool:
664+
await self._attach_knx_project(resp, entry_id)
642665
return resp
643666

644667
# List mode - get all config entries
@@ -648,6 +671,8 @@ async def ha_get_integration(
648671
ignored_detail_params = []
649672
if include_diagnostics_bool:
650673
ignored_detail_params.append("include_diagnostics")
674+
if include_knx_project_bool:
675+
ignored_detail_params.append("include_knx_project")
651676
if device_id is not None:
652677
ignored_detail_params.append("device_id")
653678
if fields_list is not None:
@@ -777,6 +802,69 @@ async def _get_single_entry(
777802
)
778803
return None # unreachable: exception_to_structured_error raises
779804

805+
async def _attach_knx_project(self, resp: dict[str, Any], entry_id: str) -> None:
806+
"""Attach the parsed KNX ETS project to a single-entry response.
807+
808+
Reads ``knx/get_knx_project`` (the same command the KNX panel uses) and
809+
attaches the group-address table, group-range hierarchy, and project
810+
metadata under ``resp["knx_project"]``. The KNX integration exposes one
811+
project globally, so the command takes no entry scope; this is gated on
812+
the entry actually being a KNX integration to avoid attaching unrelated
813+
data to a non-KNX entry.
814+
815+
Failures are surfaced as warnings rather than raised — the primary
816+
entry read already succeeded, mirroring how diagnostics fetch failures
817+
are handled. The "KNX integration not loaded" case collapses into
818+
ha_get_integration's existing no-such-entry handling (no KNX entry → no
819+
entry_id to pass here), so it needs no special casing.
820+
"""
821+
entry = resp.get("entry") if isinstance(resp.get("entry"), dict) else {}
822+
domain = entry.get("domain") if isinstance(entry, dict) else None
823+
if domain != "knx":
824+
resp.setdefault("warnings", []).append(
825+
f"include_knx_project ignored: entry {entry_id} is not a KNX "
826+
f"integration (domain={domain!r})"
827+
)
828+
return
829+
830+
result = await self._client.send_websocket_message(
831+
{"type": "knx/get_knx_project"}
832+
)
833+
if not isinstance(result, dict) or not result.get("success"):
834+
error_msg = websocket_error_message(
835+
result.get("error", "Unknown error")
836+
if isinstance(result, dict)
837+
else result
838+
)
839+
resp.setdefault("warnings", []).append(
840+
f"Failed to fetch KNX project: {error_msg}"
841+
)
842+
return
843+
844+
project = result.get("result")
845+
if not project:
846+
# No ETS project uploaded yet — get_knxproject() returns None.
847+
resp["knx_project"] = {
848+
"count": 0,
849+
"group_addresses": {},
850+
"group_ranges": {},
851+
"info": {},
852+
"note": (
853+
"The KNX integration is loaded but no ETS project has been "
854+
"uploaded yet. Upload a .knxproj file via the KNX panel to "
855+
"populate the group-address table."
856+
),
857+
}
858+
return
859+
860+
group_addresses = project.get("group_addresses", {})
861+
resp["knx_project"] = {
862+
"count": len(group_addresses),
863+
"group_addresses": group_addresses,
864+
"group_ranges": project.get("group_ranges", {}),
865+
"info": project.get("info", {}),
866+
}
867+
780868
async def _fetch_config_subentries(self, entry_id: str) -> list[dict[str, Any]]:
781869
"""Fetch config subentries for a detailed entry response."""
782870
result = await self._client.list_config_subentries(entry_id)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
E2E smoke test for the KNX project fold into ha_get_integration.
3+
4+
The test container has no KNX integration configured, so there is no KNX
5+
config entry to pass include_knx_project against. This exercises the
6+
not-loaded path the maintainer asked us to keep: requesting
7+
include_knx_project on a non-KNX entry must degrade cleanly (a warning, no
8+
crash, no KNX data attached) over the real WebSocket/REST plumbing. The
9+
parsed-project success path is covered by the unit tests, which would
10+
otherwise require a live HA with the KNX integration and an uploaded ETS
11+
project.
12+
"""
13+
14+
import logging
15+
16+
import pytest
17+
18+
from ..utilities.assertions import assert_mcp_success
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_include_knx_project_on_non_knx_entry_degrades_cleanly(mcp_client):
25+
"""include_knx_project against a non-KNX entry returns a warning, not data.
26+
27+
Picks the first available config entry (none are KNX in the test
28+
container) and asserts the response succeeds with no knx_project key and a
29+
warning explaining the flag was ignored.
30+
"""
31+
listing = await mcp_client.call_tool("ha_get_integration", {})
32+
raw_list = assert_mcp_success(listing, "list integrations")
33+
entries = raw_list.get("data", raw_list).get("entries", [])
34+
if not entries:
35+
pytest.skip("no config entries available in the test container")
36+
entry_id = entries[0]["entry_id"]
37+
38+
result = await mcp_client.call_tool(
39+
"ha_get_integration",
40+
{"entry_id": entry_id, "include_knx_project": True},
41+
)
42+
raw = assert_mcp_success(result, "get integration with include_knx_project")
43+
data = raw.get("data", raw)
44+
45+
assert "knx_project" not in data
46+
warnings = data.get("warnings", [])
47+
assert any("KNX" in w for w in warnings), (
48+
f"expected an include_knx_project warning, got warnings={warnings}"
49+
)
50+
logger.info("include_knx_project degraded cleanly on a non-KNX entry")

0 commit comments

Comments
 (0)