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