"""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''' ''' 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"\n \n 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']>', "DTD"), (b"", "root"), (b"' + b'' * 2000 + b'' * 2000 + b"", "nesting bomb"), ]: try: _load(bad) except GaExportError: pass else: raise AssertionError(f"accepted {why}") w = _load(b''' ''') 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")