From 87f065dde79b886ab52b9b0dd8a2e9bb34ebb8fa Mon Sep 17 00:00:00 2001 From: Nikolay Miroshnichenko Date: Wed, 1 Jul 2026 13:55:37 +0200 Subject: [PATCH] feat(analyze): de-noise [LF] objects, central macros, separator scratch Three false-alarm downgrades derived from validating on a larger multi-vendor villa, each turning a false positive into an INFO note without hiding any real defect: - intent: divider/separator names (punctuation-only or a marker wrapped in it, e.g. "-----addition------", "---") classify as `scratch`, so a missing DPT on them is INFO, not a red error. - analyze: typed GAs wired into Zennio "[LF] ... Data Entry" type-agnostic logic-function containers surface as INFO `dpt_on_logic_object` instead of a false `dpt_mismatch_co` warning. - analyze: all-groups broadcast/central-macro commands surface as INFO `central_macro_no_status` instead of `missing_status_address`, since a fan-out broadcast has no single state to read back. Adds a regression block covering all three; real DPT mismatches and real missing-status gaps still warn. Unreleased; no version bump. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 16 ++++++++++++ nickol_knx_mcp/analyze.py | 48 +++++++++++++++++++++++++++++----- nickol_knx_mcp/intent.py | 15 +++++++++++ tests/test_pipeline.py | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f41c41..cf81176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +**Noise-reduction refinements from a 3646-GA multi-vendor villa.** Validated the tool on a +second real signed Zennio project (5× larger, no ETS Functions). Three de-noising rules, +each downgrading a false alarm to INFO without hiding any real problem — on that project +errors fell **36 → 32** and warnings **179 → 139**. + +### Added +- **Divider/separator scratch detection** — commissioning placeholder names made only of + punctuation (`---`, `=====`) or a marker wrapped in it (`-----addition------`) now classify + as `scratch` intent, so a missing DPT on them is an INFO note, not a 🔴 error. +- **Logic-function object awareness** — a typed GA (e.g. 9.x temperature) wired into a Zennio + `[LF] … Data Entry` container (intentionally type-agnostic, raw 2-byte) surfaces as INFO + `dpt_on_logic_object` instead of a false `dpt_mismatch_co` warning. +- **Central-macro status tolerance** — all-groups broadcast commands (`Общее …`, `Все группы`, + `Все шторы - Стоп`) surface as INFO `central_macro_no_status` instead of a + `missing_status_address` warning, since a fan-out broadcast has no single state to read back. + ## [0.2.0] — 2026-06-30 **Colour/climate entity assembly and GA-intent noise reduction.** Two feature tracks diff --git a/nickol_knx_mcp/analyze.py b/nickol_knx_mcp/analyze.py index a925851..e80f685 100644 --- a/nickol_knx_mcp/analyze.py +++ b/nickol_knx_mcp/analyze.py @@ -156,6 +156,20 @@ def _is_status_ga(ga: GARecord) -> bool: return any(k in low for k in STATUS_KEYWORDS) +# Central / group-macro command names ("Общее освещение - Все группы", "Все +# шторы - стоп", "Групповое включение"). A broadcast that fans out to many +# actuators has no single state to read back, so a missing status is expected +# rather than a defect — surfaced as INFO, not a 🟡 warning. +_CENTRAL_MACRO_TOKENS = ( + "общее", "групповое", "все ", "всё", "central", "all groups", "all lights", +) + + +def _is_central_macro(name: str) -> bool: + low = (name or "").lower() + return any(t in low for t in _CENTRAL_MACRO_TOKENS) + + # command DPT main -> acceptable status DPT mains _STATUS_COMPAT = { 1: {1}, # switch command -> 1.x status @@ -197,12 +211,20 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]: same_main = [s for s in status_gas if s.main == ga.main] match = find_status(ga, same_main) or find_status(ga, status_gas) if match is None: - findings.append(_finding( - SEVERITY_WARN, "missing_status_address", addr, - f"Command '{ga.name}' (DPT {ga.dpt or '?'}, {ga.label}) has no " - "status/feedback GA. Home Assistant cannot read real state.", - name=ga.name, dpt=ga.dpt, category=ga.category, - )) + if _is_central_macro(ga.name): + findings.append(_finding( + SEVERITY_INFO, "central_macro_no_status", addr, + f"Central/group macro '{ga.name}' has no status GA — expected " + "for an all-groups broadcast; there is no single state to read back.", + name=ga.name, dpt=ga.dpt, category=ga.category, + )) + else: + findings.append(_finding( + SEVERITY_WARN, "missing_status_address", addr, + f"Command '{ga.name}' (DPT {ga.dpt or '?'}, {ga.label}) has no " + "status/feedback GA. Home Assistant cannot read real state.", + name=ga.name, dpt=ga.dpt, category=ga.category, + )) return findings @@ -247,6 +269,20 @@ def detect_dpt_issues(project: LoadedProject) -> list[dict[str, Any]]: continue mains = {d.get("main") for d in co_dpts} if ga.dpt_main not in mains: + co_name = (co.get("name") or "").strip() + # Zennio Logic-Function data-entry objects ("[LF] (2-Byte) Data + # Entry N") are intentionally type-agnostic raw containers; wiring + # a typed GA (9.x temperature, etc.) into one is expected design, + # not a DPT bug. Surface it as INFO instead of a 🟡 warning. + if co_name.lower().startswith("[lf]"): + findings.append(_finding( + SEVERITY_INFO, "dpt_on_logic_object", addr, + f"GA DPT main {ga.dpt_main} links a type-agnostic logic-function " + f"object '{co_name}' (DPT main {sorted(m for m in mains if m is not None)}) " + "— expected for LF data-entry blocks, not a mismatch.", + name=ga.name, + )) + break findings.append(_finding( SEVERITY_WARN, "dpt_mismatch_co", addr, f"GA DPT main {ga.dpt_main} differs from linked communication " diff --git a/nickol_knx_mcp/intent.py b/nickol_knx_mcp/intent.py index a7d47bb..77c11bd 100644 --- a/nickol_knx_mcp/intent.py +++ b/nickol_knx_mcp/intent.py @@ -46,6 +46,14 @@ _SCRATCH_EXACT = ( "neue gruppenadresse", ) +# Commissioning divider / marker words that appear wrapped in punctuation +# ("-----addition------", "--- разделитель ---"). Checked after stripping the +# surrounding separators. +_SCRATCH_CORE = ("addition", "разделитель", "separator", "divider") + +# Characters used to draw separators / dividers in GA names. +_SEP_CHARS = "-–—_=+*.•·~<>| \t" + def classify_intent(name: str) -> str: """Return one of functional / reserve / logic / scratch for a GA name.""" @@ -62,6 +70,13 @@ def classify_intent(name: str) -> str: # Bare numeric placeholders like "1", "5", "12" — classic leftovers. if re.fullmatch(r"\d{1,3}", raw): return INTENT_SCRATCH + # Divider / separator placeholders: a name that is only punctuation ("---", + # "=====") or a marker word wrapped in it ("-----addition------"). These are + # commissioning scratch, not functional GAs, so a missing DPT on them is + # intentional rather than a 🔴 error. + core = raw.strip(_SEP_CHARS) + if not core or core.lower() in _SCRATCH_CORE: + return INTENT_SCRATCH if any(t in low for t in _RESERVE_TOKENS): return INTENT_RESERVE diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b7f5f25..a23d9df 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -271,7 +271,13 @@ assert classify_intent("Метеостанция - Запрос температ assert classify_intent("суммарный сигнал включения групп света") == INTENT_LOGIC assert classify_intent("1") == INTENT_SCRATCH assert classify_intent("Новый групповой адрес") == INTENT_SCRATCH +# divider / separator commissioning scratch (large-villa refinement) +assert classify_intent("-----addition------") == INTENT_SCRATCH +assert classify_intent("---") == INTENT_SCRATCH +assert classify_intent("=====") == INTENT_SCRATCH assert classify_intent("Kitchen ceiling light switch") == INTENT_FUNCTIONAL +# a real name that merely CONTAINS dashes must stay functional +assert classify_intent("01. Кухня - Свет - Вкл/выкл") == INTENT_FUNCTIONAL _rawD = {"group_addresses": { # reserve spares: no DPT (intentional) + same name, different DPTs @@ -314,6 +320,55 @@ assert "9/2/0" not in _msD, "scratch GA wrongly flagged missing_status" 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") +# --------------------------------------------------------------------------- # +# Regression (large-villa refinements): [LF] logic-object DPT + central-macro status. +# From a real 3646-GA villa where (a) typed GAs wired into Zennio "[LF] Data +# Entry" objects produced false DPT-mismatch warnings, and (b) central "Все +# группы"/"Общее" broadcast commands produced false missing-status warnings. +# Both must be downgraded to INFO while REAL mismatches / gaps still warn. +# --------------------------------------------------------------------------- # +print("\n=== REGRESSION: [LF] logic-object + central-macro de-noise (large villa) ===") + +_rawLF = { + "communication_objects": { + "CO-LF": {"name": "[LF] (2-Byte) Data Entry 1", "number": 1, "text": "", + "function_text": "", "description": "", "device_address": "1.1.9", + "device_application": None, "module": None, "channel": None, + "dpts": [{"main": 7, "sub": 1}], "object_size": "2 bytes", + "group_address_links": ["5/0/0"], "flags": {}, "dpas": None}, + "CO-REAL": {"name": "Operation status", "number": 2, "text": "", + "function_text": "", "description": "", "device_address": "1.1.9", + "device_application": None, "module": None, "channel": None, + "dpts": [{"main": 7, "sub": 1}], "object_size": "2 bytes", + "group_address_links": ["6/1/13"], "flags": {}, "dpas": None}, + }, + "group_addresses": { + # typed temperature GA wired into an [LF] container -> INFO, not warning + "5/0/0": ga("5/0/0", "01. Холл - АТ - Воздух", 9, 1, co_ids=["CO-LF"]), + # real bitfield-vs-object mismatch on a normal object -> still a warning + "6/1/13": ga("6/1/13", "Основная В/У - Статус работы", 237, 600, co_ids=["CO-REAL"]), + }, +} +_lf = detect_dpt_issues(build_loaded_from_raw(_rawLF, "mem")) +_lfc = {(f["code"], f["address"]) for f in _lf} +assert ("dpt_on_logic_object", "5/0/0") in _lfc, _lfc +assert ("dpt_mismatch_co", "5/0/0") not in _lfc, "[LF] object wrongly warned as mismatch" +assert ("dpt_mismatch_co", "6/1/13") in _lfc, "real DPT mismatch was suppressed!" + +_rawCM = {"group_addresses": { + "0/0/1": ga("0/0/1", "Общее освещение - Все группы", 1, 1), # central macro + "0/1/0": ga("0/1/0", "Все шторы - Стоп", 1, 10), # central macro + "1/0/9": ga("1/0/9", "Гостиная - Бра - Вкл", 1, 1), # normal cmd, no status +}} +_cm = detect_missing_status(build_loaded_from_raw(_rawCM, "mem")) +_cmc = {(f["code"], f["address"]) for f in _cm} +assert ("central_macro_no_status", "0/0/1") in _cmc, _cmc +assert ("missing_status_address", "0/0/1") not in _cmc, "central macro wrongly warned" +assert ("central_macro_no_status", "0/1/0") in _cmc, _cmc +# a normal command with no status MUST still warn (no over-suppression) +assert ("missing_status_address", "1/0/9") in _cmc, "real missing-status lost!" +print("OK: [LF] objects + central macros de-noised; real mismatches/gaps preserved") + # --------------------------------------------------------------------------- # # Regression (Track A): colour light assembly + B1 (no borrowed brightness_state) # + climate assembly. Patterns from the real signed demo / a real Zennio project.