mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
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:
co-authored by
Claude Opus 4.8
parent
53e5630d45
commit
1fc64a7a89
@@ -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.
|
||||
|
||||
@@ -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) "
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user