generate_ha: on/off lighting becomes a light; brightness-only "lights" go to review instead of invalid YAML

Found on a real 1312-GA house, measured on all seven corpus projects.

B. Lighting on/off (1.001, lighting category) is emitted as a Home Assistant light with
   address + state_address, not a switch. The HA KNX light platform documents exactly
   that ("Simple light"). suggest.py already proposed light for these; the two engines
   disagreed. Without status it is reported as light_without_status.

D. A 5.001 lighting GA with no on/off GA in its zone produced a light with only
   brightness_address. Home Assistant requires address on a KNX light, so that YAML was
   invalid. 51 of them across six projects, mostly motion-detector parameters on 5.001.
   They now go to review as light_without_switch, and the brightness status is no longer
   consumed for an entity that does not exist, so it surfaces as not_mapped.

Corpus, before -> after: lights without address 51 -> 0 everywhere; no address lost from
YAML or review on any project; switch -> light moves of 220 (house), 205, 62, 43, 17, 10,
13. test_pipeline B1 fixture gains its on/off GAs (it encoded an invalid light) plus a
test for the review route. CHANGELOG also records fix A from 481bab0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nikolay Miroshnichenko
2026-09-16 18:32:42 +02:00
co-authored by Claude Opus 5
parent 481bab0e0c
commit cb226a008f
5 changed files with 100 additions and 28 deletions
+17 -2
View File
@@ -398,21 +398,36 @@ assert _e["color_temperature_mode"] == "absolute", _e
print("OK: RGBW + colour-temp assembled into one light entity")
# -- B1: a light with no own brightness status must NOT borrow a sibling's -----
# (both lights carry their on/off GA: Home Assistant requires `address` on a light,
# so a brightness-only light is invalid and goes to review — checked just below)
_rawB = {"group_addresses": {
"1/0/11": ga("1/0/11", "Kitchen worktop LED on/off", 1, 1),
"1/2/11": ga("1/2/11", "Kitchen worktop LED brightness", 5, 1), # no own status
"1/0/12": ga("1/0/12", "Kitchen island pendants on/off", 1, 1),
"1/2/12": ga("1/2/12", "Kitchen island pendants brightness", 5, 1),
"1/5/12": ga("1/5/12", "Kitchen island pendants brightness status", 5, 1),
}}
_pB = build_loaded_from_raw(_rawB, "mem")
_lB = _yaml.safe_load(generate_ha_yaml(_pB)["yaml"].split("\n\n", 1)[1])["knx"]["light"]
_byname = {l["name"]: l for l in _lB}
_worktop = _byname["Kitchen worktop LED brightness"]
_island = _byname["Kitchen island pendants brightness"]
_worktop = _byname["Kitchen worktop LED"]
_island = _byname["Kitchen island pendants"]
assert "brightness_state_address" not in _worktop, \
f"B1 regression: worktop borrowed a status: {_worktop}"
assert _island.get("brightness_state_address") == "1/5/12", _island
assert all("address" in l for l in _lB), _lB
print("OK: B1 — worktop took no status; island kept its own (no cross-borrow)")
# -- a brightness GA with no on/off GA is not a valid HA light -> review ------
_rawD = {"group_addresses": {
"1/2/20": ga("1/2/20", "Kitchen worktop LED brightness", 5, 1),
}}
_haD = generate_ha_yaml(build_loaded_from_raw(_rawD, "mem"))
_pkgD = _yaml.safe_load(_haD["yaml"].split("\n\n", 1)[1]) or {}
assert not (_pkgD.get("knx") or {}).get("light"), _pkgD
assert any(r["reason"] == "light_without_switch" and r["address"] == "1/2/20" for r in _haD["review"]), _haD["review"]
print("OK: brightness-only GA -> review light_without_switch, no invalid light in YAML")
# -- A-climate: thermostat zone assembles; a mode-only zone goes to review -----
_rawT = {"group_addresses": {
# full zone -> valid climate
+18
View File
@@ -9,6 +9,9 @@ A. subdpt_suspect false positives
"electricity" name. (Checked against xknx 3.20.)
- real findings survive: a bare DPT 9 named "lux threshold", a 5.x named
"temperature control".
B. on/off lighting (1.001, lighting category) is emitted as a Home Assistant light
(address + state_address), not a switch. A brightness-only light, which HA would
reject for lacking `address`, goes to review (tested in test_pipeline.py).
"""
import os
import sys
@@ -58,4 +61,19 @@ missed = [a for a, *_ in real_findings if a not in flagged]
assert not missed, f"real findings no longer flagged: {missed}"
print(f"OK: A — {len(false_positives)} false positives silent, {len(real_findings)} real findings still flagged")
# ---------------------------------------------------------------- B. on/off lighting -> light
import yaml
from nickol_knx_mcp.generate_ha import generate_ha_yaml
pB = _project([
("1/0/1", "1.05 Bedroom - Ceiling - on/off", 1, 1),
("1/1/1", "1.05 Bedroom - Ceiling - on/off status", 1, 11),
("5/0/1", "Garden pump - on/off", 1, 1),
])
knxB = yaml.safe_load(generate_ha_yaml(pB)["yaml"].split("\n\n", 1)[1])["knx"]
lightsB = {l["address"]: l for l in knxB.get("light", [])}
assert "1/0/1" in lightsB and lightsB["1/0/1"].get("state_address") == "1/1/1", knxB
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")
print("\nALL REAL-HOUSE REGRESSION TESTS PASSED")