diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c7fd3..892e5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Venetian-blind slats are handled as the blind's tilt: a slat GA (Lamelle / slat / + ламель / tilt) is attached to its parent blind cover as `move_short_address` instead of + becoming a standalone cover; an unmatched slat is flagged `shutter_slat_unattached` for + manual attachment rather than emitted wrong. +- A 1-bit diagnostics GA (wind/frost/rain/smoke/leak alarm, fault) is now a read-only + `binary_sensor` instead of a phantom command switch. - HA generation never drops a group address silently ("no silent caps"): every GA is either emitted as an entity or listed in the `review` output, and the YAML header states how many need manual review. diff --git a/nickol_knx_mcp/generate_ha.py b/nickol_knx_mcp/generate_ha.py index 1ba5bbe..6de19da 100644 --- a/nickol_knx_mcp/generate_ha.py +++ b/nickol_knx_mcp/generate_ha.py @@ -17,11 +17,34 @@ from .project import LoadedProject, GARecord from .analyze import _is_status_ga from .pairing import find_status, base_tokens, function_status_pairs +# Venetian-blind slat (tilt) detection: a slat GA is the tilt of its parent +# blind, not a standalone cover. +_SLAT_WORDS = ("lamelle", "lamel", "ламел", "slat", "louver", "louvre", "tilt") +_DIR_WORDS = {"auf", "ab", "up", "down", "hoch", "runter", "move", "step", + "long", "short", "open", "close", "вверх", "вниз"} +_BLIND_GENERIC = {"behang", "blind", "blinds", "shutter", "jalousie", "rollo", + "roller", "store", "markise", "roll", "cover", "curtain", + "штора", "жалюзи", "ролета", "ролл"} + + +def _is_slat(name: str) -> bool: + low = name.lower() + return any(w in low for w in _SLAT_WORDS) + + +def _ident_tokens(name: str) -> set: + """Identity/zone tokens for matching a slat to its blind (drop direction, + slat and generic-blind words so only the zone/name identity remains).""" + return {t for t in base_tokens(name) + if t not in _DIR_WORDS and t not in _SLAT_WORDS and t not in _BLIND_GENERIC} + def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: """Return {'yaml': str, 'review': [...], 'counts': {...}}.""" status_gas = [g for g in project.gas.values() if _is_status_ga(g)] fpairs = function_status_pairs(project) # authoritative cmd-addr -> status-addr + slat_addrs = {g.address for g in project.gas.values() + if g.category == "shutter" and _is_slat(g.name)} def status_for(cmd: GARecord): # 1. ETS Function role pairing is authoritative (names not needed). @@ -47,7 +70,8 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: for ga in project.gas.values(): if ga.address in consumed: continue - if ga.category == "shutter" and ga.dpt_main == 1 and ga.dpt_sub == 8: + if ga.category == "shutter" and ga.dpt_main == 1 and ga.dpt_sub == 8 \ + and ga.address not in slat_addrs: entity = {"name": ga.name, "move_long_address": ga.address} for sib in same_main_gas(ga): if sib.address in consumed or sib.address == ga.address: @@ -57,6 +81,12 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: if sib.dpt_main == 1 and sib.dpt_sub == 10: entity["move_short_address"] = sib.address consumed.add(sib.address) + elif sib.address in slat_addrs and sib.dpt_main == 1 \ + and "move_short_address" not in entity \ + and (_ident_tokens(ga.name) & _ident_tokens(sib.name)): + # venetian slat (tilt) = the short-move/step of this blind + entity["move_short_address"] = sib.address + consumed.add(sib.address) elif sib.dpt_main == 5 and sib.kind == "command": entity["position_address"] = sib.address consumed.add(sib.address) @@ -148,10 +178,16 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: "address": addr, "name": ga.name, "dpt": ga.dpt, "category": ga.category, "kind": ga.kind, } + # A slat/tilt GA that wasn't matched to a blind cover by name. + if ga.address in slat_addrs: + item["reason"] = "shutter_slat_unattached" + item["hint"] = ("Slat/tilt control of a venetian blind; could not be matched " + "to a blind cover by name. Attach it manually as the cover's " + "tilt/move_short address.") # A shutter-looking command that didn't become a cover almost always has # an incomplete DPT (e.g. bare "1" instead of 1.008 up/down). Make that # actionable instead of an opaque drop. - if ga.category == "shutter" and ga.kind == "command" and ga.dpt_sub is None: + elif ga.category == "shutter" and ga.kind == "command" and ga.dpt_sub is None: item["reason"] = "shutter_incomplete_dpt" item["hint"] = ("Looks like a shutter control but the DPT has no sub-type. " "Set it in ETS (1.008 up/down, 1.010 stop, 5.001 position) " diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index c458b99..f384011 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -183,6 +183,11 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: # correct the HA platform so the generator builds a cover, not a light. if category == "shutter" and ha_platform in ("light", "switch"): ha_platform = "cover" + # A 1-bit diagnostics GA (wind/frost/rain/smoke/leak alarm, fault) is a + # read-only input, not a command switch -> binary_sensor. + if category == "diagnostics" and main == 1 and ha_platform == "switch": + ha_platform = "binary_sensor" + kind = "sensor" m, mid, s = _split_three_level(ga.get("address", addr)) rec = GARecord( address=ga.get("address", addr), diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b74d3e0..8b67bdb 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -198,3 +198,25 @@ for _name in ("Behang D auf/ab", "Lamelle B auf/ab"): _cat = _refine_category(_name, "unknown") assert _cat == "shutter", f"{_name!r} -> {_cat}, expected shutter" print("OK: 'Behang/Lamelle auf/ab' classified as shutter") + +# --------------------------------------------------------------------------- # +# Regression (#6): a venetian slat becomes the tilt (move_short) of its blind, +# not a standalone cover; a 1-bit diagnostics alarm is a binary_sensor, not a +# phantom command switch. +# --------------------------------------------------------------------------- # +print("\n=== REGRESSION: slat tilt + diagnostics alarm ===") +from nickol_knx_mcp.project import build_loaded_from_raw +_raw6 = {"group_addresses": { + "2/0/0": ga("2/0/0", "Living room blind", 1, 8), + "2/0/1": ga("2/0/1", "Living room blind slat", 1, 8), + "2/0/2": ga("2/0/2", "Wind alarm", 1, 1), +}} +_p6 = build_loaded_from_raw(_raw6, "mem") +assert _p6.gas["2/0/2"].ha_platform == "binary_sensor", _p6.gas["2/0/2"].ha_platform +assert _p6.gas["2/0/2"].kind == "sensor", _p6.gas["2/0/2"].kind +_pkg6 = _yaml.safe_load(generate_ha_yaml(_p6)["yaml"].split("\n\n", 1)[1]) +_covers6 = _pkg6["knx"].get("cover", []) +assert len(_covers6) == 1, f"expected 1 cover (slat not standalone), got {len(_covers6)}" +assert _covers6[0].get("move_short_address") == "2/0/1", _covers6[0] +assert _pkg6["knx"].get("binary_sensor"), "wind alarm should be a binary_sensor" +print("OK: slat -> blind tilt; wind alarm -> binary_sensor")