mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
Three things found by auditing a TapPlan export, plus one regression found on the way. load_ga_export(path): new tool. Reads an ETS ga-export/01 XML (ETS "Export Group Addresses", or the import file a planning tool writes) into a project without devices, through safe_fromstring, capped at 50 MB and 8 levels of range nesting. Invalid or duplicate addresses and unknown DPT tokens go to import_warnings, never silently. Round trip with our own generate_ets_xml is covered by a test. Entity naming: lights, covers and climates are named after the common word prefix of their member names, cutting only function words. The first version of the rule turned "01. <room> - All Blinds - Move" into "01" on a real project, so anything not in the function vocabulary now stays. On six real projects 214 of 1189 entities got a shorter name, each rename reviewed, no address mapping changed. Regenerated packages show different names; noted in the changelog. Setpoint shift: 9.002 / 6.010 with a shift word maps to setpoint_shift_address, setpoint_shift_state_address and setpoint_shift_mode (keys checked against the HA KNX climate docs). It used to become a plain sensor. Fixed: suggest_repairs raised UnboundLocalError on any project with a missing status GA. My local rename in the 12.09 typing cleanup left two references to the old name. test_council_fixes covers it but was not in CI, so it shipped. CI now runs all 21 test files instead of a hand-picked 10; the 11 added ones pass from a clean clone. Verified: all 21 tests, ruff, mypy with the package installed, corpus guard no drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.4 KiB
Python
171 lines
6.4 KiB
Python
"""Read an ETS group-address export (ga-export/01 XML) as a project without devices.
|
|
|
|
A full ``.knxproj`` is not always what people have. ETS can export just the group
|
|
addresses, and planning tools (TapPlan and others) produce the same format for
|
|
import into ETS. That file carries names, addresses, DPTs, descriptions, the
|
|
security flag and the range tree, which is everything the GA-level checks and the
|
|
Home Assistant / ETS generators work on.
|
|
|
|
The export is turned into the same raw shape xknxproject produces for the group
|
|
address part, then goes through the normal ``build_loaded_from_raw``. Devices,
|
|
communication objects, ETS Functions and topology are simply empty, so the
|
|
device-level tools have nothing to report rather than guessing.
|
|
|
|
The file is untrusted input: size-capped and parsed through ``safe_fromstring``
|
|
(no DTD, no entities, no external fetches).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Optional, cast
|
|
|
|
from .project import LoadedProject, build_loaded_from_raw
|
|
from .safexml import SafeXmlError, safe_fromstring
|
|
|
|
MAX_EXPORT_BYTES = 50 * 1024 * 1024 # a 15 000-GA export is a few MB; this only bounds abuse
|
|
MAX_RANGE_DEPTH = 8 # ETS nests main/middle (2); planning tools rarely go deeper
|
|
|
|
_DPT_RE = re.compile(r"^DPS?T-(\d+)(?:-(\d+))?$", re.IGNORECASE)
|
|
|
|
|
|
class GaExportError(ValueError):
|
|
"""The file is not a readable ETS group-address export."""
|
|
|
|
|
|
def _local(tag: str) -> str:
|
|
return tag.rsplit("}", 1)[-1]
|
|
|
|
|
|
def parse_dpt(value: Optional[str]) -> Optional[dict[str, Optional[int]]]:
|
|
"""'DPST-1-1' -> {main 1, sub 1}; 'DPT-9' -> {main 9, sub None}; several -> the first."""
|
|
if not value:
|
|
return None
|
|
first = re.split(r"[\s,;]+", value.strip())[0]
|
|
m = _DPT_RE.match(first)
|
|
if not m:
|
|
return None
|
|
return {"main": int(m.group(1)), "sub": int(m.group(2)) if m.group(2) else None}
|
|
|
|
|
|
def _raw_address(address: str) -> Optional[int]:
|
|
try:
|
|
parts = [int(x) for x in address.split("/")]
|
|
except ValueError:
|
|
return None
|
|
if len(parts) == 3 and parts[0] <= 31 and parts[1] <= 7 and parts[2] <= 255:
|
|
return (parts[0] << 11) | (parts[1] << 8) | parts[2]
|
|
if len(parts) == 2 and parts[0] <= 31 and parts[1] <= 2047:
|
|
return (parts[0] << 11) | parts[1]
|
|
if len(parts) == 1 and 0 <= parts[0] <= 65535:
|
|
return parts[0]
|
|
return None
|
|
|
|
|
|
def _style(addresses: list[str]) -> str:
|
|
depths = {a.count("/") for a in addresses}
|
|
if depths == {2}:
|
|
return "ThreeLevel"
|
|
if depths == {1}:
|
|
return "TwoLevel"
|
|
if depths == {0}:
|
|
return "Free"
|
|
return "Mixed" if depths else ""
|
|
|
|
|
|
def read_ga_export_bytes(data: bytes, name: str = "ga-export") -> dict[str, Any]:
|
|
"""Parse export bytes into the raw project dict (no devices). Raises GaExportError."""
|
|
try:
|
|
root = safe_fromstring(data)
|
|
except SafeXmlError as e:
|
|
raise GaExportError(str(e)) from e
|
|
if _local(root.tag) != "GroupAddress-Export":
|
|
raise GaExportError(f"not an ETS group-address export (root element is <{_local(root.tag)}>)")
|
|
|
|
gas: dict[str, dict[str, Any]] = {}
|
|
warnings: list[str] = []
|
|
|
|
def walk_range(el, depth: int = 1) -> dict[str, Any]:
|
|
if depth > MAX_RANGE_DEPTH:
|
|
raise GaExportError(f"group ranges nested deeper than {MAX_RANGE_DEPTH} levels — refused")
|
|
start = el.get("RangeStart")
|
|
end = el.get("RangeEnd")
|
|
rng: dict[str, Any] = {
|
|
"name": el.get("Name", ""),
|
|
"address_start": int(start) if start and start.isdigit() else None,
|
|
"address_end": int(end) if end and end.isdigit() else None,
|
|
"comment": el.get("Description", ""),
|
|
"group_addresses": [],
|
|
"group_ranges": {},
|
|
}
|
|
for child in el:
|
|
tag = _local(child.tag)
|
|
if tag == "GroupRange":
|
|
sub = walk_range(child, depth + 1)
|
|
rng["group_ranges"][f"{sub['name']}@{sub['address_start']}"] = sub
|
|
elif tag == "GroupAddress":
|
|
addr = add_ga(child)
|
|
if addr:
|
|
rng["group_addresses"].append(addr)
|
|
return rng
|
|
|
|
def add_ga(el) -> Optional[str]:
|
|
address = (el.get("Address") or "").strip()
|
|
raw = _raw_address(address)
|
|
if raw is None:
|
|
warnings.append(f"skipped group address with invalid Address {address!r}")
|
|
return None
|
|
if address in gas:
|
|
warnings.append(f"duplicate address {address} — kept the first entry")
|
|
return None
|
|
dpts = el.get("DPTs")
|
|
dpt = parse_dpt(dpts)
|
|
if dpts and dpt is None:
|
|
warnings.append(f"{address}: DPT {dpts!r} not understood, left unset")
|
|
gas[address] = {
|
|
"name": el.get("Name", ""),
|
|
"identifier": f"GA-{raw}",
|
|
"raw_address": raw,
|
|
"address": address,
|
|
"project_uid": None,
|
|
"dpt": dpt,
|
|
"data_secure": (el.get("Security") or "").strip().lower() == "on",
|
|
"communication_object_ids": [],
|
|
"description": el.get("Description", "") or "",
|
|
"comment": "",
|
|
}
|
|
return address
|
|
|
|
ranges: dict[str, Any] = {}
|
|
for child in root:
|
|
tag = _local(child.tag)
|
|
if tag == "GroupRange":
|
|
rng = walk_range(child)
|
|
ranges[f"{rng['name']}@{rng['address_start']}"] = rng
|
|
elif tag == "GroupAddress":
|
|
add_ga(child)
|
|
|
|
info = {
|
|
"name": name,
|
|
"source": "ga-export",
|
|
"group_address_style": _style(list(gas)),
|
|
"tool_version": None,
|
|
"import_warnings": warnings,
|
|
}
|
|
return {"info": info, "group_addresses": gas, "group_ranges": ranges, "devices": {},
|
|
"communication_objects": {}, "functions": {}, "locations": {}, "topology": {}}
|
|
|
|
|
|
def load_ga_export(path: str) -> LoadedProject:
|
|
"""Load an ETS ga-export/01 XML file as a read-only project without devices."""
|
|
if not os.path.isfile(path):
|
|
raise GaExportError(f"file not found: {path}")
|
|
size = os.path.getsize(path)
|
|
if size > MAX_EXPORT_BYTES:
|
|
raise GaExportError(f"file is {size} bytes, over the {MAX_EXPORT_BYTES}-byte limit")
|
|
with open(path, "rb") as fh:
|
|
data = fh.read(MAX_EXPORT_BYTES + 1)
|
|
raw = read_ga_export_bytes(data, name=Path(path).stem)
|
|
return build_loaded_from_raw(cast(Any, raw), path)
|