mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
The lint job failed on its first run with three errors my local run had not shown. Cause: locally mypy had no xknxproject installed, so ignore_missing_imports hid every mismatch against that library's own TypedDicts. CI installs the package and therefore sees them. The two range-walk helpers took dict[str, Any] but are handed a GroupRange TypedDict, which mypy rejects because dict is invariant; they take Mapping now, which is what they actually use. suggest passes the raw parsed dict into build_loaded_from_raw, whose parameter is KNXProject, so the boundary is an explicit cast rather than a widened public signature. CONTRIBUTING now gives the command that matches CI, with the reason, so the next person does not get a clean local run and a red CI. Suite passes, corpus guard reports no drift.
129 lines
4.9 KiB
Python
129 lines
4.9 KiB
Python
"""Generate ETS-importable Group Address exports (CSV + XML).
|
|
|
|
XML uses the official ETS GA-export schema (http://knx.org/xml/ga-export/01),
|
|
which is the most reliable import path. CSV uses the native ETS group-address
|
|
layout (group name + X/Y/Z address with -/- range placeholders).
|
|
|
|
Neither export touches a live bus. The intended flow is: generate -> review the
|
|
human-readable report -> import into ETS via 'Import Group Addresses'.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
from xml.sax.saxutils import escape
|
|
|
|
from .project import LoadedProject
|
|
from .dpt_map import dpt_ets_token
|
|
|
|
|
|
def _hierarchy(project: LoadedProject) -> dict[int, dict[str, Any]]:
|
|
"""Build {main: {name, middles: {middle: {name, gas: [rec]}}}}.
|
|
|
|
Range names come from raw group_ranges when available, otherwise synthesized.
|
|
"""
|
|
# raw range names indexed by (main) and (main, middle)
|
|
main_names: dict[int, str] = {}
|
|
mid_names: dict[tuple[int, int], str] = {}
|
|
|
|
def walk(rng: Mapping[str, Any]) -> None:
|
|
start = rng.get("address_start")
|
|
name = rng.get("name", "")
|
|
if isinstance(start, int):
|
|
main = (start >> 11) & 0x1F
|
|
middle = (start >> 8) & 0x07
|
|
if start % 2048 == 0 and not rng.get("group_addresses"):
|
|
main_names.setdefault(main, name)
|
|
else:
|
|
mid_names.setdefault((main, middle), name)
|
|
for child in rng.get("group_ranges", {}).values():
|
|
walk(child)
|
|
|
|
for rng in project.raw.get("group_ranges", {}).values():
|
|
walk(rng)
|
|
|
|
tree: dict[int, dict[str, Any]] = {}
|
|
for ga in project.gas.values():
|
|
if ga.main is None or ga.middle is None:
|
|
continue
|
|
m = tree.setdefault(ga.main, {
|
|
"name": main_names.get(ga.main) or ga.main_name or f"Main group {ga.main}",
|
|
"middles": {},
|
|
})
|
|
mid = m["middles"].setdefault(ga.middle, {
|
|
"name": mid_names.get((ga.main, ga.middle)) or ga.middle_name
|
|
or f"Middle group {ga.main}/{ga.middle}",
|
|
"gas": [],
|
|
})
|
|
mid["gas"].append(ga)
|
|
return tree
|
|
|
|
|
|
def _security(ga) -> str:
|
|
return "On" if ga.data_secure else "Auto"
|
|
|
|
|
|
def generate_ets_csv(project: LoadedProject) -> str:
|
|
"""Native ETS GA CSV (semicolon-separated, with range placeholder rows)."""
|
|
buf = io.StringIO()
|
|
w = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_ALL, lineterminator="\n")
|
|
w.writerow(["Group name", "Address", "Central", "Unfiltered",
|
|
"Description", "DatapointType", "Security"])
|
|
tree = _hierarchy(project)
|
|
for main in sorted(tree):
|
|
m = tree[main]
|
|
w.writerow([m["name"], f"{main}/-/-", "", "", "", "", ""])
|
|
for middle in sorted(m["middles"]):
|
|
mid = m["middles"][middle]
|
|
w.writerow([mid["name"], f"{main}/{middle}/-", "", "", "", "", ""])
|
|
for ga in sorted(mid["gas"], key=lambda g: g.sub or 0):
|
|
w.writerow([
|
|
ga.name, ga.address, "", "",
|
|
ga.description or ga.comment or "",
|
|
dpt_ets_token(ga.dpt_main, ga.dpt_sub),
|
|
_security(ga),
|
|
])
|
|
return buf.getvalue()
|
|
|
|
|
|
def generate_ets_xml(project: LoadedProject) -> str:
|
|
"""ETS GA-export/01 XML."""
|
|
tree = _hierarchy(project)
|
|
lines = [
|
|
'<?xml version="1.0" encoding="utf-8"?>',
|
|
'<GroupAddress-Export xmlns="http://knx.org/xml/ga-export/01">',
|
|
]
|
|
for main in sorted(tree):
|
|
m = tree[main]
|
|
m_start = main << 11
|
|
m_end = m_start | 0x7FF
|
|
lines.append(
|
|
f' <GroupRange Name="{escape(m["name"], {chr(34): """})}" '
|
|
f'RangeStart="{m_start}" RangeEnd="{m_end}">'
|
|
)
|
|
for middle in sorted(m["middles"]):
|
|
mid = m["middles"][middle]
|
|
mid_start = (main << 11) | (middle << 8)
|
|
mid_end = mid_start | 0xFF
|
|
lines.append(
|
|
f' <GroupRange Name="{escape(mid["name"], {chr(34): """})}" '
|
|
f'RangeStart="{mid_start}" RangeEnd="{mid_end}">'
|
|
)
|
|
for ga in sorted(mid["gas"], key=lambda g: g.sub or 0):
|
|
dpts = dpt_ets_token(ga.dpt_main, ga.dpt_sub)
|
|
dpt_attr = f' DPTs="{dpts}"' if dpts else ""
|
|
desc = ga.description or ga.comment or ""
|
|
desc_attr = f' Description="{escape(desc, {chr(34): """})}"' if desc else ""
|
|
sec_attr = ' Security="On"' if ga.data_secure else ""
|
|
lines.append(
|
|
f' <GroupAddress Name="{escape(ga.name, {chr(34): """})}" '
|
|
f'Address="{ga.address}"{dpt_attr}{desc_attr}{sec_attr} />'
|
|
)
|
|
lines.append(' </GroupRange>')
|
|
lines.append(' </GroupRange>')
|
|
lines.append('</GroupAddress-Export>')
|
|
return "\n".join(lines) + "\n"
|