diff --git a/CHANGELOG.md b/CHANGELOG.md index 6427a74..f6c99bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 flags the odd one out: `clear_outliers` (a strong majority with a small minority — e.g. one thermostat with a different setpoint/hysteresis, one presence detector with a different detection time) and `split_configs` (balanced 2+ variants — review, often two zones). - Parameter names resolved from the app-program. Read-only; no ETS/bus; encrypted projects are - skipped honestly. Validated on real 42–275-device projects; synthetic `tests/test_param_check.py`. + Parameter names resolved from the app-program. Findings are ranked by **significance**: a + `focus` list of *config-value* outliers (setpoint/hysteresis/time/threshold — where an odd device + is usually a real mistake), separated from mode/type flags and per-room text labels — on a real + 275-device project this turns 422 raw outliers into a 9-item focus (incl. the thermostats whose + init-setpoint differs from their siblings). Read-only; no ETS/bus; encrypted projects are skipped + honestly. Validated on real 42–275-device projects; synthetic `tests/test_param_check.py`. Community-driven (asked for in Discussions). Tool count 25 → 26. - **`skills/ha-git-backup`** — an ops-companion skill for the engineer package: a two-circuit diff --git a/nickol_knx_mcp/param_check.py b/nickol_knx_mcp/param_check.py index ab6eb52..b08dd84 100644 --- a/nickol_knx_mcp/param_check.py +++ b/nickol_knx_mcp/param_check.py @@ -28,6 +28,25 @@ from typing import Any, Optional _NUMERIC_RE = re.compile(r"^-?\d+$") +# Parameter-name hints for a meaningful numeric CONFIG VALUE — the kind of setting +# where an odd value is usually a real mistake (a different hysteresis / setpoint / +# timeout on one device). Multilingual (EN / DE / RU). Everything numeric but not a +# value is a "config_flag" (mode/type/enable); non-numeric is a "label" (often a +# deliberate per-room name, lowest priority). +_VALUE_HINTS = ( + "setpoint", "hyster", "temperatur", "threshold", "delay", "duration", "timeout", + "time", "period", "level", "speed", "offset", "value", "brightness", "scaling", + "уставк", "гистерез", "температур", "порог", "задержк", "время", "период", + "длительн", "уровень", "скорост", "яркост", "значени", +) + + +def _significance(name: str, numeric: bool) -> str: + if not numeric: + return "label" + low = (name or "").lower() + return "config_value" if any(h in low for h in _VALUE_HINTS) else "config_flag" + def _localname(tag: str) -> str: return tag.rsplit("}", 1)[-1] @@ -150,16 +169,26 @@ def check_device_parameters(path: str, password: Optional[str] = None, names = _resolve_names(zf, refids_needed) + _SIG_RANK = {"config_value": 0, "config_flag": 1, "label": 2} + def _decorate(rec: dict[str, Any]) -> dict[str, Any]: rec["parameter"] = names.get(rec["refid"], rec["refid"]) rec["name_resolved"] = rec["refid"] in names + rec["significance"] = _significance(rec["parameter"], rec["numeric"]) return rec - # numeric config outliers first (time/setpoint/hysteresis-like), then the rest + # most meaningful first: config_value (setpoint/hysteresis/time…) > flag > label clear = [_decorate(r) for r in clear] splits = [_decorate(r) for r in splits] - clear.sort(key=lambda r: (not r["numeric"], len(r["odd_devices"]), -r["total"])) - splits.sort(key=lambda r: (not r["numeric"], -r["total"])) + clear.sort(key=lambda r: (_SIG_RANK[r["significance"]], len(r["odd_devices"]), -r["total"])) + splits.sort(key=lambda r: (_SIG_RANK[r["significance"]], -r["total"])) + + def _by_sig(items: list[dict[str, Any]]) -> dict[str, int]: + c = Counter(r["significance"] for r in items) + return {k: c.get(k, 0) for k in ("config_value", "config_flag", "label")} + + # the short list an integrator should actually look at first + focus = [r for r in clear if r["significance"] == "config_value"] grp_sizes = sorted((len(v) for v in groups.values() if len(v) >= min_group), reverse=True) return { @@ -168,13 +197,18 @@ def check_device_parameters(path: str, password: Optional[str] = None, "largest_groups": grp_sizes[:8], "clear_outliers_count": len(clear), "split_configs_count": len(splits), + "focus_count": len(focus), + "focus": focus[:max_findings], + "clear_outliers_by_significance": _by_sig(clear), + "split_configs_by_significance": _by_sig(splits), "clear_outliers": clear[:max_findings], "split_configs": splits[:max_findings], "names_unresolved": sum(1 for r in (clear + splits) if not r["name_resolved"]), - "note": "clear_outliers = a device whose value differs from its N identical " - "siblings (likely a mistake). split_configs = the group splits into " - "balanced variants (review — often two zones/roles, not an error). " - "Numeric config parameters (times/setpoints/hysteresis) are listed first. " - "Read-only; parameter values come from P-*/0.xml (xknxproject does not " - "expose them). Some module-definition parameter names may stay as RefIds.", + "note": "START WITH `focus` — clear outliers on a config VALUE parameter " + "(setpoint/hysteresis/time/threshold…), where an odd device is usually a " + "real mistake. `clear_outliers` also includes config_flag (mode/type) and " + "label (per-room text, often intentional) — see *_by_significance. " + "split_configs = the group splits into balanced variants (review — often two " + "zones, not an error). Read-only; values come from P-*/0.xml (xknxproject does " + "not expose them). Some module-definition parameter names may stay as RefIds.", } diff --git a/tests/test_param_check.py b/tests/test_param_check.py index d87142c..5d091e1 100644 --- a/tests/test_param_check.py +++ b/tests/test_param_check.py @@ -75,6 +75,10 @@ def main(): assert h["majority_value"] == "5" assert [d["value"] for d in h["odd_devices"]] == ["10"], h["odd_devices"] assert h["odd_devices"][0]["address"] == "10" + # significance: "Hysteresis (K)" is a config VALUE -> shows up in focus + assert h["significance"] == "config_value", h["significance"] + assert r["focus_count"] >= 1 + assert any(o["refid"].endswith("R-1") for o in r["focus"]), r["focus"] # uniform param (mode, all "2") must NOT appear anywhere assert not any(o["refid"].endswith("R-2") for o in r["clear_outliers"] + r["split_configs"])