v2.5.3 — поддержка новых устройств: Yeelight, ёлка, гирлянда, ТВ; фильтр sub-entities switch; мониторинг сцен

This commit is contained in:
2026-07-29 20:20:29 +03:00
parent a4e944ce03
commit 000d7a2634
+83 -28
View File
@@ -2,7 +2,7 @@
title: Home Assistant Smart Control
description: Управление умным домом через Home Assistant REST API с интерпретацией естественного языка.
author: ChatGPT (refactored by MiniMax-M3)
version: 2.5.2
version: 2.5.3
license: MIT
"""
@@ -24,8 +24,8 @@ class Tools:
request_timeout: int = Field(default=10, description="Таймаут HTTP-запроса, сек")
max_retries: int = Field(default=2, description="Количество ретраев при сетевых сбоях")
cache_ttl: int = Field(default=5, description="TTL кэша состояний HA, сек")
confirm_global_actions: bool = Field(default=True, description="Спрашивать подтверждение для команд на ВСЕ сущности домена")
dry_run: bool = Field(default=False, description="Не выполнять команды, только показывать что БУДЕТ сделано")
confirm_global_actions: bool = Field(default=True, description="Спрашивать подтверждение для глобальных команд")
dry_run: bool = Field(default=False, description="Не выполнять команды, только показывать")
def __init__(self):
self.valves = self.Valves()
@@ -45,11 +45,24 @@ class Tools:
"устройство", "все", "весь", "вся",
}
# Алиасы людей — препятствуют "глобальной" команде света
_PEOPLE_NAMES = {"дима", "диан", "елен", "анатоли", "милен", "admin"}
# Sub-entities switch, которые НЕ являются реальными розетками/выключателями.
# Обычно создаются Zigbee2MQTT или прошивкой устройств.
_SWITCH_SUB_ENTITIES = (
"power_outage_memory", "interlock", "do_not_disturb", "child_lock",
"permit_join", "curtain_motor_reverse", "reverse_direction",
"alarm", "indicator_light", "indicator", "fan_horizontal_swing",
"fan_alarm", "physical_control_locked", "alarm_trigger",
"led_disabled", "linkquality",
)
_DOMAIN_ALIASES = {
"binary_sensor": ["binary_sensor", "бинарн", "датчик", "датчики", "сенсор", "сенсоры"],
"sensor": ["sensor", "показател", "значение", "значения"],
"light": ["light", "свет", "лампочк", "лампа", "люстр", "свитч"],
"switch": ["switch", "розетк", "выключател", "реле"],
"light": ["light", "свет", "лампочк", "лампа", "люстр", "свитч", "спот", "led"],
"switch": ["switch", "розетк", "выключател", "реле", "ёлк", "елк", "гирлянд", "телек", "тв"],
"climate": ["climate", "климат", "климата", "кондиционер", "термостат", "пол", "полотенц"],
"cover": ["cover", "штор", "жалюз", "роллет", "гардин"],
"media_player": ["media_player", "медиа", "плеер", "колонк", "телевизор"],
@@ -89,7 +102,7 @@ class Tools:
},
"climate_status": {
"domains": ["climate", "sensor"],
"keywords": ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"],
"keywords": ["температур", "климат", "тепл", "холод", "кондиционер", "полотенц"],
},
"battery": {
"domains": ["sensor", "binary_sensor"],
@@ -194,11 +207,17 @@ class Tools:
attrs.get("area_id", "") or "",
]).lower()
def _is_sub_entity(self, eid: str) -> bool:
"""Проверяет, является ли switch/light sub-entity (служебной)."""
eid_lower = eid.lower()
return any(sub in eid_lower for sub in self._SWITCH_SUB_ENTITIES)
def _find_entities(
self,
keywords: List[str],
domain: Optional[str] = None,
require_all: bool = False,
skip_sub_entities: bool = False,
) -> List[str]:
states = self._get_states()
result: List[str] = []
@@ -206,6 +225,8 @@ class Tools:
eid = s["entity_id"]
if domain and not eid.startswith(domain + "."):
continue
if skip_sub_entities and self._is_sub_entity(eid):
continue
haystack = self._entity_text(s)
if not keywords:
if domain:
@@ -226,10 +247,8 @@ class Tools:
return (s.get("attributes", {}) or {}).get(key, default)
def _resolve_domain(self, cmd: str) -> Optional[str]:
# Приоритет: scene > script (т.к. "сцен" подходит обоим)
for domain in ["scene", "script", "automation", "binary_sensor"]:
if domain in self._DOMAIN_ALIASES:
for a in self._DOMAIN_ALIASES[domain]:
for a in self._DOMAIN_ALIASES.get(domain, []):
if a in cmd:
return domain
for domain, aliases in self._DOMAIN_ALIASES.items():
@@ -265,14 +284,12 @@ class Tools:
return default
def _format_entity(self, s: Dict[str, Any]) -> str:
"""Форматирует сущность для вывода. Пробел между state и unit."""
attrs = s.get("attributes", {}) or {}
fn = attrs.get("friendly_name", s["entity_id"])
state = s.get("state", "?")
unit = attrs.get("unit_of_measurement", "")
area = attrs.get("area", "") or attrs.get("area_id", "")
room = " (" + area + ")" if area else ""
# Разделяем state и unit пробелом только если оба непустые
if state and unit and unit not in str(state):
state_str = str(state) + " " + str(unit)
else:
@@ -338,10 +355,13 @@ class Tools:
return ["свет не найден"]
on_items: List[str] = []
off_items: List[str] = []
unavailable_items: List[str] = []
for eid in entities:
state = self._get_state(eid)
if state == "on":
on_items.append(eid)
elif state == "unavailable":
unavailable_items.append(eid)
else:
off_items.append(eid)
lines: List[str] = []
@@ -354,6 +374,8 @@ class Tools:
break
if off_items:
lines.append("**Выключено:** " + str(len(off_items)) + " шт.")
if unavailable_items:
lines.append("**Недоступно:** " + str(len(unavailable_items)) + " шт.")
return lines
def _monitor_climate(self, tokens: List[str]) -> List[str]:
@@ -511,17 +533,24 @@ class Tools:
return lines
def _monitor_switches(self, tokens: List[str]) -> List[str]:
"""Какие розетки/реле включены."""
"""Какие розетки/реле включены (без sub-entities)."""
cfg = self._MONITOR_DOMAINS["switches"]
entities = self._find_entities(tokens if tokens else cfg["keywords"], "switch")
entities = self._find_entities(
tokens if tokens else cfg["keywords"],
"switch",
skip_sub_entities=True,
)
if not entities:
return ["розетки не найдены"]
on_items: List[str] = []
off_items: List[str] = []
unavailable_items: List[str] = []
for eid in entities:
state = self._get_state(eid)
if state == "on":
on_items.append(eid)
elif state == "unavailable":
unavailable_items.append(eid)
else:
off_items.append(eid)
lines: List[str] = []
@@ -534,10 +563,33 @@ class Tools:
break
if off_items:
lines.append("**Выключено:** " + str(len(off_items)) + " шт.")
if unavailable_items:
lines.append("**Недоступно:** " + str(len(unavailable_items)) + " шт.")
return lines
def _monitor_scenes(self) -> List[str]:
"""Список сцен с последним временем активации."""
states = self._get_states()
scenes = [s for s in states if s["entity_id"].startswith("scene.")]
if not scenes:
return ["Сцены не найдены"]
lines = ["**Сцены:**"]
for s in scenes:
eid = s["entity_id"]
fn = self._attr(s, "friendly_name", eid)
state = s.get("state", "")
attrs = s.get("attributes", {}) or {}
# Для сцен state содержит timestamp последней активации или "unknown"
last_activated = attrs.get("last_activated", "")
description = ""
if state == "unknown":
description = " (ни разу не активировалась)"
elif last_activated:
description = " — последняя: " + str(last_activated)[:19]
lines.append(" * " + fn + description)
return lines
def _monitor_system(self) -> List[str]:
"""Состояние технической инфраструктуры: ноды, бэкапы, обновления, мосты."""
states = self._get_states()
items: List[str] = []
warnings: List[str] = []
@@ -556,7 +608,6 @@ class Tools:
text = (eid + " " + name).lower()
if any(k in text for k in keys_backup):
# Backups Stale: off = всё ОК
if cur == "on":
warnings.append("Бэкапы устарели: " + self._attr(s, "friendly_name", eid))
continue
@@ -567,7 +618,6 @@ class Tools:
continue
if any(k in text for k in keys_node):
# Node kybinka Status: on = OK
if cur == "off":
warnings.append("Нода offline: " + self._attr(s, "friendly_name", eid))
else:
@@ -613,6 +663,8 @@ class Tools:
lines.append("")
lines.extend(self._monitor_battery([]))
lines.append("")
lines.extend(self._monitor_scenes())
lines.append("")
lines.extend(self._monitor_system())
return lines
@@ -683,16 +735,13 @@ class Tools:
if is_question:
matched = False
# ВАЖНО: порядок блоков влияет на маршрутизацию.
# Более специфичные ключевые слова — первыми.
if any(k in cmd for k in ["окн", "двер", "форточ", "калитк", "ворота", "гараж"]):
actions.extend(self._monitor_windows_doors(tokens))
matched = True
elif any(k in cmd for k in ["розетк", "выключател", "реле"]):
actions.extend(self._monitor_switches(tokens))
matched = True
elif any(k in cmd for k in ["свет", "лампочк", "люстр"]) and (
elif any(k in cmd for k in ["свет", "лампочк", "люстр", "спот", "led"]) and (
"горит" in cmd or "включ" in cmd or "лишн" in cmd
):
actions.extend(self._monitor_lights(tokens))
@@ -703,7 +752,6 @@ class Tools:
elif any(k in cmd for k in ["движен", "двигает", "хот", "кто-то ход"]):
actions.extend(self._monitor_motion(tokens))
matched = True
# БАТАРЕЙКИ — раньше климата! иначе "батарейки" → "батаре" → климат
elif "батарейк" in cmd or "аккумулятор" in cmd or "заряд" in cmd:
actions.extend(self._monitor_battery(tokens))
matched = True
@@ -713,6 +761,9 @@ class Tools:
elif any(k in cmd for k in ["музык", "медиа", "плеер", "колонк", "телевизор"]):
actions.extend(self._monitor_media(tokens))
matched = True
elif "сцен" in cmd and ("какие" in cmd or "список" in cmd or "покажи" in cmd):
actions.extend(self._monitor_scenes())
matched = True
elif "дома" in cmd or "присутств" in cmd or "кто" in cmd:
actions.extend(self._monitor_presence(tokens))
matched = True
@@ -742,6 +793,7 @@ class Tools:
" - кто дома?\n"
" - батарейки садятся?\n"
" - что в розетках включено?\n"
" - какие сцены есть?\n"
" - как система? есть ли обновления?\n"
" - покажи все binary_sensor\n"
" - переименуй automation.сертификат в Обновление SSL\n"
@@ -770,19 +822,19 @@ class Tools:
) -> List[str]:
actions: List[str] = []
if "свет" in cmd or "лампочк" in cmd:
if "свет" in cmd or "лампочк" in cmd or "спот" in cmd:
entities = self._find_entities(tokens, "light")
if not entities:
warnings.append("светильники не найдены")
else:
turn_off = any(w in cmd for w in ("выключ", "отключ", "погас"))
brightness = self._extract_number(cmd)
# Локальная команда, если указано имя, площадь или имя человека
is_global = not any(
t in cmd for t in [
"спальн", "гостин", "кухн", "детск", "ванн",
"прихож", "коридор", "кабинет", "зал", "веранд",
"дима", "диан", "елен", "анатоли", "милен",
]
] + list(self._PEOPLE_NAMES)
) and len(entities) > 1
if is_global and self.valves.confirm_global_actions and not self.valves.dry_run:
@@ -817,8 +869,10 @@ class Tools:
label = "выключен" if turn_off else "включён"
actions.append("свет " + label + " (" + str(count) + " шт.)")
if any(k in cmd for k in ["розетк", "выключател", "устройств", "реле"]):
entities = self._find_entities(tokens, "switch")
if any(k in cmd for k in ["розетк", "выключател", "устройств", "реле", "ёлк", "елк", "гирлянд", "телек"]):
entities = self._find_entities(
tokens, "switch", skip_sub_entities=True,
)
if not entities:
warnings.append("розетки не найдены")
else:
@@ -989,7 +1043,6 @@ class Tools:
else:
warnings.append("кнопки не найдены")
# Сцены (домен scene — мгновенное применение)
if any(k in cmd for k in ["сцен"]) and "запуст" in cmd:
all_scenes = self._find_entities([], "scene")
matched: List[str] = []
@@ -1006,7 +1059,6 @@ class Tools:
else:
warnings.append("подходящая сцена не найдена среди " + str(len(all_scenes)))
# Скрипты (run-once)
if "скрипт" in cmd or "запуст" in cmd:
all_scripts = self._find_entities([], "script")
matched = []
@@ -1035,6 +1087,7 @@ class Tools:
only_with_state: str = "",
show_unavailable: bool = True,
limit: int = 50,
skip_sub_entities: bool = False,
) -> str:
if not self.valves.ha_token:
return "Не задан HA Token (Valves)."
@@ -1064,6 +1117,8 @@ class Tools:
if not eid.startswith(domain + "."):
continue
if skip_sub_entities and self._is_sub_entity(eid):
continue
if device_class and dc != device_class:
continue
if only_with_state and cur_state != only_with_state: