mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
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:
co-authored by
Claude Opus 5
parent
76249469bb
commit
a3f436fc74
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user