mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
fix(#12 residual): recognise a shutter Function by its 1.008 move, not only a role token
Kris1166's real-file re-test of f20b2e4: main bug gone (light:0, all 29 RM → status) but
15/29 position feedbacks stayed lighting/not_mapped. Root cause (reproduced 1:1): those
actuator-level Functions carry blank or raw-GUID roles — no role token anywhere — so the
shutter-Function gate skipped them even though they hold a 1.008 up/down move. The
anomaly (2/3/1 promoting while its step/stop stayed unknown) is explained: its move has a
role; its step/stop is DPT 1.009 'Enable' (the reporter's own modeling slip, not a step).
- A shutter Function is now recognised by a shutter ROLE token OR by an UNCONTESTED 1.008
move member (category==shutter; a 1.008 whose name pins another domain must not license
its neutral siblings — gate-1 audit).
- is_step includes DPT 1.017 (trigger), which the cover builder already treats as a stop.
Gate-1: APPROVED; 0 category/kind/platform drift across 5 real .knxproj (1965 GA); the new
fixtures fail on the old code. Gate-2: the LLM council already endorsed the sibling-DPT
(1.008 move) signal as the primary structural evidence — this implements it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f20b2e4126
commit
467772618e
@@ -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)
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
Reference in New Issue
Block a user