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>
689 lines
29 KiB
Python
689 lines
29 KiB
Python
"""nickol-knx-mcp — MCP server for design-time KNX/ETS project work.
|
|
|
|
Exposes read + analysis + generation tools over a parsed .knxproj. The server
|
|
has NO KNX/IP bus connectivity of any kind: it only reads the project archive and
|
|
writes output files into a confined workspace. It can never write to a live bus.
|
|
|
|
Run (stdio): python -m nickol_knx_mcp.server
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from .project import load_project as load_project_file, LoadedProject
|
|
from .ga_export import load_ga_export as load_ga_export_file
|
|
from .analyze import (validate_naming, detect_missing_status, detect_dpt_issues,
|
|
detect_topology_issues, secure_posture)
|
|
from .generate_ha import generate_ha_yaml
|
|
from .generate_ets import generate_ets_csv, generate_ets_xml
|
|
from .report import build_report, FEEDBACK_URL
|
|
from .handover import build_handover
|
|
from .device_library import decompose_device as _decompose_device, list_recipes
|
|
from .appprog_parser import (parse_project as _parse_project,
|
|
summary as _appprog_summary, to_catalog_yaml as _appprog_to_yaml)
|
|
from .repair import suggest_repairs as _suggest_repairs
|
|
from .advanced import (matter_readiness, completeness_grade, energy_scaffold,
|
|
test_protocol, suggest_naming)
|
|
from .diffproj import diff_projects as _diff_projects
|
|
from .iot import generate_knx_iot_turtle
|
|
from .param_check import check_device_parameters as _check_device_parameters
|
|
from .policy import (check_policy as _check_policy, load_policy as _load_policy,
|
|
example_policy_yaml as _example_policy_yaml)
|
|
from .explain import explain_ga as _explain_ga
|
|
from . import room_library as _room_library
|
|
|
|
mcp = FastMCP("nickol-knx")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# State + safety helpers
|
|
# --------------------------------------------------------------------------- #
|
|
_STATE: dict[str, Optional[LoadedProject]] = {"project": None}
|
|
|
|
# Output writes are confined to this directory (default: ./knx-workspace).
|
|
_WORKSPACE = Path(os.environ.get("NICKOL_KNX_WORKSPACE", "./knx-workspace")).resolve()
|
|
|
|
|
|
def _project() -> LoadedProject:
|
|
p = _STATE["project"]
|
|
if p is None:
|
|
raise ValueError("No project loaded. Call load_project(path) first.")
|
|
return p
|
|
|
|
|
|
def _ga_sort_key(address: str) -> tuple:
|
|
"""Stable ordering for group addresses across styles.
|
|
|
|
ThreeLevel "1/2/3" and TwoLevel "1/2" sort numerically per part; free-style "1234"
|
|
sorts numerically; anything unparseable sorts last, lexically. Two group addresses
|
|
never compare equal unless they are the same address, so a cursor is unambiguous.
|
|
"""
|
|
parts = (address or "").split("/")
|
|
try:
|
|
nums = tuple(int(p) for p in parts)
|
|
return (0, len(nums), nums, address or "")
|
|
except ValueError:
|
|
return (1, 0, (), address or "")
|
|
|
|
|
|
def _ia_sort_key(address: str) -> tuple:
|
|
"""Stable ordering for individual addresses "area.line.device"."""
|
|
try:
|
|
return (0, tuple(int(p) for p in (address or "").split(".")), address or "")
|
|
except ValueError:
|
|
return (1, (), address or "")
|
|
|
|
|
|
def _safe_write(rel_or_abs_path: str, content: str) -> str:
|
|
"""Write inside the workspace only. Returns the absolute path written."""
|
|
_WORKSPACE.mkdir(parents=True, exist_ok=True)
|
|
target = Path(rel_or_abs_path)
|
|
if not target.is_absolute():
|
|
target = _WORKSPACE / target
|
|
target = target.resolve()
|
|
if _WORKSPACE not in target.parents and target != _WORKSPACE:
|
|
raise ValueError(
|
|
f"Refusing to write outside workspace {_WORKSPACE}. "
|
|
"Set NICKOL_KNX_WORKSPACE to change it."
|
|
)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(content, encoding="utf-8")
|
|
return str(target)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Read tools
|
|
# --------------------------------------------------------------------------- #
|
|
@mcp.tool()
|
|
def load_project(path: str, password: Optional[str] = None,
|
|
language: Optional[str] = None) -> dict[str, Any]:
|
|
"""Parse a .knxproj file (read-only) and cache it for the session.
|
|
|
|
Args:
|
|
path: Path to the .knxproj file.
|
|
password: Project password, if the .knxproj is protected.
|
|
language: Optional language code (e.g. 'de-DE', 'ru-RU').
|
|
"""
|
|
proj = load_project_file(path, password=password, language=language)
|
|
_STATE["project"] = proj
|
|
return {
|
|
"loaded": True,
|
|
"name": proj.info.get("name"),
|
|
"ga_style": proj.style,
|
|
"group_addresses": len(proj.gas),
|
|
"devices": len(proj.devices),
|
|
"functions": len(proj.functions),
|
|
"ets_tool_version": proj.info.get("tool_version"),
|
|
# The first call every new user makes — the one place a gentle nudge for a
|
|
# real-project field report reaches exactly the people who ran it.
|
|
"feedback": ("Ran this on a real ETS project? Two lines on what it got "
|
|
"right/wrong help the next release: "
|
|
f"{FEEDBACK_URL} (anonymised is fine)."),
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
def load_ga_export(path: str) -> dict[str, Any]:
|
|
"""Load an ETS group-address export (ga-export/01 XML) instead of a full .knxproj.
|
|
|
|
For when you only have the GA list: an ETS "Export Group Addresses" file, or the
|
|
ETS import file a planning tool produces (TapPlan and similar). Names, addresses,
|
|
DPTs, descriptions, the security flag and the range tree are read; the result
|
|
replaces the loaded project for every other tool.
|
|
|
|
Works: check_naming, check_missing_status, check_dpt, check_policy, check_secure,
|
|
analyze_all, suggest_repairs, project_report, generate_ha_package,
|
|
generate_ets_group_addresses. Nothing to read (the export has no devices, ETS
|
|
Functions or topology): get_devices, get_topology, check_topology,
|
|
decompose_device, check_device_parameters, parse_devices_from_project. Pairing
|
|
relies on names only, since there are no ETS Function roles.
|
|
|
|
Args:
|
|
path: Path to the exported .xml file.
|
|
"""
|
|
proj = load_ga_export_file(path)
|
|
_STATE["project"] = proj
|
|
with_dpt = sum(1 for g in proj.gas.values() if g.dpt_main is not None)
|
|
return {
|
|
"loaded": True,
|
|
"source": "ga-export",
|
|
"name": proj.info.get("name"),
|
|
"ga_style": proj.style,
|
|
"group_addresses": len(proj.gas),
|
|
"with_dpt": with_dpt,
|
|
"devices": 0,
|
|
"import_warnings": proj.info.get("import_warnings", []),
|
|
"note": ("GA-only project: device, function and topology tools have nothing to read; "
|
|
"command/status pairing uses names only."),
|
|
}
|
|
|
|
@mcp.tool()
|
|
def list_group_addresses(category: Optional[str] = None,
|
|
kind: Optional[str] = None,
|
|
missing_dpt_only: bool = False,
|
|
limit: int = 500,
|
|
cursor: Optional[str] = None) -> dict[str, Any]:
|
|
"""List parsed group addresses with classification, in a **stable order**.
|
|
|
|
Filters: category (lighting/shutter/hvac/sensor/scene/energy/diagnostics),
|
|
kind (command/status/sensor), missing_dpt_only.
|
|
|
|
Paging: results are always sorted by the group address itself (main/middle/sub
|
|
numerically, free-style addresses numerically, anything else lexically), so the
|
|
order does not depend on how the project happened to parse and a retry returns
|
|
the same page. Pass the returned `next_cursor` back as `cursor` for the next
|
|
page; `next_cursor` is null on the last page. `total_matched` reports how many
|
|
addresses match the filters, so a truncated answer is never silent.
|
|
"""
|
|
proj = _project()
|
|
rows = [ga for ga in proj.gas.values()
|
|
if not (category and ga.category != category)
|
|
and not (kind and ga.kind != kind)
|
|
and not (missing_dpt_only and ga.dpt_main is not None)]
|
|
rows.sort(key=lambda ga: _ga_sort_key(ga.address))
|
|
total = len(rows)
|
|
start = 0
|
|
if cursor:
|
|
ck = _ga_sort_key(cursor)
|
|
start = next((i for i, ga in enumerate(rows) if _ga_sort_key(ga.address) > ck), total)
|
|
page = rows[start:start + max(1, limit)]
|
|
return {
|
|
"group_addresses": [{
|
|
"address": ga.address, "name": ga.name, "dpt": ga.dpt,
|
|
"category": ga.category, "kind": ga.kind, "intent": ga.intent,
|
|
"ha_platform": ga.ha_platform, "secure": ga.data_secure,
|
|
"description": ga.description,
|
|
} for ga in page],
|
|
"total_matched": total,
|
|
"returned": len(page),
|
|
"next_cursor": page[-1].address if start + len(page) < total else None,
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
def get_devices(limit: int = 500, cursor: Optional[str] = None) -> dict[str, Any]:
|
|
"""List devices (individual address, name, order number, manufacturer), sorted by
|
|
individual address (area/line/device numerically). Same paging contract as
|
|
`list_group_addresses`: `next_cursor` / `total_matched` / `returned`."""
|
|
proj = _project()
|
|
devs = sorted(proj.devices.values(),
|
|
key=lambda d: _ia_sort_key(d.get("individual_address") or ""))
|
|
total = len(devs)
|
|
start = 0
|
|
if cursor:
|
|
ck = _ia_sort_key(cursor)
|
|
start = next((i for i, d in enumerate(devs)
|
|
if _ia_sort_key(d.get("individual_address") or "") > ck), total)
|
|
page = devs[start:start + max(1, limit)]
|
|
return {
|
|
"devices": [{
|
|
"individual_address": d.get("individual_address"),
|
|
"name": d.get("name"),
|
|
"order_number": d.get("order_number"),
|
|
"manufacturer": d.get("manufacturer_name"),
|
|
"communication_objects": len(d.get("communication_object_ids", []) or []),
|
|
} for d in page],
|
|
"total_matched": total,
|
|
"returned": len(page),
|
|
"next_cursor": (page[-1].get("individual_address") if start + len(page) < total else None),
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
def get_topology() -> dict[str, Any]:
|
|
"""Return the area/line/device topology tree."""
|
|
proj = _project()
|
|
tree: dict[str, Any] = {}
|
|
for aid, area in proj.topology.items():
|
|
lines = {}
|
|
for lid, line in area.get("lines", {}).items():
|
|
lines[lid] = {"name": line.get("name"),
|
|
"medium": line.get("medium_type"),
|
|
"devices": line.get("devices", [])}
|
|
tree[aid] = {"name": area.get("name"), "lines": lines}
|
|
return tree
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Analysis tools
|
|
# --------------------------------------------------------------------------- #
|
|
@mcp.tool()
|
|
def check_naming(name_regex: Optional[str] = None) -> list[dict[str, Any]]:
|
|
"""Validate naming conventions and 3-level structure."""
|
|
return validate_naming(_project(), name_regex=name_regex)
|
|
|
|
|
|
@mcp.tool()
|
|
def check_missing_status() -> list[dict[str, Any]]:
|
|
"""Detect controllable GAs lacking a status/feedback counterpart."""
|
|
return detect_missing_status(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def check_dpt() -> list[dict[str, Any]]:
|
|
"""Detect missing, inconsistent or mismatched DPTs."""
|
|
return detect_dpt_issues(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def check_topology() -> list[dict[str, Any]]:
|
|
"""Check topology capacity and individual-address validity (KNX Handbook).
|
|
|
|
Flags lines over the TP1 segment (64) / line (256) limits, invalid or
|
|
duplicate individual addresses, and multi-line projects missing a coupler.
|
|
"""
|
|
return detect_topology_issues(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def suggest_repairs() -> dict[str, Any]:
|
|
"""Propose concrete fixes for the project's findings — repair, don't just flag.
|
|
|
|
For each issue it suggests a reviewable fix: infer a DPT for a GA that has none,
|
|
correct a suspect sub-DPT, synthesise a status/feedback GA in a free address slot,
|
|
or add an absolute-brightness GA for a relative-only dimmer. Suggestions only —
|
|
a human reviews them; accepted new GAs feed generate_ets_group_addresses. The
|
|
server never writes to ETS or the bus.
|
|
"""
|
|
return _suggest_repairs(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def check_secure() -> dict[str, Any]:
|
|
"""Summarise KNX Data Secure posture + the keyring handover checklist.
|
|
|
|
Reports how many group addresses are secured vs plaintext, flags middle
|
|
groups that mix secure and plaintext addresses (a function is only as secure
|
|
as its weakest GA), and emits the ETS/HA keyring workflow as a checklist.
|
|
Report-only — this server never touches key material.
|
|
"""
|
|
return secure_posture(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def analyze_all(name_regex: Optional[str] = None) -> dict[str, Any]:
|
|
"""Run every check and return the report summary plus all findings."""
|
|
proj = _project()
|
|
rep = build_report(proj, name_regex=name_regex)
|
|
topology = detect_topology_issues(proj)
|
|
# build_report counts only naming+status+dpt severities; fold in topology so
|
|
# the errors/warnings/info totals cover every check the summary reports on.
|
|
summary = dict(rep["summary"])
|
|
topo_sev = Counter(f["severity"] for f in topology)
|
|
summary["errors"] = summary.get("errors", 0) + topo_sev.get("error", 0)
|
|
summary["warnings"] = summary.get("warnings", 0) + topo_sev.get("warning", 0)
|
|
summary["info"] = summary.get("info", 0) + topo_sev.get("info", 0)
|
|
return {
|
|
"summary": summary,
|
|
"naming": validate_naming(proj, name_regex=name_regex),
|
|
"missing_status": detect_missing_status(proj),
|
|
"dpt": detect_dpt_issues(proj),
|
|
"topology": topology,
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Generation tools (write only into the confined workspace)
|
|
# --------------------------------------------------------------------------- #
|
|
@mcp.tool()
|
|
def generate_ha_package(output_path: Optional[str] = None) -> dict[str, Any]:
|
|
"""Generate a Home Assistant KNX package YAML.
|
|
|
|
If output_path is given, the YAML is written into the workspace and the path
|
|
returned; otherwise the YAML text is returned inline.
|
|
"""
|
|
proj = _project()
|
|
res = generate_ha_yaml(proj)
|
|
out: dict[str, Any] = {"counts": res["counts"], "review": res["review"]}
|
|
if output_path:
|
|
out["written"] = _safe_write(output_path, res["yaml"])
|
|
else:
|
|
out["yaml"] = res["yaml"]
|
|
return out
|
|
|
|
|
|
@mcp.tool()
|
|
def generate_ets_group_addresses(fmt: str = "xml",
|
|
output_path: Optional[str] = None) -> dict[str, Any]:
|
|
"""Generate an ETS-importable Group Address export.
|
|
|
|
Args:
|
|
fmt: 'xml' (ga-export/01, recommended) or 'csv' (native ETS layout).
|
|
output_path: optional file inside the workspace.
|
|
"""
|
|
proj = _project()
|
|
if fmt == "csv":
|
|
content = generate_ets_csv(proj)
|
|
elif fmt == "xml":
|
|
content = generate_ets_xml(proj)
|
|
else:
|
|
raise ValueError("fmt must be 'xml' or 'csv'")
|
|
out: dict[str, Any] = {"format": fmt}
|
|
if output_path:
|
|
out["written"] = _safe_write(output_path, content)
|
|
else:
|
|
out["content"] = content
|
|
return out
|
|
|
|
|
|
@mcp.tool()
|
|
def project_report(output_path: Optional[str] = None,
|
|
name_regex: Optional[str] = None) -> dict[str, Any]:
|
|
"""Produce the human-readable Markdown report (review before any import)."""
|
|
proj = _project()
|
|
rep = build_report(proj, name_regex=name_regex)
|
|
out: dict[str, Any] = {"summary": rep["summary"]}
|
|
if output_path:
|
|
out["written"] = _safe_write(output_path, rep["markdown"])
|
|
else:
|
|
out["markdown"] = rep["markdown"]
|
|
return out
|
|
|
|
|
|
@mcp.tool()
|
|
def generate_handover_pack(output_dir: Optional[str] = None) -> dict[str, Any]:
|
|
"""Generate a project handover pack (as-built deliverable for commissioning).
|
|
|
|
Assembles an equipment inventory, group-address map by domain, command/status
|
|
coverage, KNX Secure scope and QA state into ``handover.md``, plus a
|
|
``topology.svg`` diagram, the full ``group-addresses.csv`` and the
|
|
``ha-package.yaml``. When ``output_dir`` is given (a folder inside the
|
|
workspace) all files are written there and the paths returned; otherwise the
|
|
handover markdown + SVG are returned inline.
|
|
"""
|
|
proj = _project()
|
|
pack = build_handover(proj)
|
|
out: dict[str, Any] = {"summary": pack["summary"]}
|
|
if output_dir:
|
|
d = output_dir.rstrip("/")
|
|
written = {
|
|
"handover": _safe_write(f"{d}/handover.md", pack["markdown"]),
|
|
"topology_svg": _safe_write(f"{d}/topology.svg", pack["svg"]),
|
|
"group_addresses_csv": _safe_write(f"{d}/group-addresses.csv",
|
|
generate_ets_csv(proj)),
|
|
"ha_package": _safe_write(f"{d}/ha-package.yaml",
|
|
generate_ha_yaml(proj)["yaml"]),
|
|
}
|
|
out["written"] = written
|
|
else:
|
|
out["markdown"] = pack["markdown"]
|
|
out["svg"] = pack["svg"]
|
|
return out
|
|
|
|
|
|
@mcp.tool()
|
|
def decompose_device(order_number: str, channels: int = 1) -> dict[str, Any]:
|
|
"""Expand a device into its group-address decomposition recipe.
|
|
|
|
A KNX actuator channel is not one GA — it expands into command/status/dimming/
|
|
position/mode objects, each with its DPT. Given a device order number, type or
|
|
alias (e.g. 'ZIO-MB24', 'dimmer', 'JRA/S', 'presence detector') and a channel
|
|
count, returns the objects a professional wires per channel and the total GA
|
|
count. Use when turning a spec/ТЗ device list into a group-address structure.
|
|
"""
|
|
return _decompose_device(order_number, channels=channels)
|
|
|
|
|
|
@mcp.tool()
|
|
def list_device_recipes() -> list[dict[str, Any]]:
|
|
"""List the device decomposition recipes in the built-in device library."""
|
|
return list_recipes()
|
|
|
|
|
|
@mcp.tool()
|
|
def parse_devices_from_project(path: str, output_path: Optional[str] = None,
|
|
password: Optional[str] = None) -> dict[str, Any]:
|
|
"""Extract exact device object models from a .knxproj / .knxprod application programs.
|
|
|
|
Reads the manufacturer application programs (M-*) embedded in an ETS `.knxproj`
|
|
(devices actually used) or a `.knxprod` product database, and returns each device's
|
|
order number, app-program version, object counts and detected per-channel blocks —
|
|
the EXACT vendor comm-object model, not a generic recipe. Read-only and PII-safe: it
|
|
reads only vendor catalog data, never the client project (P-*/0.xml).
|
|
|
|
Use this to build/grow the local device catalog that `decompose_device` consumes
|
|
(set NICKOL_KNX_CATALOG to the catalog dir). If `output_path` is given, the full
|
|
catalog is written into the workspace as device-library YAML; the return value is
|
|
always a compact per-device summary + coverage manifest (the full object lists are
|
|
not inlined). DPT `unverified` = the vendor app-program declares none (never guessed).
|
|
"""
|
|
result = _parse_project(path, password=password)
|
|
if "error" in result:
|
|
return result
|
|
out = _appprog_summary(result)
|
|
if output_path:
|
|
out["written"] = _safe_write(output_path, _appprog_to_yaml(result))
|
|
out["written_note"] = ("Local catalog file. Point NICKOL_KNX_CATALOG at its "
|
|
"directory to make decompose_device return catalog-exact.")
|
|
return out
|
|
|
|
|
|
@mcp.tool()
|
|
def check_device_parameters(path: str, password: Optional[str] = None,
|
|
min_group: int = 3) -> dict[str, Any]:
|
|
"""Find the device whose ETS **parameter** settings differ from its N identical
|
|
siblings — the odd thermostat/sensor out (e.g. one thermostat with a different
|
|
setpoint/hysteresis, one presence detector with a different detection time).
|
|
|
|
Reads per-device parameter values straight from the `.knxproj` project part
|
|
(data xknxproject does not expose), groups identical devices by application
|
|
program, and returns `clear_outliers` (a strong majority with a small minority —
|
|
likely a mistake) and `split_configs` (balanced 2+ variants — review, often two
|
|
zones). Numeric config parameters are listed first; names are resolved from the
|
|
device application program. Read-only, no ETS/bus. Give a real `.knxproj` `path`
|
|
(a password-protected/encrypted project cannot be read)."""
|
|
return _check_device_parameters(path, password=password, min_group=min_group)
|
|
|
|
|
|
@mcp.tool()
|
|
def check_policy(profile_path: Optional[str] = None,
|
|
write_example_to: Optional[str] = None) -> dict[str, Any]:
|
|
"""Validate the loaded project against a **Project Policy Profile** — *your*
|
|
agreed rules (main-group taxonomy, naming regex, command/status exemptions),
|
|
not one universal "standard". Flags GAs whose domain doesn't match the main
|
|
group your policy assigns, and names that don't match your pattern. Pass
|
|
`profile_path` to a YAML profile (omit to validate against the taxonomy inferred
|
|
from the project itself). Set `write_example_to` to drop a commented example
|
|
profile into the workspace — **seeded from the loaded project's own main groups**
|
|
(mains that do not exist in the project are not written). Report-only."""
|
|
if write_example_to:
|
|
proj = _STATE["project"]
|
|
seeded = (f"project '{(proj.info or {}).get('name') or proj.path}' (main groups inferred "
|
|
f"from the project itself)" if proj is not None else
|
|
"defaults (no project loaded — call load_project first to get an example "
|
|
"seeded from your own main groups)")
|
|
return {"example_written": _safe_write(write_example_to, _example_policy_yaml(proj)),
|
|
"seeded_from": seeded}
|
|
return _check_policy(_project(), _load_policy(profile_path))
|
|
|
|
|
|
@mcp.tool()
|
|
def explain_ga(address: str) -> dict[str, Any]:
|
|
"""**Provenance** for one group address — why the tool classified it the way it did.
|
|
Replays the classification and shows, per decision (category / kind / status pairing),
|
|
the signals that fired with a **confidence tier**: authoritative (an ETS Function role) >
|
|
structural (the KNX DPT) > heuristic (a name keyword). Flags **conflicts** (e.g. a GA
|
|
the DPT calls `lighting` while its name says "AC") — the hotspot for silent
|
|
misclassification. Read-only; use before trusting a category or generating an entity."""
|
|
return _explain_ga(_project(), address)
|
|
|
|
|
|
@mcp.tool()
|
|
def check_matter() -> dict[str, Any]:
|
|
"""Matter-readiness lint: which controllable functions round-trip to a Matter
|
|
cluster (have command + status + a decodable DPT) and which won't."""
|
|
return matter_readiness(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def grade_completeness() -> dict[str, Any]:
|
|
"""Grade the project: bare functional skeleton vs as-built grade — by the presence
|
|
of the professional patterns (central macros, device tuning, astro/meteo, monitoring,
|
|
deep metering, scenes, reserves, a debug main)."""
|
|
return completeness_grade(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def check_energy() -> dict[str, Any]:
|
|
"""Check the metering/energy domain (energy DPTs 13.x / 14.056) and suggest a
|
|
per-circuit / PV / battery / EVSE structure for the HA energy dashboard."""
|
|
return energy_scaffold(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def suggest_names() -> dict[str, Any]:
|
|
"""Naming hygiene suggestions (empty names, status GAs missing a status keyword)."""
|
|
return suggest_naming(_project())
|
|
|
|
|
|
@mcp.tool()
|
|
def generate_test_protocol(output_path: Optional[str] = None) -> dict[str, Any]:
|
|
"""Draft a functional acceptance protocol (per function: command → expected status,
|
|
pass/fail/sign-off) as Markdown. Execution is manual/on-site; this only drafts it."""
|
|
res = test_protocol(_project())
|
|
out: dict[str, Any] = {"functions": res["functions"]}
|
|
if output_path:
|
|
out["written"] = _safe_write(output_path, res["markdown"])
|
|
else:
|
|
out["markdown"] = res["markdown"]
|
|
return out
|
|
|
|
|
|
@mcp.tool()
|
|
def diff_projects(path_a: str, path_b: str,
|
|
password_a: Optional[str] = None,
|
|
password_b: Optional[str] = None) -> dict[str, Any]:
|
|
"""Semantic diff between two .knxproj files (path_a = base/old, path_b = new):
|
|
added / removed GAs, DPT changes, renames, security-flag changes. Read-only."""
|
|
return _diff_projects(path_a, path_b, password_a=password_a, password_b=password_b)
|
|
|
|
|
|
@mcp.tool()
|
|
def generate_knx_iot(output_path: Optional[str] = None) -> dict[str, Any]:
|
|
"""Export a KNX IoT semantic view (Turtle/RDF) of the project's functional
|
|
datapoints — a pragmatic skeleton for the IP-native model, for review."""
|
|
proj = _project()
|
|
turtle = generate_knx_iot_turtle(proj)
|
|
if output_path:
|
|
return {"written": _safe_write(output_path, turtle)}
|
|
return {"turtle": turtle}
|
|
|
|
|
|
@mcp.tool()
|
|
def validate_room_template(template: Optional[str] = None,
|
|
path: Optional[str] = None) -> dict[str, Any]:
|
|
"""Validate a Room Library template against the R1 schema (report-only).
|
|
|
|
Pass ``template`` (a built-in template's semantic slot_id, e.g. 'bedroom',
|
|
'kitchen') or ``path`` to a custom template YAML. Checks the public contract:
|
|
a locale-neutral slot_id, ru/en labels, per-slot basic/comfort presets, known
|
|
function types, valid multiplicities, and that ``area_m2`` is a hint with
|
|
provenance (never a normative fact). Returns ok + findings; nothing is written.
|
|
"""
|
|
if path:
|
|
tmpl = _room_library.load_template_file(path)
|
|
elif template:
|
|
tmpls = _room_library.load_builtin_templates()
|
|
if template not in tmpls:
|
|
return {"ok": False, "error": f"unknown built-in template '{template}'. "
|
|
f"Available: {sorted(tmpls)}"}
|
|
tmpl = tmpls[template]
|
|
else:
|
|
tmpls = _room_library.load_builtin_templates()
|
|
return {"ok": True, "available_templates": sorted(tmpls),
|
|
"note": "Pass template=<slot_id> or path=<file.yaml> to validate one."}
|
|
return _room_library.validate_room_template(tmpl)
|
|
|
|
|
|
@mcp.tool()
|
|
def compose_rooms(rooms: list[dict[str, Any]], language: str = "ru",
|
|
project_name: str = "Room Library house",
|
|
output_dir: Optional[str] = None,
|
|
dry_run: bool = True) -> dict[str, Any]:
|
|
"""Compose a **new** KNX project from a list of room templates (constructor).
|
|
|
|
``rooms`` is a list of specs, each: ``{template, preset?, slot_presets?,
|
|
params?, label?}`` — e.g. ``{"template": "bedroom", "preset": "comfort"}``.
|
|
``preset`` is basic|comfort (per-room); ``slot_presets`` overrides individual
|
|
slots (mix comfort climate with basic lighting); ``params`` overrides template
|
|
defaults (window/circuit counts); ``label`` sets a custom zone name.
|
|
|
|
Pipeline: resolve templates+params to a functional model, allocate group
|
|
addresses (main = domain, middle = role, sub sequential), write a real
|
|
``.knxproj`` and **re-read it with the standard loader**, then run our linters
|
|
on the re-read project. Output: a ``manifest`` (allocation), ETS GA XML/CSV,
|
|
and a device ``bom`` proposal from the device library.
|
|
|
|
R1 builds NEW projects only and is dry-run by default (nothing written). Set
|
|
``dry_run=false`` with ``output_dir`` (a folder inside the workspace) to write
|
|
the .knxproj, ETS exports, manifest.yaml and bom.yaml. Docking into an
|
|
existing project and exact device selection are R2. Never touches a bus.
|
|
"""
|
|
if language not in _room_library.SUPPORTED_LANGUAGES:
|
|
raise ValueError(f"language must be one of {_room_library.SUPPORTED_LANGUAGES}")
|
|
try:
|
|
res = _room_library.compose(rooms, language=language, project_name=project_name)
|
|
except _room_library.RoomLibraryError as e:
|
|
return {"ok": False, "error": str(e)}
|
|
|
|
knxproj_bytes = res.pop("_knxproj_bytes")
|
|
out: dict[str, Any] = {
|
|
"ok": True,
|
|
"dry_run": dry_run,
|
|
"project_name": res["project_name"],
|
|
"language": res["language"],
|
|
"totals": res["totals"],
|
|
"lint": res["lint"],
|
|
"manifest": res["manifest"],
|
|
"bom": res["bom"],
|
|
}
|
|
if dry_run or not output_dir:
|
|
out["ets_xml"] = res["ets_xml"]
|
|
out["ets_csv"] = res["ets_csv"]
|
|
out["note"] = ("Dry run — nothing written. Review the manifest and lint, then "
|
|
"re-run with dry_run=false and output_dir to persist artifacts.")
|
|
else:
|
|
import yaml
|
|
d = output_dir.rstrip("/")
|
|
_WORKSPACE.mkdir(parents=True, exist_ok=True)
|
|
knx_path = Path(_safe_write(f"{d}/project.knxproj", "")) # reserve+validate path
|
|
knx_path.write_bytes(knxproj_bytes)
|
|
written = {
|
|
"knxproj": str(knx_path),
|
|
"ets_xml": _safe_write(f"{d}/group-addresses.xml", res["ets_xml"]),
|
|
"ets_csv": _safe_write(f"{d}/group-addresses.csv", res["ets_csv"]),
|
|
"manifest": _safe_write(f"{d}/manifest.yaml",
|
|
yaml.safe_dump(res["manifest"], allow_unicode=True,
|
|
sort_keys=False)),
|
|
"bom": _safe_write(f"{d}/bom.yaml",
|
|
yaml.safe_dump(res["bom"], allow_unicode=True,
|
|
sort_keys=False)),
|
|
}
|
|
out["written"] = written
|
|
return out
|
|
|
|
|
|
@mcp.tool()
|
|
def workspace_info() -> dict[str, Any]:
|
|
"""Show the confined output workspace and the safety guarantees."""
|
|
return {
|
|
"workspace": str(_WORKSPACE),
|
|
"bus_access": False,
|
|
"note": "This server never connects to a KNX/IP bus. It only reads the "
|
|
".knxproj and writes files inside the workspace. Use a Git MCP / "
|
|
"filesystem MCP to version the outputs.",
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
mcp.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|