diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8f4bb..6b8e74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and 14.031, and power factor has its own rule (14.057). Checked against xknx 3.20. On a real 1312-GA house: 17 findings -> 2. +- **Self-reporting commands get their state address.** When the actuator's status object (Read + + Transmit flags) is linked to the command GA itself, there is no separate status GA to find. The + generator now uses the command GA as `state_address` (and the brightness GA as + `brightness_state_address`) instead of reporting a missing status. `check_missing_status` already + treated these as satisfied; the two now agree. On a real house 43 fewer review items, 40 to 44 + entities per large project gain a state address, and no entity count changes. + ### Changed - **On/off lighting is generated as a Home Assistant `light`, not a `switch`.** The KNX light platform diff --git a/nickol_knx_mcp/generate_ha.py b/nickol_knx_mcp/generate_ha.py index 666c814..18536f7 100644 --- a/nickol_knx_mcp/generate_ha.py +++ b/nickol_knx_mcp/generate_ha.py @@ -15,7 +15,7 @@ import yaml from .project import LoadedProject, GARecord from .analyze import _is_status_ga -from .pairing import find_status, base_tokens, function_status_pairs +from .pairing import find_status, base_tokens, function_status_pairs, self_reporting # Venetian-blind slat (tilt) detection: a slat GA is the tilt of its parent # blind, not a standalone cover. @@ -440,12 +440,16 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: if st5: entity["brightness_state_address"] = st5.address consumed.add(st5.address) + elif self_reporting(ga, project): + entity["brightness_state_address"] = ga.address if sib is not None: entity["address"] = sib.address s1 = status_for_dpt(sib, 1) # on/off status (1.x) if s1: entity["state_address"] = s1.address consumed.add(s1.address) + elif self_reporting(sib, project): + entity["state_address"] = sib.address consumed.add(sib.address) attach_colour(entity, ga) # RGB/RGBW/xyY/colour-temp of this zone lights.append(entity) @@ -498,6 +502,11 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: if st: entity["state_address"] = st.address consumed.add(st.address) + elif self_reporting(ga, project): + # The actuator's status object (Read + Transmit) is linked to the command GA + # itself, so the command GA is its own state. detect_missing_status already + # treats these as satisfied; the generator now agrees. + entity["state_address"] = ga.address else: review.append({"reason": "light_without_status" if is_light else "switch_without_status", "address": ga.address, "name": ga.name}) diff --git a/tests/test_real_house_fixes.py b/tests/test_real_house_fixes.py index 99d75ab..5d4c05f 100644 --- a/tests/test_real_house_fixes.py +++ b/tests/test_real_house_fixes.py @@ -76,4 +76,34 @@ assert "1/0/1" in lightsB and lightsB["1/0/1"].get("state_address") == "1/1/1", assert not any(s["address"] == "1/0/1" for s in knxB.get("switch", [])), knxB print("OK: B — on/off lighting is a light with address + state_address, not a switch") +# ---------------------------------------------------------------- C. self-reporting status +def _proj_with_cos(rows, flags_by_addr): + gas, cos = {}, {} + for a, n, m, s in rows: + g = _ga(a, n, m, s) + if a in flags_by_addr: + cid = f"CO-{a}" + g["communication_object_ids"] = [cid] + cos[cid] = {"flags": flags_by_addr[a], "group_address_links": [a], "dpts": [], "text": "", "function_text": ""} + gas[a] = g + return build_loaded_from_raw({"group_addresses": gas, "communication_objects": cos, + "info": {"group_address_style": "ThreeLevel"}}, "mem") + +pC = _proj_with_cos( + [("1/0/5", "1.02 Hall - Ceiling light - on/off", 1, 1), + ("1/0/6", "1.03 Living - Floor lamp light - on/off", 1, 1), + ("5/0/5", "Workshop socket - on/off", 1, 1)], + {"1/0/5": {"write": True, "read": True, "transmit": True}, + "1/0/6": {"write": True, "read": False, "transmit": False}, + "5/0/5": {"write": True, "read": True, "transmit": True}}) +resC = generate_ha_yaml(pC) +knxC = yaml.safe_load(resC["yaml"].split("\n\n", 1)[1])["knx"] +ents = {e["address"]: e for plat in ("light", "switch") for e in knxC.get(plat, [])} +assert ents["1/0/5"].get("state_address") == "1/0/5", ents +assert ents["5/0/5"].get("state_address") == "5/0/5", ents +assert "state_address" not in ents["1/0/6"], ents +no_status = {r["address"] for r in resC["review"] if r["reason"] in ("light_without_status", "switch_without_status")} +assert no_status == {"1/0/6"}, no_status +print("OK: C — Read+Transmit object on the command GA is its own state; write-only stays in review") + print("\nALL REAL-HOUSE REGRESSION TESTS PASSED") diff --git a/tools/corpus_baseline.json b/tools/corpus_baseline.json index f62418c..1c6e19a 100644 --- a/tools/corpus_baseline.json +++ b/tools/corpus_baseline.json @@ -91,7 +91,7 @@ "sensor": 55, "switch": 42 }, - "ha_review_items": 413, + "ha_review_items": 401, "suggestion_hints": { "channels": 229, "diagnostics_skipped": 2, @@ -141,7 +141,7 @@ "sensor": 19, "switch": 3 }, - "ha_review_items": 69, + "ha_review_items": 68, "suggestion_hints": { "channels": 52, "diagnostics_skipped": 0, @@ -198,7 +198,7 @@ "sensor": 87, "switch": 48 }, - "ha_review_items": 234, + "ha_review_items": 202, "suggestion_hints": { "channels": 135, "diagnostics_skipped": 8, @@ -263,7 +263,7 @@ "sensor": 111, "switch": 79 }, - "ha_review_items": 326, + "ha_review_items": 286, "suggestion_hints": { "channels": 179, "diagnostics_skipped": 21, @@ -328,7 +328,7 @@ "sensor": 159, "switch": 95 }, - "ha_review_items": 598, + "ha_review_items": 555, "suggestion_hints": { "channels": 305, "diagnostics_skipped": 40, @@ -394,7 +394,7 @@ "sensor": 343, "switch": 319 }, - "ha_review_items": 1589, + "ha_review_items": 1553, "suggestion_hints": { "channels": 983, "diagnostics_skipped": 53,