diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index 93abd15..de99494 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -404,7 +404,9 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: def _promote_to_shutter(rec: Optional[GARecord], has_move: bool) -> None: if rec is None or rec.category == "shutter" or not has_move: return - is_step = rec.dpt_main == 1 and rec.dpt_sub in (7, 10) + # 1.017 (trigger) is what several actuator templates emit for step/stop; the + # cover builder already treats it as a stop, so promotion must agree. + is_step = rec.dpt_main == 1 and rec.dpt_sub in (7, 10, 17) is_pos_status = rec.dpt_main == 5 and rec.dpt_sub == 1 and rec.kind == "status" if not (is_step or is_pos_status): return @@ -421,8 +423,16 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: has_shutter_role = any( any(t in (ref.get("role") or "").lower() for t in _SHUTTER_ROLE_TOKENS) for ref in members.values()) - has_move = any(r is not None and r.dpt_main == 1 and r.dpt_sub == 8 for r in recs) - if not has_shutter_role: + # The licensing move must be an UNCONTESTED shutter move: a 1.008 whose name + # pins another domain classifies 'unknown' and must not license its neutral + # siblings (gate-1 audit) — positive evidence only. + has_move = any(r is not None and r.category == "shutter" + and r.dpt_main == 1 and r.dpt_sub == 8 for r in recs) + # A shutter Function is recognised by a shutter ROLE token OR by an up/down + # MOVE member (DPT 1.008 is shutter-specific). Roles are often blank or a raw + # GUID on actuator-level Functions, so role strings alone silently skipped + # 15/29 real position feedbacks (issue #12 re-test by Kris1166). + if not (has_shutter_role or has_move): continue for rec in recs: _promote_to_shutter(rec, has_move) diff --git a/tests/test_rm_status.py b/tests/test_rm_status.py index a30525b..436eff3 100644 --- a/tests/test_rm_status.py +++ b/tests/test_rm_status.py @@ -57,6 +57,26 @@ def _build(): gas["2/1/60"] = _ga("2/1/60", "Zone20 Rollladen Bewegen", 1, 8) gas["2/2/60"] = _ga("2/2/60", "Living Room Light On", 1, 1) # stray light in a shutter fn fns["FN-Mixed-60"] = _fn("Mixed", ("2/1/60", "MoveUpDown"), ("2/2/60", "")) + # RESIDUAL (Kris re-test 2026-09-04, 15/29): actuator-level Functions carry BLANK + # or raw-GUID roles — no role token anywhere — yet hold a 1.008 move. The RM + # must still promote: a shutter Function is recognised by its up/down MOVE, not + # only by a role string. Step/stop is 1.017 here (what the actuator emits). + gas["2/1/0"] = _ga("2/1/0", "Zone01 Fenster Bewegen", 1, 8) + gas["2/2/0"] = _ga("2/2/0", "Zone01 Fenster Schritt/Stop", 1, 17) + gas["2/3/0"] = _ga("2/3/0", "Zone01 Fenster RM", 5, 1) + fns["FN-guid-0"] = _fn("Fenster", ("2/1/0", "a1b2-guid"), ("2/2/0", "c3d4-guid"), + ("2/3/0", "")) + gas["2/1/6"] = _ga("2/1/6", "Zone02 kl. Fenster Bewegen", 1, 8) + gas["2/2/6"] = _ga("2/2/6", "Zone02 kl. Fenster Schritt/Stop", 1, 7) + gas["2/3/6"] = _ga("2/3/6", "Zone02 kl. Fenster RM", 5, 1) + fns["FN-blank-6"] = _fn("Fenster", ("2/1/6", ""), ("2/2/6", ""), ("2/3/6", "")) + # ANOMALY 2/3/1 (explained): the move HAS a role, the step/stop is DPT 1.009 + # ("Enable" — the reporter's own modeling slip, not a step DPT). The RM promotes + # via the move's role while the 1.009 step/stop honestly stays unknown. + gas["2/1/1"] = _ga("2/1/1", "Zone16 Fenster Bewegen", 1, 8) + gas["2/2/1"] = _ga("2/2/1", "Zone16 Fenster Schritt/Stop", 1, 9) + gas["2/3/1"] = _ga("2/3/1", "Zone16 Fenster RM", 5, 1) + fns["FN-anom-1"] = _fn("Fenster", ("2/1/1", "MoveUpDown"), ("2/2/1", ""), ("2/3/1", "")) # NEGATIVE 3 (LLM-council): a DPT-5.001 scene-recall COMMAND (blank role, neutral # name) sharing a shutter Function must NOT become a cover — it is not a position # STATUS, so promotion must skip it even though the Function is a shutter one. @@ -88,6 +108,15 @@ def main(): # negative 2: a stray light inside a heterogeneous shutter function stays lighting assert p.gas["2/2/60"].category == "lighting", \ f"stray light in a shutter fn was over-promoted: {p.gas['2/2/60'].category}" + # residual: blank / GUID-role Functions still promote their RM via the 1.008 move, + # and the 1.017 step/stop promotes too (the cover builder treats 1.017 as a stop). + for a in ("2/3/0", "2/3/6"): + assert p.gas[a].category == "shutter" and p.gas[a].kind == "status", \ + f"{a}: blank/GUID-role Function RM not promoted: {p.gas[a].category}/{p.gas[a].kind}" + assert p.gas["2/2/0"].category == "shutter", "1.017 step/stop in a blank-role fn must promote" + # anomaly: RM promotes via the move's role; the 1.009 (Enable) step/stop stays unknown. + assert p.gas["2/3/1"].category == "shutter", "anomaly RM 2/3/1 must promote" + assert p.gas["2/2/1"].category != "shutter", "a 1.009 'Enable' is not a step DPT — stays as-is" # negative 3: a 5.001 scene-recall COMMAND in a shutter function is NOT promoted # (only a 5.001 position STATUS is), while the step/stop 2/2/70 still is. assert p.gas["2/4/70"].category != "shutter", \ @@ -101,6 +130,10 @@ def main(): # 2. Each cover gets its RM GA as position_state — and the RM GA is NOT a light. assert covers["2/1/26"].get("position_state_address") == "2/3/26", covers["2/1/26"] assert covers["2/1/50"].get("position_state_address") == "2/3/50", covers["2/1/50"] + # residual: blank / GUID-role covers get their RM as position_state too (end-to-end) + assert covers["2/1/0"].get("position_state_address") == "2/3/0", covers["2/1/0"] + assert covers["2/1/6"].get("position_state_address") == "2/3/6", covers["2/1/6"] + assert covers["2/1/1"].get("position_state_address") == "2/3/1", covers["2/1/1"] light_addrs = {a for l in lights.values() for a in (l.get("address"), l.get("state_address"), l.get("brightness_address"), l.get("brightness_state_address"))}