generate_ha: one climate entity per physical device, fail closed when devices cannot be told apart

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 <noreply@anthropic.com>
This commit is contained in:
Nikolay Miroshnichenko
2026-09-16 19:20:49 +02:00
co-authored by Claude Opus 5
parent 76249469bb
commit a3f436fc74
4 changed files with 368 additions and 36 deletions
+19
View File
@@ -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,
+215 -21
View File
@@ -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"(?<![a-zа-я])(?:а/с|a/c|ac)(?![a-zа-я])|кондиционер|сплит|split|klimaanlage|air\s*con"
r"|\bvr[vf]\b|\bврв\b")),
)
# Standalone numbers in a name ("Kids room 1", "2. Гостиная"). The tokenizer drops single
# digits, so "Kids room 1" matched "Kids room 2". Digits glued to a word ("Статус_1") or
# part of a dotted code or value ("1.09", "D.1.2", "21.5") are not counted.
_NUMBER_RE = re.compile(r"(?<![\w.])\d+(?![\w]|\.\d)")
def _numbers(name: str) -> 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 = []
+119
View File
@@ -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")
+15 -15
View File
@@ -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,