v2.5.3 — поддержка новых устройств: Yeelight, ёлка, гирлянда, ТВ; фильтр sub-entities switch; мониторинг сцен
This commit is contained in:
+83
-28
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user