Files
Nikolay MiroshnichenkoandClaude Opus 5 ec63bcf106 load_ga_export, entity naming, climate setpoint shift; fix suggest_repairs crash; CI runs every test
Three things found by auditing a TapPlan export, plus one regression found on the way.

load_ga_export(path): new tool. Reads an ETS ga-export/01 XML (ETS "Export Group
Addresses", or the import file a planning tool writes) into a project without
devices, through safe_fromstring, capped at 50 MB and 8 levels of range nesting.
Invalid or duplicate addresses and unknown DPT tokens go to import_warnings, never
silently. Round trip with our own generate_ets_xml is covered by a test.

Entity naming: lights, covers and climates are named after the common word prefix
of their member names, cutting only function words. The first version of the rule
turned "01. <room> - All Blinds - Move" into "01" on a real project, so anything not
in the function vocabulary now stays. On six real projects 214 of 1189 entities got
a shorter name, each rename reviewed, no address mapping changed. Regenerated
packages show different names; noted in the changelog.

Setpoint shift: 9.002 / 6.010 with a shift word maps to setpoint_shift_address,
setpoint_shift_state_address and setpoint_shift_mode (keys checked against the HA
KNX climate docs). It used to become a plain sensor.

Fixed: suggest_repairs raised UnboundLocalError on any project with a missing status
GA. My local rename in the 12.09 typing cleanup left two references to the old name.
test_council_fixes covers it but was not in CI, so it shipped. CI now runs all 21
test files instead of a hand-picked 10; the 11 added ones pass from a clean clone.

Verified: all 21 tests, ruff, mypy with the package installed, corpus guard no drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:11:45 +02:00

194 lines
10 KiB
Python

"""GA-export intake, multi-address entity naming, climate setpoint shift.
Fixture mirrors a real TapPlan export (2026-09-13): ga-export/01 namespace, main 0
range starting at 1, DPST tokens, block templates for a dimmable light, a roller
and an RTC. Extended with a setpoint-shift status and the DPT/security variants an
ETS export can carry.
"""
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import yaml
from nickol_knx_mcp.ga_export import (GaExportError, load_ga_export, parse_dpt,
read_ga_export_bytes)
from nickol_knx_mcp.project import build_loaded_from_raw
from nickol_knx_mcp.generate_ha import generate_ha_yaml, _entity_name
from nickol_knx_mcp.generate_ets import generate_ets_xml
from nickol_knx_mcp.analyze import validate_naming, detect_topology_issues, detect_missing_status
XML = b'''<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<GroupAddress-Export xmlns="http://knx.org/xml/ga-export/01">
<GroupRange Name="Lighting" RangeStart="1" RangeEnd="2047">
<GroupRange Name="Ground Floor" RangeStart="1" RangeEnd="255">
<GroupAddress Name="Kitchen Spots On-Off" Address="0/0/6" DPTs="DPST-1-1" />
<GroupAddress Name="Kitchen Spots On-Off Feedback" Address="0/0/7" DPTs="DPST-1-11" />
<GroupAddress Name="Kitchen Spots Dimm" Address="0/0/8" DPTs="DPST-3-7" />
<GroupAddress Name="Kitchen Spots B.Value" Address="0/0/9" DPTs="DPST-5-1" />
<GroupAddress Name="Kitchen Spots B.Value Feedback" Address="0/0/10" DPTs="DPST-5-1" />
</GroupRange>
</GroupRange>
<GroupRange Name="Shading" RangeStart="2048" RangeEnd="4095">
<GroupRange Name="Ground Floor" RangeStart="2048" RangeEnd="2303">
<GroupAddress Name="Bedroom Blind Long Operation" Address="1/0/3" DPTs="DPST-1-8" />
<GroupAddress Name="Bedroom Blind Short Operation" Address="1/0/4" DPTs="DPST-1-7" />
<GroupAddress Name="Bedroom Blind Position" Address="1/0/5" DPTs="DPST-5-1" />
<GroupAddress Name="Bedroom Blind Position Feedback" Address="1/0/6" DPTs="DPST-5-1" />
</GroupRange>
</GroupRange>
<GroupRange Name="Climate" RangeStart="4096" RangeEnd="6143">
<GroupRange Name="First Floor" RangeStart="4096" RangeEnd="4351">
<GroupAddress Name="Bedroom Climate Setpoint" Address="2/0/2" DPTs="DPST-9-1" />
<GroupAddress Name="Bedroom Climate Setpoint Shift" Address="2/0/3" DPTs="DPST-9-2" />
<GroupAddress Name="Bedroom Climate Setpoint Status" Address="2/0/4" DPTs="DPST-9-1" />
<GroupAddress Name="Bedroom Climate Actual Temp" Address="2/0/5" DPTs="DPST-9-1" />
<GroupAddress Name="Bedroom Climate Mode" Address="2/0/6" DPTs="DPST-20-102" />
<GroupAddress Name="Bedroom Climate Mode Status" Address="2/0/7" DPTs="DPST-20-102" />
<GroupAddress Name="Bedroom Climate Setpoint Shift Status" Address="2/0/8" DPTs="DPST-9-2" />
<GroupAddress Name="Hall Secure Temp" Address="2/0/9" DPTs="DPT-9" Security="On" Description="door sensor" />
</GroupRange>
</GroupRange>
</GroupAddress-Export>
'''
def _load(data=XML):
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as fh:
fh.write(data)
path = fh.name
try:
return load_ga_export(path)
finally:
os.unlink(path)
def _knx(proj):
return yaml.safe_load(generate_ha_yaml(proj)["yaml"])["knx"]
# 1. Intake: every GA, DPTs, style, range names, security, description.
p = _load()
assert len(p.gas) == 17, len(p.gas)
assert p.style == "ThreeLevel", p.style
assert p.info["source"] == "ga-export" and p.info["import_warnings"] == []
assert (p.gas["0/0/7"].dpt_main, p.gas["0/0/7"].dpt_sub) == (1, 11)
assert (p.gas["2/0/9"].dpt_main, p.gas["2/0/9"].dpt_sub) == (9, None)
assert p.gas["2/0/9"].data_secure and p.gas["2/0/9"].description == "door sensor"
assert not p.gas["0/0/6"].data_secure
assert not p.devices and not p.functions
print("OK: intake — 17 GAs, ThreeLevel, DPST/DPT tokens, Security, Description")
# 2. Device-less project runs the GA-level checks without inventing device findings.
codes = {f["code"] for f in validate_naming(p)}
assert "ga_style_not_three_level" not in codes, codes
assert detect_topology_issues(p) == []
assert not [f for f in detect_missing_status(p) if f["address"] in ("0/0/9", "1/0/5")]
print("OK: GA-level checks run; no style or topology noise on a GA-only project")
# 3. Naming: entities are named after the shared part of their member names.
knx = _knx(p)
light = knx["light"][0]
cover = knx["cover"][0]
climate = knx["climate"][0]
assert light["name"] == "Kitchen Spots", light
assert (light["address"], light["state_address"], light["brightness_address"],
light["brightness_state_address"]) == ("0/0/6", "0/0/7", "0/0/9", "0/0/10"), light
assert cover["name"] == "Bedroom Blind", cover
assert climate["name"] == "Bedroom Climate", climate
print("OK: naming — 'Kitchen Spots', 'Bedroom Blind', 'Bedroom Climate'")
# 4. Setpoint shift lands in the climate, with the mode from the DPT, not as a sensor.
assert climate["setpoint_shift_address"] == "2/0/3", climate
assert climate["setpoint_shift_state_address"] == "2/0/8", climate
assert climate["setpoint_shift_mode"] == "DPT9002", climate
assert climate["target_temperature_state_address"] == "2/0/4"
assert climate["temperature_address"] == "2/0/5"
sensor_addrs = {s["state_address"] for s in knx.get("sensor", [])}
assert not sensor_addrs & {"2/0/3", "2/0/8"}, sensor_addrs
print("OK: setpoint shift -> climate setpoint_shift_address/state, mode DPT9002")
# 4b. 6.010 shift gives DPT6010; a 9.002 without the word stays out of the climate.
x610 = XML.replace(b'Address="2/0/3" DPTs="DPST-9-2"', b'Address="2/0/3" DPTs="DPST-6-10"') \
.replace(b'Address="2/0/8" DPTs="DPST-9-2"', b'Address="2/0/8" DPTs="DPST-6-10"')
assert _knx(_load(x610))["climate"][0]["setpoint_shift_mode"] == "DPT6010"
noword = XML.replace(b"Setpoint Shift Status", b"Delta Status").replace(b"Setpoint Shift", b"Delta")
c2 = _knx(_load(noword))["climate"][0]
assert "setpoint_shift_address" not in c2 and "setpoint_shift_mode" not in c2, c2
print("OK: 6.010 -> DPT6010; a bare 9.002 without a shift word is not taken as a shift")
# 5. Naming guards.
assert _entity_name("Spot 1 Brightness", ["Spot 1 Brightness", "Spot 12 Status"]) == "Spot 1 Brightness"
assert _entity_name("Licht Küche Wert", ["Licht Küche Wert", "RM Licht Küche"]) == "Licht Küche Wert"
assert _entity_name("Kitchen Light", ["Kitchen Light"]) == "Kitchen Light"
assert _entity_name("Kanal A Helligkeit", ["Kanal A Helligkeit", "Kanal A Schalten"]) == "Kanal A"
assert _entity_name("Status: Wert", ["Status: Wert", "Status: Schalten"]) == "Status: Wert"
# Shapes seen on real projects (anonymised). Trim only function words; anything that
# names the room, the device or its type stays, even when the names diverge early.
assert _entity_name("01. Hall - All Blinds - Move", ["01. Hall - All Blinds - Move", "01. Hall blinds stop"]) \
== "01. Hall - All Blinds - Move"
assert _entity_name("ch1 long door", ["ch1 long door", "ch1 short door"]) == "ch1 long door"
assert _entity_name("06. Bath/Convector - Mode", ["06. Bath/Convector - Mode", "06. Bath temperature"]) \
== "06. Bath/Convector - Mode"
assert _entity_name("28. Living - A/C - Mode", ["28. Living - A/C - Mode", "28. Living - A/C - Mode status"]) \
== "28. Living - A/C - Mode"
assert _entity_name("01. Hall - FH - HVAC", ["01. Hall - FH - HVAC", "01. Hall temperature"]) == "01. Hall - FH - HVAC"
assert _entity_name("Kitchen ceiling - Absolute dimming", ["Kitchen ceiling - Absolute dimming",
"Kitchen ceiling - On"]) == "Kitchen ceiling"
assert _entity_name("Garage gate left - Open/Close", ["Garage gate left - Open/Close",
"Garage gate left - Stop"]) == "Garage gate left"
assert _entity_name("Living room blind up/down", ["Living room blind up/down",
"Living room blind position"]) == "Living room blind"
# Collision: two lights whose common prefix would be the same name keep their anchors.
two = XML.replace(b"</GroupRange>\n </GroupRange>\n <GroupRange Name=\"Shading\"", b'''
<GroupAddress Name="Kitchen Spots B.Value Left" Address="0/0/20" DPTs="DPST-5-1" />
<GroupAddress Name="Kitchen Spots On-Off Left" Address="0/0/21" DPTs="DPST-1-1" />
</GroupRange>
</GroupRange>
<GroupRange Name="Shading"''', 1)
names = sorted(e["name"] for e in _knx(_load(two))["light"])
assert len(names) == len(set(n.lower() for n in names)), names
print("OK: naming guards — digit identity kept, no common prefix, no identity, collisions")
# 6. Round trip: our own ETS export reads back to the same GAs and DPTs.
back = build_loaded_from_raw(read_ga_export_bytes(generate_ets_xml(p).encode("utf-8"), "rt"), "rt")
assert set(back.gas) == set(p.gas)
for a, g in p.gas.items():
assert (back.gas[a].dpt_main, back.gas[a].dpt_sub, back.gas[a].name) == (g.dpt_main, g.dpt_sub, g.name), a
print("OK: generate_ets_xml -> load_ga_export round trip keeps every GA, name and DPT")
# 7. Hostile and malformed input is refused with a plain error, and warnings are never silent.
for bad, why in [
(b'<?xml version="1.0"?><!DOCTYPE x [<!ENTITY a "b">]><GroupAddress-Export/>', "DTD"),
(b"<Project/>", "root"),
(b"<GroupAddress-Export", "malformed"),
(b'<GroupAddress-Export xmlns="http://knx.org/xml/ga-export/01">' + b'<GroupRange Name="x">' * 2000
+ b'</GroupRange>' * 2000 + b"</GroupAddress-Export>", "nesting bomb"),
]:
try:
_load(bad)
except GaExportError:
pass
else:
raise AssertionError(f"accepted {why}")
w = _load(b'''<GroupAddress-Export xmlns="http://knx.org/xml/ga-export/01">
<GroupAddress Name="a" Address="1/0/1" DPTs="DPST-1-1" />
<GroupAddress Name="dup" Address="1/0/1" DPTs="DPST-1-1" />
<GroupAddress Name="bad" Address="99/9/999" />
<GroupAddress Name="weird" Address="1/0/2" DPTs="foo" />
</GroupAddress-Export>''')
assert set(w.gas) == {"1/0/1", "1/0/2"}
assert len(w.info["import_warnings"]) == 3, w.info["import_warnings"]
assert parse_dpt("DPST-1-1 DPST-1-11") == {"main": 1, "sub": 1} and parse_dpt(None) is None
try:
load_ga_export("/nonexistent/x.xml")
except GaExportError:
pass
else:
raise AssertionError("missing file accepted")
print("OK: DTD, wrong root, malformed, nesting bomb and missing files refused; duplicates/invalid/unknown DPT warned")
print("\nALL GA-EXPORT / NAMING / SETPOINT-SHIFT TESTS PASSED")