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 <noreply@anthropic.com>
This commit is contained in:
Nikolay Miroshnichenko
2026-07-01 13:55:37 +02:00
co-authored by Claude Opus 4.8
parent bc33273cd4
commit 87f065dde7
4 changed files with 128 additions and 6 deletions
+42 -6
View File
@@ -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 "
+15
View File
@@ -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