Polish shutter slats and diagnostics alarms (HA mapping)

- Venetian slat GAs (Lamelle/slat/tilt) attach to their parent blind cover as
  move_short_address instead of becoming standalone covers; unmatched slats are
  flagged shutter_slat_unattached for manual attachment.
- 1-bit diagnostics GAs (wind/frost/smoke/leak alarm, fault) become a read-only
  binary_sensor instead of a phantom command switch.
- Add regression tests for both.

Backlog item #6 from VALIDATION-FINDINGS, surfaced by real ETS fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay Miroshnichenko
2026-06-28 10:49:24 +02:00
co-authored by Claude Opus 4.8
parent 53e5630d45
commit 1fc64a7a89
4 changed files with 71 additions and 2 deletions
+6
View File
@@ -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.
+38 -2
View File
@@ -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) "
+5
View File
@@ -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),
+22
View File
@@ -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")