fix(generate_ha): Russian function words in identity + exact-identity outranks subset

Dogfooding the voice layer exposed two pairing defects: (1) _FUNC_WORDS had no
Russian entries, so 'вкл/выкл/цвет/яркость' polluted device identity and an RGBW
colour GA could not find its own switch; (2) subset identity matching let
'RGBW подсветка яркость' {zone, подсветка} grab 'камин подсветка' by subset and
its status by tie-break. Sibling pick and status_for_dpt now prefer EXACT
identity equality before subset fallback. 13/13 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay1
2026-07-15 12:31:37 +02:00
co-authored by Claude Opus 4.8
parent 6cabea08af
commit 4a9dd5ce4a
2 changed files with 41 additions and 20 deletions
+26 -13
View File
@@ -69,6 +69,9 @@ _FUNC_WORDS = {
# colour function words: ignored so a colour GA pairs to its zone's light
"colour", "color", "rgb", "rgbw", "rgbww", "hue", "saturation", "sat",
"white", "warm", "cct", "kelvin", "temp", "temperature", "xyy", "farbe",
# Russian function words — identity is the zone/device, not the verb
"вкл", "выкл", "статус", "состояние", "яркость", "значение", "диммирование",
"цвет", "позиция", "движение", "стоп", "вверх", "вниз",
}
@@ -156,7 +159,12 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]:
# so a command never borrows a sibling's status.
if not _identity_match(cmd.name, s.name):
continue
score = len(cid & _pair_ident(s.name)) + (2 if s.main == cmd.main else 0)
sid = _pair_ident(s.name)
# EXACT identity outranks any subset overlap: "RGBW подсветка"
# must take "RGBW подсветка статус" over "камин подсветка статус"
# even though both share {zone, подсветка}.
score = len(cid & sid) + (2 if s.main == cmd.main else 0) \
+ (3 if sid == cid else 0)
if score > best_score:
best, best_score = s, score
return best
@@ -263,18 +271,23 @@ def generate_ha_yaml(project: LoadedProject) -> dict[str, Any]:
if st5:
entity["brightness_state_address"] = st5.address
consumed.add(st5.address)
for sib in same_main_gas(ga):
if sib.address in consumed:
continue
if sib.category in ("lighting", "unknown") and sib.dpt_main == 1 \
and sib.kind == "command" and _identity_match(ga.name, sib.name):
entity["address"] = sib.address
s1 = status_for_dpt(sib, 1) # on/off status (1.x)
if s1:
entity["state_address"] = s1.address
consumed.add(s1.address)
consumed.add(sib.address)
break
# two-pass sibling pick: EXACT identity first, subset only as a
# fallback — else "RGBW подсветка яркость" ({living, подсветка})
# grabs "камин подсветка" ({living, камин, подсветка}) by subset
# while its true on/off sits one address further.
sibs = [s for s in same_main_gas(ga)
if s.address not in consumed and s.dpt_main == 1
and s.kind == "command" and s.category in ("lighting", "unknown")]
my_ident = _pair_ident(ga.name)
sib = next((s for s in sibs if _pair_ident(s.name) == my_ident), None) \
or next((s for s in sibs if _identity_match(ga.name, s.name)), None)
if sib is not None:
entity["address"] = sib.address
s1 = status_for_dpt(sib, 1) # on/off status (1.x)
if s1:
entity["state_address"] = s1.address
consumed.add(s1.address)
consumed.add(sib.address)
attach_colour(entity, ga) # RGB/RGBW/xyY/colour-temp of this zone
lights.append(entity)
consumed.add(ga.address)
+15 -7
View File
@@ -186,13 +186,21 @@ def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, An
"populated — leave spare address space per this project's convention.",
})
elif not reserve_mains and source != "profile":
# inferred taxonomy: no main group is reserve-like at all — the
# project has no spare address space anywhere
findings.append({
"severity": "info", "code": "policy_no_reserve", "address": "-",
"message": "No reserve main group exists in the project — every main is "
"populated. Leave spare address space for future extensions.",
})
# inferred taxonomy can't see reserves (reserve GAs are non-functional
# by intent) — before complaining, look for reserve-intent GAs and
# reserve-named group ranges directly.
has_reserve = any(g.intent == "reserve" for g in project.gas.values())
if not has_reserve:
rtok = ("резерв", "reserve", "spare")
names = [g.main_name for g in project.gas.values()] + \
[g.middle_name for g in project.gas.values()]
has_reserve = any(t in (n or "").lower() for n in names for t in rtok)
if not has_reserve:
findings.append({
"severity": "info", "code": "policy_no_reserve", "address": "-",
"message": "No reserve main group exists in the project — every main is "
"populated. Leave spare address space for future extensions.",
})
errors = sum(1 for f in findings if f["severity"] == "error")
warns = sum(1 for f in findings if f["severity"] == "warning")