Files
nickol-knx-mcp/nickol_knx_mcp/pairing.py
T
Nikolay MiroshnichenkoandClaude Opus 4.8 29d39dc147 Wire up ETS Function role pairing for command/status
Implement pairing.function_status_pairs(): pair command<->status GAs from ETS
Function roles (SwitchOnOff<->InfoOnOff etc.), used by check_missing_status and
the HA generator. Fixes false "missing status" on function-paired commands and
gives HA entities the correct state_address even when the feedback GA is named
only "Status". Add a regression test.

Found via real-ETS6 fixture testing: Functions were previously ignored despite
the README claiming they were the primary pairing signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:35:34 +02:00

123 lines
4.5 KiB
Python

"""Name-token based command/status pairing.
In real KNX projects the status/feedback GA almost never sits in the same middle
group as its command (commands in .../0/..., feedback in .../4/...). So pairing
must be driven by *name similarity within the same main group*, not by address
proximity. This module centralizes that logic so analysis and HA generation agree.
"""
from __future__ import annotations
from typing import Iterable, Optional
from .project import GARecord, STATUS_KEYWORDS, COMMAND_KEYWORDS
_STOP = set(STATUS_KEYWORDS) | set(COMMAND_KEYWORDS) | {
"the", "of", "and", "und", "der", "die", "das", "и", "в", "на",
"rm", "fb",
}
# command DPT main -> acceptable status DPT mains
STATUS_COMPAT = {
1: {1, 5}, # switch/up-down -> 1.x state or 5.x position feedback
3: {1, 5}, # relative dim -> on/off or brightness feedback
5: {5}, # scaling setpoint -> scaling feedback
9: {9}, # float setpoint -> float feedback
}
def base_tokens(name: str) -> set[str]:
low = name.lower()
for ch in "/-_.,()[]:":
low = low.replace(ch, " ")
return {t for t in low.split() if t and t not in _STOP and len(t) > 1}
def find_status(command: GARecord, candidates: Iterable[GARecord]) -> Optional[GARecord]:
"""Return the best status GA matching `command`, or None.
A candidate matches when (a) token overlap with the command base name is at
least min(2, len(command tokens)), and (b) category matches OR DPT main is
compatible. Same-main-group candidates are preferred.
"""
cmd_tokens = base_tokens(command.name)
if not cmd_tokens:
return None
need = min(2, len(cmd_tokens))
compat = STATUS_COMPAT.get(command.dpt_main or -1, {command.dpt_main})
best: Optional[GARecord] = None
best_score = -1
for c in candidates:
if c.address == command.address:
continue
inter = cmd_tokens & base_tokens(c.name)
if len(inter) < need:
continue
if c.category != command.category and (c.dpt_main not in compat):
continue
score = len(inter) + (2 if c.main == command.main else 0)
if score > best_score:
best, best_score = c, score
return best
# --------------------------------------------------------------------------- #
# Authoritative pairing from ETS Functions (roles)
# --------------------------------------------------------------------------- #
_FN_STATUS_TOKENS = ("info", "status", "state", "feedback", "rueck", "rück")
_FN_STATUS_PREFIXES = ("Info", "Status", "State", "Feedback")
def _role_is_status(role: str) -> bool:
r = (role or "").lower()
return any(t in r for t in _FN_STATUS_TOKENS)
def _status_suffix(role: str) -> str:
"""'InfoOnOff' -> 'onoff', 'StatusValue' -> 'value' (strip status prefix)."""
r = role or ""
for p in _FN_STATUS_PREFIXES:
if r.startswith(p):
r = r[len(p):]
break
return r.lower()
def function_status_pairs(project) -> dict[str, str]:
"""Map command-GA address -> status-GA address using ETS Function roles.
This is the authoritative signal: ETS Functions group the GAs of one logical
function and tag each with a role (e.g. ``SwitchOnOff`` for the command and
``InfoOnOff`` for the feedback). Within each function we pair every command
role to a status role, preferring a matching role suffix
(``InfoOnOff`` <-> ``SwitchOnOff``) and otherwise falling back to the single
status GA in that function. Names are never needed, so a feedback GA called
just "Status" still pairs correctly.
"""
pairs: dict[str, str] = {}
for fn in (project.functions or {}).values():
roles = fn.get("group_addresses", {}) or {}
cmds: list[tuple[str, str]] = []
stats: list[tuple[str, str]] = []
for key, ref in roles.items():
addr = ref.get("address", key)
role = ref.get("role") or ""
(stats if _role_is_status(role) else cmds).append((addr, role))
valid_stats = [(a, r) for a, r in stats if a in project.gas]
if not cmds or not valid_stats:
continue
for caddr, crole in cmds:
if caddr not in project.gas:
continue
partner = None
for saddr, srole in valid_stats:
suf = _status_suffix(srole)
if suf and suf in crole.lower():
partner = saddr
break
if partner is None:
partner = valid_stats[0][0]
pairs[caddr] = partner
return pairs