mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
Cloners install and run the tool but rarely reread the README, so the existing 'call for testers' never reaches them. Two low-noise touchpoints instead: - project_report: a one-line footer linking the real-project test-report issue template (the report is the artefact a human reads after a real run); - load_project: a short 'feedback' field on the result — the first call every new user makes. Anonymised addresses explicitly welcome. No logic change; full suite green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
146 lines
5.9 KiB
Python
146 lines
5.9 KiB
Python
"""Human-readable Markdown report.
|
|
|
|
Per the safety requirement, a report like this is produced BEFORE any ETS import
|
|
or HA deployment, so a human can review what will change.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter, defaultdict
|
|
from typing import Any
|
|
|
|
from .project import LoadedProject
|
|
from .analyze import (validate_naming, detect_missing_status, detect_dpt_issues,
|
|
detect_topology_issues)
|
|
from .generate_ha import generate_ha_yaml
|
|
|
|
|
|
_SEV_ICON = {"error": "🔴", "warning": "🟡", "info": "🔵"}
|
|
|
|
|
|
FEEDBACK_URL = "https://github.com/NickoScope/nickol-knx-mcp/issues/new?template=real_project_test.yml"
|
|
|
|
def _section(title: str, findings: list[dict[str, Any]]) -> str:
|
|
if not findings:
|
|
return f"### {title}\n\n_No issues found._\n"
|
|
out = [f"### {title} ({len(findings)})\n"]
|
|
by_sev = defaultdict(list)
|
|
for f in findings:
|
|
by_sev[f["severity"]].append(f)
|
|
for sev in ("error", "warning", "info"):
|
|
for f in by_sev.get(sev, []):
|
|
out.append(f"- {_SEV_ICON.get(sev,'')} `{f['address']}` — {f['message']}")
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def build_report(project: LoadedProject,
|
|
name_regex: str | None = None) -> dict[str, Any]:
|
|
"""Return {'markdown': str, 'summary': {...}}."""
|
|
naming = validate_naming(project, name_regex=name_regex)
|
|
status = detect_missing_status(project)
|
|
dpts = detect_dpt_issues(project)
|
|
topology = detect_topology_issues(project)
|
|
ha = generate_ha_yaml(project)
|
|
|
|
info = project.info
|
|
gas = project.gas
|
|
|
|
cat_counts = Counter(ga.category for ga in gas.values())
|
|
kind_counts = Counter(ga.kind for ga in gas.values())
|
|
intent_counts = Counter(ga.intent for ga in gas.values())
|
|
no_dpt = sum(1 for ga in gas.values() if ga.dpt_main is None)
|
|
secure = sum(1 for ga in gas.values() if ga.data_secure)
|
|
non_functional = sum(v for k, v in intent_counts.items() if k != "functional")
|
|
|
|
all_findings = naming + status + dpts + topology
|
|
sev_counts = Counter(f["severity"] for f in all_findings)
|
|
|
|
md: list[str] = []
|
|
md.append(f"# KNX Project Report — {info.get('name', '?')}\n")
|
|
md.append(
|
|
f"- **Source:** `{project.path}`\n"
|
|
f"- **GA style:** {info.get('group_address_style', '?')}\n"
|
|
f"- **ETS tool version:** {info.get('tool_version', '?')} "
|
|
f"(xknxproject {info.get('xknxproject_version', '?')})\n"
|
|
f"- **Last modified:** {info.get('last_modified', '?')}\n"
|
|
)
|
|
|
|
md.append("\n## 1. Inventory\n")
|
|
md.append(
|
|
f"- Group addresses: **{len(gas)}**\n"
|
|
f"- Devices: **{len(project.devices)}**\n"
|
|
f"- Functions: **{len(project.functions)}**\n"
|
|
f"- Without DPT: **{no_dpt}**\n"
|
|
f"- KNX Data Secure GAs: **{secure}**\n"
|
|
)
|
|
md.append("\n**By category:** " +
|
|
", ".join(f"{k}={v}" for k, v in sorted(cat_counts.items())) + "\n")
|
|
md.append("**By kind:** " +
|
|
", ".join(f"{k}={v}" for k, v in sorted(kind_counts.items())) + "\n")
|
|
if non_functional:
|
|
md.append(
|
|
"**By purpose:** " +
|
|
", ".join(f"{k}={v}" for k, v in sorted(intent_counts.items())) +
|
|
f" — {non_functional} non-functional GA(s) (reserve / logic / scratch) "
|
|
"are excluded from error and missing-status checks to keep the report "
|
|
"focused on real device addresses.\n"
|
|
)
|
|
|
|
md.append("\n## 2. Findings\n")
|
|
md.append(
|
|
f"Totals: 🔴 errors **{sev_counts.get('error',0)}**, "
|
|
f"🟡 warnings **{sev_counts.get('warning',0)}**, "
|
|
f"🔵 info **{sev_counts.get('info',0)}**\n"
|
|
)
|
|
md.append("\n" + _section("2.1 Naming & structure", naming))
|
|
md.append("\n" + _section("2.2 Missing status addresses", status))
|
|
md.append("\n" + _section("2.3 DPT consistency", dpts))
|
|
md.append("\n" + _section("2.4 Topology & addressing", topology))
|
|
|
|
md.append("\n## 3. Home Assistant mapping preview\n")
|
|
c = ha["counts"]
|
|
md.append(
|
|
f"Entities that can be generated now: switch **{c['switch']}**, "
|
|
f"light **{c['light']}**, cover **{c['cover']}**, "
|
|
f"climate **{c.get('climate', 0)}**, "
|
|
f"binary_sensor **{c['binary_sensor']}**, sensor **{c['sensor']}**.\n"
|
|
)
|
|
if ha["review"]:
|
|
md.append(f"\n**Needs manual review ({len(ha['review'])}):**\n")
|
|
for r in ha["review"][:50]:
|
|
md.append(f"- `{r.get('address','-')}` {r.get('name','')} — {r['reason']}")
|
|
if len(ha["review"]) > 50:
|
|
md.append(f"- … and {len(ha['review']) - 50} more")
|
|
md.append("")
|
|
|
|
md.append("\n## 4. Next steps\n")
|
|
md.append(
|
|
"1. Resolve 🔴 errors (missing DPT, empty names) in ETS first.\n"
|
|
"2. Add status/feedback GAs for every flagged command.\n"
|
|
"3. Re-run this report until errors are clear.\n"
|
|
"4. Generate ETS CSV/XML and HA YAML, commit to Git, then import into ETS "
|
|
"and deploy to Home Assistant.\n"
|
|
)
|
|
# A gentle, one-line nudge at the very end — the report is the artefact a
|
|
# human actually reads after running the tool on a real project, so this is
|
|
# where a field report is most likely to happen. Anonymised is fine.
|
|
md.append(
|
|
"\n---\n*Ran this on a real ETS project? Two lines in a "
|
|
f"[real-project test report]({FEEDBACK_URL}) — what it got right, what it "
|
|
"got wrong — are what make the next release better. Anonymised addresses "
|
|
"are perfectly fine.*\n"
|
|
)
|
|
|
|
summary = {
|
|
"ga_count": len(gas),
|
|
"errors": sev_counts.get("error", 0),
|
|
"warnings": sev_counts.get("warning", 0),
|
|
"info": sev_counts.get("info", 0),
|
|
"missing_status": len(status),
|
|
"topology": len(topology),
|
|
"intent": dict(intent_counts),
|
|
"ha_entities": {k: v for k, v in c.items() if k != "review"},
|
|
"ha_review": c["review"],
|
|
}
|
|
return {"markdown": "\n".join(md), "summary": summary}
|