From a3f436fc740b3ff337fc349859add63b6b3a3358 Mon Sep 17 00:00:00 2001 From: Nikolay Miroshnichenko Date: Wed, 16 Sep 2026 19:20:49 +0200 Subject: [PATCH] generate_ha: one climate entity per physical device, fail closed when devices cannot be told apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on a real 1312-GA house: a room with floor heating, a convector and an AC unit became ONE climate (convector setpoint, AC controller mode, AC fan speed as the valve). Room 1.09 matched 2.09 and a central "09." GA, "Kids room 1" took "Kids room 2 temperature", and valves came from light brightness and blind position statuses. The zone was the room alone: device words were stripped as qualifiers, "А/С" vanishes in the tokenizer, the floor digit of "1.09" is dropped. Now a climate is assembled per room AND device type (floor, wall, radiator/convector, fan coil, AC; RU/EN/DE words, removed from the zone as whole words): - room code "N.NN" only at the start of a name, and it must match on both sides; - standalone numbers of the anchor must be in a device member; a shared room sensor's numbers must be among the anchor's; digits in dotted codes and values do not count; - an untyped GA is shared room data: any role if the room has one device, otherwise only the current temperature, which several devices may reuse; with a room code the room's untyped sensor qualifies even without the device's extra words; - a word that sets another device of the room apart keeps its GAs away; - control roles must be unambiguous: more than one candidate -> review climate_ambiguous; - two mode GAs that cannot be told apart -> review climate_duplicate_anchor, not dropped; - only 20.102 / 20.105 anchor; valve needs a valve word; AC never gets a valve; - stable address order, so results never depend on parse order. Two gates. Own audit on seven real projects, every changed entity inspected: no control GA used by two climates, no address lost; house 16 -> 33 climates (one per device), manual review 33 -> 12; flat 7 -> 9; HDL 3 -> 5; demo 6 -> 13; villa 23 -> 21 with the 3 it cannot disambiguate sent to review instead of guessed. LLM council (three models + devil's advocate, sanitised packet): accept with hardening; its P0s (same-type devices, silent first pick) and P1s (mid-name codes, dotted digits, non-102/105 anchors, generic valve words) are all in this commit, with tests E and F covering its failure catalogue. Out of scope, recorded: identically named rooms of different flats without room codes, and lexical identity in general -> device-channel identity ("lever 0"). Also works around a mypy 2.3.1 parse error on a comment right after a compound if. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 +++ nickol_knx_mcp/generate_ha.py | 236 ++++++++++++++++++++++++++++++--- tests/test_real_house_fixes.py | 119 +++++++++++++++++ tools/corpus_baseline.json | 30 ++--- 4 files changed, 368 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8e74b..84ef4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Climate entities no longer merge different devices of one room.** A room with floor heating, a + convector or radiator and an AC unit used to become a single Home Assistant climate: the convector's + setpoint, the AC's controller mode and the AC fan speed as the "valve". Valves were also taken from + unrelated 5.x statuses (light brightness, blind position), room "1.09" matched "2.09", and "Kids + room 1" took "Kids room 2 temperature". A climate is now assembled per room **and** device type + (floor / wall / radiator-convector / AC, detected in RU/EN/DE names): the room code ("1.09") and the + numbers in the anchor name must match, a shared untyped room sensor may serve every device in the + room as current temperature but never as a device's setpoint or mode, a valve needs a valve word and + an AC never gets one. Hardened after an LLM-council review: a word that sets another device of the + room apart ("shower", "hall") keeps its GAs away; if a control role still has more than one candidate + the device goes to review as `climate_ambiguous` instead of taking the first; two mode GAs that cannot + be told apart go to review as `climate_duplicate_anchor` instead of being dropped silently; room codes + only count at the start of a name ("21.5 °C", "16.10", "ДД 34.1" are not rooms); digits inside dotted + codes are not room numbers; only 20.102 and 20.105 start a climate; fan coils and VRV/VRF are their + own device types; valve words narrowed to valve / клапан / Stellwert. On seven real projects: no + control GA used by two climates, no address lost. A 1312-GA house goes from 16 climates (several + wrong) to 33 (one per device), manual climate review 33 -> 12; a 3646-GA villa keeps 21 of 23 and + sends the 3 it cannot disambiguate (three thermostats in one corridor, two identical AC mode GAs) to + review instead of guessing. - **Home Assistant lights without `address` are no longer generated.** A 5.001 lighting GA with no on/off GA in its zone used to become a light with only `brightness_address`, which Home Assistant rejects (`address` is required on a KNX light). On six real projects 51 such lights were generated, diff --git a/nickol_knx_mcp/generate_ha.py b/nickol_knx_mcp/generate_ha.py index 18536f7..eee1852 100644 --- a/nickol_knx_mcp/generate_ha.py +++ b/nickol_knx_mcp/generate_ha.py @@ -9,6 +9,7 @@ status GAs in a separate middle group are still matched. from __future__ import annotations +import re from typing import Any import yaml @@ -164,6 +165,85 @@ def _has(name: str, words) -> bool: return any(w in low for w in words) +# ---- Climate zone identity ------------------------------------------------------------- +# A room often holds several climate devices: floor heating, a radiator or convector, an AC +# unit, sometimes wall heating or a second floor loop. Each is its own Home Assistant climate. +# The zone used to be the room alone (device words were stripped as qualifiers, and "А/С" +# vanishes in the tokenizer), so all of them were merged into one entity. Found on a real +# 1312-GA house; hardened after an LLM-council review (same-type devices, ambiguous picks). + +# "1.09" style room code at the START of a name. The tokenizer drops the floor digit +# ("1.09" -> "09"), which made 1.09 match 2.09 and a central "09. ..." GA. Mid-name dotted +# numbers are device tags ("ДД 34.1"), dates ("16.10") or values ("21.5 °C"), not rooms. +_ROOM_CODE_RE = re.compile(r"^\W*(\d{1,2})\s*\.\s*(\d{1,2})(?![\d.])") + + +def _room_code(name: str) -> str | None: + m = _ROOM_CODE_RE.search(name or "") + return f"{int(m.group(1))}.{int(m.group(2)):02d}" if m else None + + +_CLIMATE_DEVICE_PATTERNS: tuple[tuple[str, re.Pattern], ...] = ( + # bare "floor" only as a delimited name part ("Floor-Living-RealTemp") or right before + # valve/loop/circuit/heating, so a level ("1st floor", "ground floor") does not count + ("floor", re.compile(r"тепл\w*\s+пол|водян\w*\s+пол|\bтп\b|fu(?:ss|ß)boden|floor\s*heat|underfloor" + r"|(?:^|[-_/])floor(?=[-_/]|$)|\bfloor(?=\s+(?:valve|loop|circuit|heating)\b)")), + ("wall", re.compile(r"тепл\w*\s+стен|\bстена\b|wandheiz|wall\s*heat")), + ("radiator", re.compile(r"радиатор|конвектор|батаре|radiator|convector|heizk(?:ö|oe)rper")), + # a fan coil is hydronic, not an AC unit; its own type keeps it apart from both + ("fancoil", re.compile(r"fan\s*-?\s*coil|фан\s*-?\s*койл|\bfcu\b")), + # bare German "Klima" is deliberately absent: it also just means "climate" + ("ac", re.compile(r"(? frozenset: + return frozenset(int(n) for n in _NUMBER_RE.findall(name or "")) + + +def _without_device_words(name: str) -> str: + """The name with every climate device word removed, so the room identity is the same for + "Convector - Mode", "Конвектор - Режим" and a shared "Air temperature" in that room.""" + low = (name or "").lower().replace("ё", "е") + blank = [False] * len(low) + for _kind, rx in _CLIMATE_DEVICE_PATTERNS: + for m in rx.finditer(low): + # widen to whole words: "floor heat" inside "floor heating" must not leave "ing" + start, end = m.start(), m.end() + while start > 0 and low[start - 1].isalnum(): + start -= 1 + while end < len(low) and low[end].isalnum(): + end += 1 + for i in range(start, end): + blank[i] = True + return "".join(" " if b else ch for ch, b in zip(low, blank)) + + +def _climate_types(name: str) -> frozenset: + low = (name or "").lower().replace("ё", "е") + return frozenset(kind for kind, rx in _CLIMATE_DEVICE_PATTERNS if rx.search(low)) + + +def _addr_key(address: str) -> tuple: + try: + return (0, tuple(int(x) for x in (address or "").split("/"))) + except ValueError: + return (1, (address or "",)) + + +# A 5.x status is a heating valve only when the name says so; an AC fan speed (5.001 +# "Вентилятор") or a generic "control value" is not. +_VALVE_WORDS = ("клапан", "valve", "stellwert", "stellgr") + + + # Setpoint shift (HA climate `setpoint_shift_address`, DPT 6.010 or 9.002). The DPT # alone is not enough — 9.002 is any temperature difference — so a shift needs the # word as well. German "Sollwertverschiebung", Russian "смещение/сдвиг уставки". @@ -518,36 +598,148 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: # lives in a different main group than the mode). Emitted only when the HA- # required minimum is present (current temp + target-temp status); otherwise # the zone goes to review so we never write an invalid climate entity. ---- - built_climate: list[set] = [] - for ga in project.gas.values(): + built_climate: set = set() + # only the two mode DPTs a climate entity uses; stable order so results never depend on + # how the project happened to parse + climate_anchors = sorted((g for g in project.gas.values() + if g.category == "hvac" and g.kind == "command" + and (g.dpt_main, g.dpt_sub) in ((20, 102), (20, 105))), + key=lambda g: _addr_key(g.address)) + + def _zone_loc(g: GARecord) -> set: + return _pair_ident(_without_device_words(g.name)) - _CLIMATE_QUALIFIERS + + def _zone_key(g: GARecord): + return _room_code(g.name) or frozenset(_zone_loc(g)) + + shared_current: set[str] = set() # untyped room sensors already used as a current temperature + room_types: dict = {} # room -> set of anchor device-type sets + room_anchor_locs: dict = {} # room -> [(anchor address, its zone tokens)] + for a in climate_anchors: + room_types.setdefault(_zone_key(a), set()).add(_climate_types(a.name)) + room_anchor_locs.setdefault(_zone_key(a), []).append((a.address, frozenset(_zone_loc(a)))) + + for ga in climate_anchors: if ga.address in consumed: continue - if ga.category != "hvac" or ga.kind != "command" or ga.dpt_main != 20: + zone_loc = _zone_loc(ga) + anchor_code = _room_code(ga.name) + anchor_types = _climate_types(ga.name) + anchor_numbers = _numbers(ga.name) + zkey = (_zone_key(ga), anchor_types, frozenset(zone_loc), anchor_numbers) + if zone_loc and zkey in built_climate: + # same room, device type and name identity as an entity already built: two mode + # GAs we cannot tell apart. Never drop it silently, never merge it. + review.append({"reason": "climate_duplicate_anchor", "address": ga.address, + "name": ga.name, "dpt": ga.dpt, + "hint": "Another HVAC mode GA with the same room, device type and name " + "already produced a climate entity — map this device manually."}) + consumed.add(ga.address) continue - zone_loc = _pair_ident(ga.name) - _CLIMATE_QUALIFIERS - if zone_loc and any(zone_loc == d for d in built_climate): - continue # this zone already produced a climate entity - zone = [g for g in project.gas.values() - if g.address not in consumed and zone_loc and zone_loc <= _pair_ident(g.name)] + typed_kinds = {t for t in room_types.get(_zone_key(ga), set()) if t} + typed_kinds_flat = frozenset().union(*typed_kinds) if typed_kinds else frozenset() + ambiguous_room = len(typed_kinds) >= 2 + # words that set OTHER climate devices of this room apart ("душ", "холл"): a member + # carrying one of them belongs to that device, not to this one + foreign_words: set = set() + for other_addr, other_loc in room_anchor_locs.get(_zone_key(ga), []): + if other_addr != ga.address: + foreign_words |= set(other_loc) - zone_loc - def _pick(pred): - return next((g for g in zone if pred(g)), None) + def _member_ok(g: GARecord, role: str) -> bool: + member_code = _room_code(g.name) + if anchor_code and member_code != anchor_code: + return False + if not anchor_code and member_code: + return False + gt = _climate_types(g.name) + member_numbers = _numbers(g.name) + if role == "current" and not gt: + # a shared room sensor names the room, not the device ("Room 1 Temperature" for + # "Room 1 Floor heating 2"), so its numbers must be among the anchor's + if not member_numbers <= anchor_numbers: + return False + elif not anchor_numbers <= member_numbers: + return False + if foreign_words & _pair_ident(_without_device_words(g.name)) \ + and not (role == "current" and not gt and anchor_code): + return False + if gt == anchor_types: + return True + # The anchor names no device type and no other device in the room has the member's + # type, so the typed member ("floor valve") belongs to this untyped device. + unique_type_for_untyped = bool(gt) and not anchor_types and not (gt & typed_kinds_flat) + if unique_type_for_untyped: + return True + if not gt: + # an untyped GA is shared room data; with several devices in the room it + # can only be the measured temperature, never a device's setpoint or mode + return role == "current" or not ambiguous_room + return False + + # a shared, untyped room temperature sensor may serve every climate device in the room + zone_all = sorted((g for g in project.gas.values() + if (g.address not in consumed or g.address in shared_current) + and zone_loc and (zone_loc <= _pair_ident(g.name) + or zone_loc <= _pair_ident(_without_device_words(g.name)))), + key=lambda g: _addr_key(g.address)) + # prefer members of the anchor's own device type over shared, untyped ones + zone = ([g for g in zone_all if _climate_types(g.name) == anchor_types] + + [g for g in zone_all if _climate_types(g.name) != anchor_types]) + # With a room code the room is already certain, so an untyped room sensor with the same + # code may be the current temperature even if it lacks the device's extra words + # ("1.09 Bath air temperature" for "1.09 Bath - Floor heating shower"). + room_sensors = [] if not anchor_code else sorted( + (g for g in project.gas.values() + if (g.address not in consumed or g.address in shared_current) + and g not in zone and _room_code(g.name) == anchor_code and not _climate_types(g.name)), + key=lambda g: _addr_key(g.address)) + ambiguous_roles: dict[str, list[str]] = {} + + def _pick(pred, role: str): + pool = zone + room_sensors if role == "current" else zone + cands = [g for g in pool if pred(g) and _member_ok(g, role) + and (role == "current" or g.address not in shared_current)] + if role == "current" or len(cands) <= 1: + # a current temperature only feeds the display; for it the preferred first + # candidate is enough. Control roles must be unambiguous. + return cands[0] if cands else None + own = [g for g in cands if _climate_types(g.name) == anchor_types] + if len(own) == 1: + return own[0] + ambiguous_roles[role] = [g.address for g in cands] + return None # Temperature GAs must be DPT 9.001 specifically — DPT main 9 also covers # humidity (9.007), CO2 (9.008), lux (9.004); never treat those as a setpoint. cur = _pick(lambda g: (g.dpt_main, g.dpt_sub) == (9, 1) and g.kind == "sensor" - and not _has(g.name, _TARGET_WORDS)) + and not _has(g.name, _TARGET_WORDS), "current") tgt_state = _pick(lambda g: (g.dpt_main, g.dpt_sub) == (9, 1) and _is_status_ga(g) - and _has(g.name, _TARGET_WORDS)) + and _has(g.name, _TARGET_WORDS), "target_state") tgt_cmd = _pick(lambda g: (g.dpt_main, g.dpt_sub) == (9, 1) and not _is_status_ga(g) - and _has(g.name, _TARGET_WORDS)) - op_cmd = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 102 and g.kind == "command") - op_state = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 102 and _is_status_ga(g)) - ctrl_cmd = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 105 and g.kind == "command") - ctrl_state = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 105 and _is_status_ga(g)) - valve = _pick(lambda g: g.dpt_main == 5 and _is_status_ga(g)) - shift_cmd = _pick(lambda g: _is_shift(g) and not _is_status_ga(g)) - shift_state = _pick(lambda g: _is_shift(g) and _is_status_ga(g)) + and _has(g.name, _TARGET_WORDS), "target") + op_cmd = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 102 and g.kind == "command", + "operation_mode") + op_state = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 102 and _is_status_ga(g), + "operation_mode_state") + ctrl_cmd = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 105 and g.kind == "command", + "controller_mode") + ctrl_state = _pick(lambda g: g.dpt_main == 20 and g.dpt_sub == 105 and _is_status_ga(g), + "controller_mode_state") + # an AC unit has no heating valve; a valve GA in the room belongs to another device + valve = None if "ac" in anchor_types else \ + _pick(lambda g: g.dpt_main == 5 and _is_status_ga(g) and _has(g.name, _VALVE_WORDS), + "valve") + shift_cmd = _pick(lambda g: _is_shift(g) and not _is_status_ga(g), "setpoint_shift") + shift_state = _pick(lambda g: _is_shift(g) and _is_status_ga(g), "setpoint_shift_state") + + if ambiguous_roles: + review.append({"reason": "climate_ambiguous", "address": ga.address, + "name": ga.name, "dpt": ga.dpt, "candidates": ambiguous_roles, + "hint": "More than one GA fits a control role of this climate device " + "(several devices share the naming) — pick the right ones manually."}) + consumed.add(ga.address) + continue if not (cur and tgt_state): review.append({"reason": "manual_climate", "address": ga.address, @@ -579,13 +771,15 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]: shift_ref = shift_cmd or shift_state if shift_ref: ent["setpoint_shift_mode"] = _SHIFT_MODE[(shift_ref.dpt_main, shift_ref.dpt_sub)] + if cur is not None and not _climate_types(cur.name): + shared_current.add(cur.address) for m in (cur, tgt_state, tgt_cmd, op_cmd, op_state, ctrl_cmd, ctrl_state, valve, shift_cmd, shift_state): if m: consumed.add(m.address) climates.append(ent) if zone_loc: - built_climate.append(zone_loc) + built_climate.add(zkey) # B2 climate correctness: mode-command-without-state makes the mode unshowable; # controller/operation mode lists auto-detect wrong; setpoint-shift needs cmd+state. issues = [] diff --git a/tests/test_real_house_fixes.py b/tests/test_real_house_fixes.py index 5d4c05f..f5b110c 100644 --- a/tests/test_real_house_fixes.py +++ b/tests/test_real_house_fixes.py @@ -106,4 +106,123 @@ no_status = {r["address"] for r in resC["review"] if r["reason"] in ("light_with assert no_status == {"1/0/6"}, no_status print("OK: C — Read+Transmit object on the command GA is its own state; write-only stays in review") +# ---------------------------------------------------------------- E. climate: one entity per device +# A room with floor heating + convector + AC unit sharing one air sensor used to become ONE climate +# (convector setpoint, AC controller mode, AC fan speed as "valve"). Room codes lost their floor +# digit (1.09 matched 2.09 and a central GA), and "Kids room 1" matched "Kids room 2". +pE = _project([ + ("2/0/6", "1.05 Bedroom - Air temperature", 9, 1), + ("2/2/20", "1.05 Bedroom - Convector - Уставка", 9, 1), + ("2/2/21", "1.05 Bedroom - Convector - Уставка - Статус", 9, 1), + ("2/2/22", "1.05 Bedroom - Convector - Режим", 20, 102), + ("2/2/23", "1.05 Bedroom - Convector - Режим - Статус", 20, 102), + ("2/5/37", "1.05 Bedroom -А/С - Уставка", 9, 1), + ("2/5/38", "1.05 Bedroom -А/С - Уставка - Статус", 9, 1), + ("2/5/39", "1.05 Bedroom -А/С - Режим", 20, 105), + ("2/5/40", "1.05 Bedroom -А/С - Режим - Статус", 20, 105), + ("2/5/42", "1.05 Bedroom -А/С - Вентилятор - Статус", 5, 1), + ("2/4/10", "1.09 Bath - Теплый пол - Уставка", 9, 1), + ("2/4/11", "1.09 Bath - Теплый пол - Уставка - Статус", 9, 1), + ("2/4/12", "1.09 Bath - Теплый пол - Режим", 20, 102), + ("2/4/13", "1.09 Bath - Теплый пол - Режим - Статус", 20, 102), + ("2/1/9", "1.09 Bath - Теплый пол - Температура", 9, 1), + ("2/4/14", "1.09 Bath - Теплый пол - клапан - Статус", 5, 1), + ("0/1/7", "09. Bath 1st floor ТП + стена - Уставка", 9, 1), + ("2/3/0", "2.09 Lounge - Теплый пол - Уставка", 9, 1), + ("2/3/1", "2.09 Lounge - Теплый пол - Уставка - Статус", 9, 1), + ("2/3/2", "2.09 Lounge - Теплый пол - Режим", 20, 102), + ("2/1/19", "2.09 Lounge - Теплый пол - Температура", 9, 1), + ("3/0/1", "Kids room 1 temperature", 9, 1), + ("3/0/2", "Kids room 2 temperature", 9, 1), + ("3/1/1", "Kids room 1 AC setpoint", 9, 1), + ("3/1/2", "Kids room 1 AC setpoint status", 9, 1), + ("3/1/3", "Kids room 1 AC mode", 20, 102), +]) +climE = yaml.safe_load(generate_ha_yaml(pE)["yaml"].split("\n\n", 1)[1])["knx"]["climate"] +by_mode = {(e.get("operation_mode_address") or e.get("controller_mode_address")): e for e in climE} +conv, ac, bath, lounge, kids = by_mode["2/2/22"], by_mode["2/5/39"], by_mode["2/4/12"], by_mode["2/3/2"], by_mode["3/1/3"] +assert conv["target_temperature_state_address"] == "2/2/21" and conv["temperature_address"] == "2/0/6", conv +assert "controller_mode_address" not in conv and "command_value_state_address" not in conv, conv +assert ac["target_temperature_state_address"] == "2/5/38" and ac["temperature_address"] == "2/0/6", ac +assert "command_value_state_address" not in ac and "operation_mode_address" not in ac, ac +assert bath["target_temperature_address"] == "2/4/10" and bath["temperature_address"] == "2/1/9", bath +assert bath.get("command_value_state_address") == "2/4/14", bath +assert lounge["target_temperature_state_address"] == "2/3/1" and lounge["temperature_address"] == "2/1/19", lounge +assert kids["temperature_address"] == "3/0/1", kids +controls = [e[k] for e in climE for k in e if k.endswith("address") and k != "temperature_address"] +assert len(controls) == len(set(controls)), controls +assert "0/1/7" not in controls, controls +print(f"OK: E — {len(climE)} climates, one per device; room code, numbers and device type keep them apart") + +# ---------------------------------------------------------------- F. climate: LLM-council failure catalog +def _climates(rows): + res = generate_ha_yaml(_project(rows)) + doc = yaml.safe_load(res["yaml"].split("\n\n", 1)[1]) or {} + return (doc.get("knx") or {}).get("climate", []), res["review"] + +def _controls(clims): + return [e[k] for e in clims for k in e if k.endswith("address") and k != "temperature_address"] + +# F1. two devices of the same type, told apart by a word -> two climates, no cross-wiring +cl, rv = _climates([ + ("4/0/1", "3.01 Hall - Air temperature", 9, 1), + ("4/1/1", "3.01 Hall - Convector north - Setpoint", 9, 1), + ("4/1/2", "3.01 Hall - Convector north - Setpoint status", 9, 1), + ("4/1/3", "3.01 Hall - Convector north - HVAC mode", 20, 102), + ("4/2/1", "3.01 Hall - Convector south - Setpoint", 9, 1), + ("4/2/2", "3.01 Hall - Convector south - Setpoint status", 9, 1), + ("4/2/3", "3.01 Hall - Convector south - HVAC mode", 20, 102), +]) +by = {e["operation_mode_address"]: e for e in cl} +assert by["4/1/3"]["target_temperature_state_address"] == "4/1/2", cl +assert by["4/2/3"]["target_temperature_state_address"] == "4/2/2", cl +assert by["4/1/3"]["temperature_address"] == by["4/2/3"]["temperature_address"] == "4/0/1", cl +print("OK: F1 — same-type devices told apart by a word, shared room sensor") + +# F2. two devices we cannot tell apart ("AC_1"/"AC_2") -> never merged, never a silent pick +cl, rv = _climates([ + ("5/0/1", "Hall temperature", 9, 1), + ("5/1/1", "Hall AC_1 setpoint", 9, 1), ("5/1/2", "Hall AC_1 setpoint status", 9, 1), + ("5/1/3", "Hall AC_1 mode", 20, 105), + ("5/2/1", "Hall AC_2 setpoint", 9, 1), ("5/2/2", "Hall AC_2 setpoint status", 9, 1), + ("5/2/3", "Hall AC_2 mode", 20, 105), +]) +assert not cl, cl +assert {r["address"] for r in rv if r["reason"] in ("climate_ambiguous", "climate_duplicate_anchor")} == {"5/1/3", "5/2/3"}, rv +print("OK: F2 — indistinguishable same-type devices go to review, nothing is guessed") + +# F3. a value or a device tag is not a room code or a room number; a shared room sensor +# fits a numbered device ("Room 1 Temperature" for "Room 1 Floor heating 2") +cl, rv = _climates([ + ("6/0/1", "Room 1 Temperature", 9, 1), + ("6/1/1", "Room 1 Floor heating 2 setpoint 21.5 °C", 9, 1), + ("6/1/2", "Room 1 Floor heating 2 setpoint 21.5 °C status", 9, 1), + ("6/1/3", "Room 1 Floor heating 2 D.1.2 HVAC mode", 20, 102), +]) +assert len(cl) == 1 and cl[0]["temperature_address"] == "6/0/1" and cl[0]["target_temperature_state_address"] == "6/1/2", (cl, rv) +print("OK: F3 — '21.5' is no room code, 'D.1.2' no room number, shared sensor fits a numbered loop") + +# F4. a second floor loop's word ("shower") keeps its members away from the main loop +cl, rv = _climates([ + ("7/0/1", "1.09 Bath - Air temperature", 9, 1), + ("7/1/1", "1.09 Bath - Теплый пол - Уставка", 9, 1), ("7/1/2", "1.09 Bath - Теплый пол - Уставка - Статус", 9, 1), + ("7/1/3", "1.09 Bath - Теплый пол - Режим", 20, 102), + ("7/2/1", "1.09 Bath - Теплый пол душ - Уставка", 9, 1), ("7/2/2", "1.09 Bath - Теплый пол душ - Уставка - Статус", 9, 1), + ("7/2/3", "1.09 Bath - Теплый пол душ - Режим", 20, 102), +]) +by = {e["operation_mode_address"]: e for e in cl} +assert by["7/1/3"]["target_temperature_state_address"] == "7/1/2", cl +assert by["7/2/3"]["target_temperature_state_address"] == "7/2/2", cl +assert len(_controls(cl)) == len(set(_controls(cl))), cl +print("OK: F4 — main floor loop and shower loop each keep their own setpoint and mode") + +# F5. only 20.102 / 20.105 anchor a climate +cl, rv = _climates([ + ("8/0/1", "Lab temperature", 9, 1), ("8/0/2", "Lab heating setpoint status", 9, 1), + ("8/0/3", "Lab HVAC control mode", 20, 1), +]) +assert not cl, cl +assert any(r["address"] == "8/0/3" for r in rv), rv # not an anchor, but never dropped silently +print("OK: F5 — a 20.001 GA does not start a climate entity and still reaches review") + print("\nALL REAL-HOUSE REGRESSION TESTS PASSED") diff --git a/tools/corpus_baseline.json b/tools/corpus_baseline.json index 1c6e19a..89c32d6 100644 --- a/tools/corpus_baseline.json +++ b/tools/corpus_baseline.json @@ -31,13 +31,13 @@ "group_addresses": 238, "ha_entities": { "binary_sensor": 13, - "climate": 6, + "climate": 13, "cover": 6, "light": 25, - "sensor": 41, + "sensor": 25, "switch": 6 }, - "ha_review_items": 57, + "ha_review_items": 55, "suggestion_hints": { "channels": 0, "diagnostics_skipped": 0, @@ -84,14 +84,14 @@ "group_addresses": 859, "ha_entities": { "binary_sensor": 66, - "climate": 3, + "climate": 5, "cover": 1, "expose": 1, "light": 180, - "sensor": 55, + "sensor": 51, "switch": 42 }, - "ha_review_items": 401, + "ha_review_items": 402, "suggestion_hints": { "channels": 229, "diagnostics_skipped": 2, @@ -256,14 +256,14 @@ "group_addresses": 685, "ha_entities": { "binary_sensor": 49, - "climate": 7, + "climate": 9, "cover": 7, "expose": 1, "light": 75, - "sensor": 111, + "sensor": 106, "switch": 79 }, - "ha_review_items": 286, + "ha_review_items": 287, "suggestion_hints": { "channels": 179, "diagnostics_skipped": 21, @@ -321,14 +321,14 @@ "group_addresses": 1312, "ha_entities": { "binary_sensor": 88, - "climate": 16, + "climate": 33, "cover": 11, "expose": 1, "light": 232, - "sensor": 159, + "sensor": 119, "switch": 95 }, - "ha_review_items": 555, + "ha_review_items": 559, "suggestion_hints": { "channels": 305, "diagnostics_skipped": 40, @@ -387,14 +387,14 @@ "group_addresses": 3646, "ha_entities": { "binary_sensor": 89, - "climate": 23, + "climate": 21, "cover": 40, "expose": 1, "light": 412, - "sensor": 343, + "sensor": 350, "switch": 319 }, - "ha_review_items": 1553, + "ha_review_items": 1577, "suggestion_hints": { "channels": 983, "diagnostics_skipped": 53,