diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c5025..bbe8d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **GA-intent classification — noise reduction on real projects** (`intent.py`). Every group + address is now classified as `functional` / `reserve` / `logic` / `scratch`. Intentional + non-functional GAs no longer "cry wolf": reserve spares with no DPT become an INFO note + (`reserve_without_dpt`) instead of a 🔴 error; reserve names repeated across different DPTs + are not flagged `duplicate_name` / `inconsistent_dpt`; internal logic / virtual signals and + scratch leftovers are excluded from missing-status warnings. The `dpt_mismatch_co` check and + every real functional finding are untouched. Driven by a real 685-GA Zennio project where + this cut false errors **29 → 6** and missing-status noise **79 → 45** while preserving all + 12 real `dpt_mismatch_co` catches. `list_group_addresses`, the report inventory and the + `analyze_all` summary now expose the intent breakdown. New regression test covers the + reserve / logic / scratch patterns (and proves real DPT + status problems still surface). - `docs/` — a self-contained GitHub Pages landing site (project overview, two-layer architecture, demo-house stats, an interactive 5-tab dashboard preview, the HA "brain", and a call for testers). diff --git a/nickol_knx_mcp/analyze.py b/nickol_knx_mcp/analyze.py index 24e5499..a925851 100644 --- a/nickol_knx_mcp/analyze.py +++ b/nickol_knx_mcp/analyze.py @@ -16,6 +16,7 @@ from typing import Any, Optional from .project import LoadedProject, GARecord, STATUS_KEYWORDS from .pairing import find_status, function_status_pairs +from .intent import INTENT_FUNCTIONAL, INTENT_RESERVE, INTENT_SCRATCH SEVERITY_ERROR = "error" SEVERITY_WARN = "warning" @@ -58,6 +59,12 @@ def validate_naming(project: LoadedProject, )) continue + # Reserve / logic / scratch GAs are intentional placeholders — short + # names, repeated "Резерв", non-conventional names are expected, so they + # must not raise naming warnings (that is the noise we are removing). + if ga.intent != INTENT_FUNCTIONAL: + continue + seen_names[name.lower()].append(addr) if len(name) < min_name_len: @@ -180,6 +187,8 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]: for addr, ga in project.gas.items(): if addr in covered: continue + if ga.intent != INTENT_FUNCTIONAL: + continue # reserve / logic / scratch GAs need no status by design if ga.kind != "command": continue if ga.dpt_main is None: @@ -207,12 +216,22 @@ def detect_dpt_issues(project: LoadedProject) -> list[dict[str, Any]]: # 1. missing DPT for addr, ga in project.gas.items(): if ga.dpt_main is None: - findings.append(_finding( - SEVERITY_ERROR, "missing_dpt", addr, - f"'{ga.name}' has no DPT assigned. Home Assistant requires a DPT " - "to decode this group address.", - name=ga.name, - )) + # A reserve / scratch GA with no DPT is intentional (a spare), so it + # is an INFO note, not a 🔴 error that blocks the project. + if ga.intent in (INTENT_RESERVE, INTENT_SCRATCH): + findings.append(_finding( + SEVERITY_INFO, "reserve_without_dpt", addr, + f"'{ga.name}' is a {ga.intent} placeholder with no DPT — " + "intentional, assign a DPT only when you start using it.", + name=ga.name, intent=ga.intent, + )) + else: + findings.append(_finding( + SEVERITY_ERROR, "missing_dpt", addr, + f"'{ga.name}' has no DPT assigned. Home Assistant requires a DPT " + "to decode this group address.", + name=ga.name, + )) # 2. CO <-> GA dpt mismatch cos = project.raw.get("communication_objects", {}) @@ -242,6 +261,10 @@ def detect_dpt_issues(project: LoadedProject) -> list[dict[str, Any]]: if ga.name.strip(): by_name[ga.name.strip().lower()].append(ga) for low, recs in by_name.items(): + # Reserve / logic / scratch GAs intentionally share a generic name + # ("Резерв") across different DPTs — not an inconsistency. + if recs[0].intent != INTENT_FUNCTIONAL: + continue dpts = {r.dpt for r in recs if r.dpt} if len(dpts) > 1: findings.append(_finding( diff --git a/nickol_knx_mcp/intent.py b/nickol_knx_mcp/intent.py new file mode 100644 index 0000000..a7d47bb --- /dev/null +++ b/nickol_knx_mcp/intent.py @@ -0,0 +1,71 @@ +"""Classify a group address by INTENT: functional / reserve / logic / scratch. + +Real ETS projects are full of intentional NON-functional group addresses: + * **reserve** — spare placeholders ("Резерв", "Reserve", "Spare") left for + future use, often with no DPT on purpose; + * **logic** — internal/virtual signals used only inside the bus program + (intermediate logic results, summed signals, time markers, request triggers) + that have no physical state to read back; + * **scratch** — test/leftover GAs ("Новый групповой адрес", a bare "1"). + +Treating these like functional device GAs is what makes the tool "cry wolf" on a +real project: a spare with no DPT becomes a 🔴 error, a logic signal becomes a +"missing status" 🟡 warning. This classifier lets the checks reclassify that +noise (downgrade / skip) instead of drowning the real findings. + +Name-based and deliberately conservative: when unsure we return ``functional`` so +a real problem is never hidden. Multilingual (RU / EN / DE). +""" + +from __future__ import annotations + +import re + +INTENT_FUNCTIONAL = "functional" +INTENT_RESERVE = "reserve" +INTENT_LOGIC = "logic" +INTENT_SCRATCH = "scratch" + +# Spare / reserved placeholders. "резерв" lower-cases both "Резерв" and "РЕЗЕРВ". +_RESERVE_TOKENS = ( + "резерв", "reserve", "reserved", "spare", "запас", "не использ", "unused", + "не задейств", +) + +# Internal logic / virtual signals — no physical status by design. +_LOGIC_TOKENS = ( + "промежуточн", "суммарн", "логик", "logic", "метка времени", "выход ф-ци", + "ф-ция управлен", "ф-ции управлен", "ф-ция упр", "дубль", "вспомогат", + "intermediate", "internal signal", "virtual", "запрос", "request", + "годовой период", "сигнал включения", "сигнал отключения", +) + +# Known scratch / default ETS names (exact, lower-cased). +_SCRATCH_EXACT = ( + "новый групповой адрес", "new group address", "new group object", + "neue gruppenadresse", +) + + +def classify_intent(name: str) -> str: + """Return one of functional / reserve / logic / scratch for a GA name.""" + raw = (name or "").strip() + if not raw: + # Empty name is a separate, real problem (``empty_name``); leave it + # functional so that check still fires. + return INTENT_FUNCTIONAL + + low = raw.lower() + + if low in _SCRATCH_EXACT: + return INTENT_SCRATCH + # Bare numeric placeholders like "1", "5", "12" — classic leftovers. + if re.fullmatch(r"\d{1,3}", raw): + return INTENT_SCRATCH + + if any(t in low for t in _RESERVE_TOKENS): + return INTENT_RESERVE + if any(t in low for t in _LOGIC_TOKENS): + return INTENT_LOGIC + + return INTENT_FUNCTIONAL diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index f384011..a4f5d22 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -15,6 +15,7 @@ from xknxproject import XKNXProj from xknxproject.models import KNXProject from .dpt_map import classify_dpt, dpt_key +from .intent import classify_intent, INTENT_FUNCTIONAL # Multilingual keyword sets (EN / DE / RU) used by the heuristic fallbacks. @@ -71,6 +72,10 @@ class GARecord: ha_platform: str value_type: Optional[str] label: str + # Purpose of the GA: functional / reserve / logic / scratch. Non-functional + # GAs are intentional noise (spares, internal logic, leftovers) and the + # checks reclassify them instead of raising false errors/warnings. + intent: str = INTENT_FUNCTIONAL # 3-level decomposition main: Optional[int] = None middle: Optional[int] = None @@ -203,6 +208,7 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: ha_platform=ha_platform, value_type=info["value_type"], label=info["label"], + intent=classify_intent(ga.get("name", "")), main=m, middle=mid, sub=s, main_name=range_names.get(str(m), "") if m is not None else "", middle_name=range_names.get(f"{m}/{mid}", "") if m is not None and mid is not None else "", diff --git a/nickol_knx_mcp/report.py b/nickol_knx_mcp/report.py index 127a7b7..760659e 100644 --- a/nickol_knx_mcp/report.py +++ b/nickol_knx_mcp/report.py @@ -43,8 +43,10 @@ def build_report(project: LoadedProject, 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 sev_counts = Counter(f["severity"] for f in all_findings) @@ -71,6 +73,14 @@ def build_report(project: LoadedProject, ", ".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( @@ -112,6 +122,7 @@ def build_report(project: LoadedProject, "warnings": sev_counts.get("warning", 0), "info": sev_counts.get("info", 0), "missing_status": len(status), + "intent": dict(intent_counts), "ha_entities": {k: v for k, v in c.items() if k != "review"}, "ha_review": c["review"], } diff --git a/nickol_knx_mcp/server.py b/nickol_knx_mcp/server.py index 261a62c..2b8fe8d 100644 --- a/nickol_knx_mcp/server.py +++ b/nickol_knx_mcp/server.py @@ -103,7 +103,7 @@ def list_group_addresses(category: Optional[str] = None, continue out.append({ "address": ga.address, "name": ga.name, "dpt": ga.dpt, - "category": ga.category, "kind": ga.kind, + "category": ga.category, "kind": ga.kind, "intent": ga.intent, "ha_platform": ga.ha_platform, "secure": ga.data_secure, "description": ga.description, }) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 69c8528..35e04b8 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -250,3 +250,66 @@ _covers7 = _pkg7.get("cover", []) assert len(_covers7) == 1 and _covers7[0].get("move_short_address") == "2/0/1", _covers7 assert any(r["reason"] == "manual_datetime" for r in _ha7["review"]), "datetime not routed to review" print("OK: full light entity (no dup switch); 1.001/1.017 cover; datetime -> review") + +# --------------------------------------------------------------------------- # +# Regression (Track D): GA-intent classification removes real-project NOISE. +# Patterns taken verbatim from the real Minsk (Zennio) project, where reserve +# spares + internal logic GAs produced ~29 false errors and dozens of false +# missing-status warnings. Non-functional GAs must be reclassified, while a real +# functional problem must STILL be flagged (no over-suppression). +# --------------------------------------------------------------------------- # +print("\n=== REGRESSION: GA-intent noise reduction (reserve / logic / scratch) ===") +from nickol_knx_mcp.intent import ( + classify_intent, INTENT_RESERVE, INTENT_LOGIC, INTENT_SCRATCH, INTENT_FUNCTIONAL, +) + +# Direct classifier checks on real names. +assert classify_intent("Резерв") == INTENT_RESERVE +assert classify_intent("РЕЗЕРВ") == INTENT_RESERVE +assert classify_intent("Промежуточный результат логики индикации") == INTENT_LOGIC +assert classify_intent("Метеостанция - Запрос температуры") == INTENT_LOGIC +assert classify_intent("суммарный сигнал включения групп света") == INTENT_LOGIC +assert classify_intent("1") == INTENT_SCRATCH +assert classify_intent("Новый групповой адрес") == INTENT_SCRATCH +assert classify_intent("Kitchen ceiling light switch") == INTENT_FUNCTIONAL + +_rawD = {"group_addresses": { + # reserve spares: no DPT (intentional) + same name, different DPTs + "0/1/8": ga("0/1/8", "Резерв", None, None), + "1/2/49": ga("1/2/49", "Резерв", 5, 1), + "1/2/50": ga("1/2/50", "Резерв", 1, 3), + # internal logic command with no status (should NOT be flagged) + "1/1/7": ga("1/1/7", "Промежуточный результат логики индикации", 1, 1), + "8/0/18": ga("8/0/18", "Метеостанция - Запрос температуры", 1, 17), + # scratch leftover + "9/2/0": ga("9/2/0", "1", 1, 1), + # a REAL functional problem that must survive: missing DPT + missing status + "3/1/21": ga("3/1/21", "07. Спальня - выход Д СО - порог 2", None, None), + "1/0/0": ga("1/0/0", "Kitchen ceiling light switch", 1, 1), +}} +_pD = build_loaded_from_raw(_rawD, "mem") +assert _pD.gas["0/1/8"].intent == INTENT_RESERVE +assert _pD.gas["1/1/7"].intent == INTENT_LOGIC + +_dptD = detect_dpt_issues(_pD) +_codes = {(f["code"], f["address"]) for f in _dptD} +# reserve with no DPT -> INFO reserve_without_dpt, NOT a missing_dpt error +assert ("reserve_without_dpt", "0/1/8") in _codes, _codes +assert ("missing_dpt", "0/1/8") not in _codes, "reserve wrongly raised a DPT error" +# functional missing DPT still a real error +assert ("missing_dpt", "3/1/21") in _codes, "real missing DPT was suppressed!" +# "Резерв" reused with different DPTs is NOT an inconsistency +assert not any(f["code"] == "inconsistent_dpt" for f in _dptD), "reserve flagged inconsistent_dpt" + +_naflD = validate_naming(_pD) +# reserve dupes + scratch short name must not raise naming warnings +assert not any(f["code"] == "duplicate_name" for f in _naflD), "reserve flagged duplicate_name" +assert not any(f["code"] == "name_too_short" for f in _naflD), "scratch '1' flagged name_too_short" + +_msD = {f["address"] for f in detect_missing_status(_pD)} +assert "1/1/7" not in _msD, "logic GA wrongly flagged missing_status" +assert "8/0/18" not in _msD, "weather request wrongly flagged missing_status" +assert "9/2/0" not in _msD, "scratch GA wrongly flagged missing_status" +# the real functional switch with no status IS still flagged +assert "1/0/0" in _msD, "real functional command lost its missing-status warning!" +print("OK: reserve/logic/scratch de-noised; real DPT + status problems preserved")