v2.5.2 — фикс порядка блоков (батареи), фикс форматирования, список 'Неизвестно', системный статус, поддержка scene, мониторинг switch

This commit is contained in:
2026-07-29 20:13:22 +03:00
parent 6c1c1a11ed
commit 3d137d7694
+165 -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.1 version: 2.5.2
license: MIT license: MIT
""" """
@@ -50,7 +50,7 @@ class Tools:
"sensor": ["sensor", "показател", "значение", "значения"], "sensor": ["sensor", "показател", "значение", "значения"],
"light": ["light", "свет", "лампочк", "лампа", "люстр", "свитч"], "light": ["light", "свет", "лампочк", "лампа", "люстр", "свитч"],
"switch": ["switch", "розетк", "выключател", "реле"], "switch": ["switch", "розетк", "выключател", "реле"],
"climate": ["climate", "климат", "кондиционер", "термостат"], "climate": ["climate", "климат", "климата", "кондиционер", "термостат", "пол", "полотенц"],
"cover": ["cover", "штор", "жалюз", "роллет", "гардин"], "cover": ["cover", "штор", "жалюз", "роллет", "гардин"],
"media_player": ["media_player", "медиа", "плеер", "колонк", "телевизор"], "media_player": ["media_player", "медиа", "плеер", "колонк", "телевизор"],
"lock": ["lock", "замок"], "lock": ["lock", "замок"],
@@ -60,7 +60,8 @@ class Tools:
"device_tracker": ["device_tracker", "трекер", "геолокац"], "device_tracker": ["device_tracker", "трекер", "геолокац"],
"button": ["button", "кнопк"], "button": ["button", "кнопк"],
"automation": ["automation", "автоматизац"], "automation": ["automation", "автоматизац"],
"script": ["script", "скрипт", "сцен", "сценарий"], "script": ["script", "скрипт"],
"scene": ["scene", "сцен"],
"input_boolean": ["input_boolean", "переключател", "тумблер"], "input_boolean": ["input_boolean", "переключател", "тумблер"],
} }
@@ -80,8 +81,6 @@ class Tools:
"domains": ["binary_sensor"], "domains": ["binary_sensor"],
"open_states": ("open", "on", "unlocked", "detected"), "open_states": ("open", "on", "unlocked", "detected"),
"closed_states": ("closed", "off", "locked", "clear"), "closed_states": ("closed", "off", "locked", "clear"),
# Допустимые device_class для окон/дверей/замков.
# Если в HA не задан device_class — сущность игнорируется.
"valid_device_classes": ("door", "window", "garage_door", "lock"), "valid_device_classes": ("door", "window", "garage_door", "lock"),
}, },
"light_status": { "light_status": {
@@ -89,7 +88,7 @@ class Tools:
"keywords": ["свет", "лампочк", "люстр"], "keywords": ["свет", "лампочк", "люстр"],
}, },
"climate_status": { "climate_status": {
"domains": ["climate"], "domains": ["climate", "sensor"],
"keywords": ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"], "keywords": ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"],
}, },
"battery": { "battery": {
@@ -115,6 +114,15 @@ class Tools:
"device_classes": ["volatile_organic_compounds", "carbon_dioxide", "device_classes": ["volatile_organic_compounds", "carbon_dioxide",
"pm25", "pm10", "humidity", "aqi"], "pm25", "pm10", "humidity", "aqi"],
}, },
"switches": {
"domains": ["switch"],
"keywords": ["розетк", "выключател", "реле"],
},
"system": {
"domains": ["binary_sensor"],
"keywords": ["бэкап", "backup", "stale", "updates", "updates_package",
"node", "status", "connection", "gateway", "bridge"],
},
} }
def _headers(self) -> Dict[str, str]: def _headers(self) -> Dict[str, str]:
@@ -218,7 +226,15 @@ 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"]:
if domain in self._DOMAIN_ALIASES:
for a in self._DOMAIN_ALIASES[domain]:
if a in cmd:
return domain
for domain, aliases in self._DOMAIN_ALIASES.items(): for domain, aliases in self._DOMAIN_ALIASES.items():
if domain in ("scene", "script", "automation", "binary_sensor"):
continue
for a in aliases: for a in aliases:
if a in cmd: if a in cmd:
return domain return domain
@@ -249,25 +265,24 @@ 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 ""
return " * " + fn + room + ": " + str(state) + str(unit) # Разделяем state и unit пробелом только если оба непустые
if state and unit and unit not in str(state):
state_str = str(state) + " " + str(unit)
else:
state_str = str(state)
return " * " + fn + room + ": " + state_str
# ======================================================================== # ========================================================================
# Мониторинг # Мониторинг
# ======================================================================== # ========================================================================
def _monitor_windows_doors(self, tokens: List[str]) -> List[str]: def _monitor_windows_doors(self, tokens: List[str]) -> List[str]:
"""
Какие окна/двери открыты.
Строгий фильтр: только binary_sensor с device_class в
(door, window, garage_door, lock). Это исключает ложные
срабатывания на датчики движения, батареи штор, шторы Aqara и т.п.
"""
cfg = self._MONITOR_DOMAINS["window_door"] cfg = self._MONITOR_DOMAINS["window_door"]
valid_dc = cfg["valid_device_classes"] valid_dc = cfg["valid_device_classes"]
states = self._get_states() states = self._get_states()
@@ -298,9 +313,13 @@ class Tools:
for s in open_items: for s in open_items:
lines.append(self._format_entity(s)) lines.append(self._format_entity(s))
if closed_items: if closed_items:
lines.append("Закрыто: " + str(len(closed_items)) + " шт.") lines.append("**Закрыто:**")
for s in closed_items:
lines.append(self._format_entity(s))
if unknown: if unknown:
lines.append("Неизвестно: " + str(len(unknown)) + " шт.") lines.append("**Неизвестно** (state не в open/closed):")
for s in unknown:
lines.append(self._format_entity(s))
if tokens: if tokens:
filtered = [] filtered = []
@@ -327,14 +346,14 @@ class Tools:
off_items.append(eid) off_items.append(eid)
lines: List[str] = [] lines: List[str] = []
if on_items: if on_items:
lines.append("Горит: " + str(len(on_items)) + " шт.") lines.append("**Горит:** " + str(len(on_items)) + " шт.")
for eid in on_items[:15]: for eid in on_items[:15]:
for s in self._get_states(): for s in self._get_states():
if s["entity_id"] == eid: if s["entity_id"] == eid:
lines.append(self._format_entity(s)) lines.append(self._format_entity(s))
break break
if off_items: if off_items:
lines.append("Выключено: " + str(len(off_items)) + " шт.") lines.append("**Выключено:** " + str(len(off_items)) + " шт.")
return lines return lines
def _monitor_climate(self, tokens: List[str]) -> List[str]: def _monitor_climate(self, tokens: List[str]) -> List[str]:
@@ -491,6 +510,91 @@ class Tools:
lines.append(" (информация отсутствует)") lines.append(" (информация отсутствует)")
return lines return lines
def _monitor_switches(self, tokens: List[str]) -> List[str]:
"""Какие розетки/реле включены."""
cfg = self._MONITOR_DOMAINS["switches"]
entities = self._find_entities(tokens if tokens else cfg["keywords"], "switch")
if not entities:
return ["розетки не найдены"]
on_items: List[str] = []
off_items: List[str] = []
for eid in entities:
state = self._get_state(eid)
if state == "on":
on_items.append(eid)
else:
off_items.append(eid)
lines: List[str] = []
if on_items:
lines.append("**Включено:** " + str(len(on_items)) + " шт.")
for eid in on_items[:15]:
for s in self._get_states():
if s["entity_id"] == eid:
lines.append(self._format_entity(s))
break
if off_items:
lines.append("**Выключено:** " + str(len(off_items)) + " шт.")
return lines
def _monitor_system(self) -> List[str]:
"""Состояние технической инфраструктуры: ноды, бэкапы, обновления, мосты."""
states = self._get_states()
items: List[str] = []
warnings: List[str] = []
keys_node = ("node_", "node ")
keys_update = ("updates", "package")
keys_backup = ("backup", "stale")
keys_conn = ("connection", "gateway", "bridge")
for s in states:
eid = s["entity_id"]
if not eid.startswith("binary_sensor."):
continue
name = (self._attr(s, "friendly_name", "") or "").lower()
cur = s.get("state", "")
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
if any(k in text for k in keys_update):
if cur == "on":
warnings.append("Доступны обновления: " + self._attr(s, "friendly_name", eid))
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:
items.append(self._format_entity(s))
continue
if any(k in text for k in keys_conn):
if cur == "off":
warnings.append("Соединение потеряно: " + self._attr(s, "friendly_name", eid))
else:
items.append(self._format_entity(s))
continue
lines = ["**Системный статус:**"]
if items:
lines.append("OK:")
for s in items:
lines.append(s)
if warnings:
lines.append("")
lines.append("⚠️ **Требует внимания:**")
for w in warnings:
lines.append(" - " + w)
if not items and not warnings:
lines.append("Нет данных о системе")
return lines
def _monitor_full_report(self) -> List[str]: def _monitor_full_report(self) -> List[str]:
lines = ["**Отчёт по дому:**", ""] lines = ["**Отчёт по дому:**", ""]
lines.extend(self._monitor_presence([])) lines.extend(self._monitor_presence([]))
@@ -499,6 +603,8 @@ class Tools:
lines.append("") lines.append("")
lines.extend(self._monitor_lights([])) lines.extend(self._monitor_lights([]))
lines.append("") lines.append("")
lines.extend(self._monitor_switches([]))
lines.append("")
lines.extend(self._monitor_climate([])) lines.extend(self._monitor_climate([]))
lines.append("") lines.append("")
lines.extend(self._monitor_air_quality([])) lines.extend(self._monitor_air_quality([]))
@@ -506,6 +612,8 @@ class Tools:
lines.extend(self._monitor_motion([])) lines.extend(self._monitor_motion([]))
lines.append("") lines.append("")
lines.extend(self._monitor_battery([])) lines.extend(self._monitor_battery([]))
lines.append("")
lines.extend(self._monitor_system())
return lines return lines
# ======================================================================== # ========================================================================
@@ -575,9 +683,15 @@ 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 ["розетк", "выключател", "реле"]):
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 ["свет", "лампочк", "люстр"]) and (
"горит" in cmd or "включ" in cmd or "лишн" in cmd "горит" in cmd or "включ" in cmd or "лишн" in cmd
): ):
@@ -589,18 +703,22 @@ 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 any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]): # БАТАРЕЙКИ — раньше климата! иначе "батарейки" → "батаре" → климат
actions.extend(self._monitor_climate(tokens)) elif "батарейк" in cmd or "аккумулятор" in cmd or "заряд" in cmd:
matched = True
elif "батарейк" in cmd or "заряд" in cmd:
actions.extend(self._monitor_battery(tokens)) actions.extend(self._monitor_battery(tokens))
matched = True matched = True
elif any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер"]):
actions.extend(self._monitor_climate(tokens))
matched = True
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 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
elif any(k in cmd for k in ["систем", "нод", "бэкап", "backup", "обновлен", "мост"]):
actions.extend(self._monitor_system())
matched = True
if not matched: if not matched:
actions.append("**Общий статус дома:**") actions.append("**Общий статус дома:**")
@@ -623,6 +741,8 @@ class Tools:
" - где есть движение?\n" " - где есть движение?\n"
" - кто дома?\n" " - кто дома?\n"
" - батарейки садятся?\n" " - батарейки садятся?\n"
" - что в розетках включено?\n"
" - как система? есть ли обновления?\n"
" - покажи все binary_sensor\n" " - покажи все binary_sensor\n"
" - переименуй automation.сертификат в Обновление SSL\n" " - переименуй automation.сертификат в Обновление SSL\n"
" - выключи автоматизацию Гирлянда крыльцо\n" " - выключи автоматизацию Гирлянда крыльцо\n"
@@ -661,7 +781,7 @@ class Tools:
t in cmd for t in [ t in cmd for t in [
"спальн", "гостин", "кухн", "детск", "ванн", "спальн", "гостин", "кухн", "детск", "ванн",
"прихож", "коридор", "кабинет", "зал", "веранд", "прихож", "коридор", "кабинет", "зал", "веранд",
"дима", "диан", "елен", "анатоли", "дима", "диан", "елен", "анатоли", "милен",
] ]
) and len(entities) > 1 ) and len(entities) > 1
@@ -869,9 +989,27 @@ class Tools:
else: else:
warnings.append("кнопки не найдены") warnings.append("кнопки не найдены")
if any(k in cmd for k in ["сцен", "режим", "запуст"]): # Сцены (домен scene — мгновенное применение)
all_scripts = self._find_entities([], "script") if any(k in cmd for k in ["сцен"]) and "запуст" in cmd:
all_scenes = self._find_entities([], "scene")
matched: List[str] = [] matched: List[str] = []
for s in all_scenes:
sl = s.lower()
for t in tokens:
if len(t) >= 3 and t in sl:
matched.append(s)
break
if matched:
for s in matched:
self._call_service("scene", "turn_on", {"entity_id": s})
actions.append("SCENE " + s)
else:
warnings.append("подходящая сцена не найдена среди " + str(len(all_scenes)))
# Скрипты (run-once)
if "скрипт" in cmd or "запуст" in cmd:
all_scripts = self._find_entities([], "script")
matched = []
for s in all_scripts: for s in all_scripts:
sl = s.lower() sl = s.lower()
for t in tokens: for t in tokens:
@@ -883,7 +1021,7 @@ class Tools:
self._call_service("script", "turn_on", {"entity_id": s}) self._call_service("script", "turn_on", {"entity_id": s})
actions.append("SCRIPT " + s) actions.append("SCRIPT " + s)
else: else:
warnings.append("подходящая сцена не найдена среди " + str(len(all_scripts))) warnings.append("подходящий скрипт не найден среди " + str(len(all_scripts)))
return actions return actions
@@ -918,7 +1056,6 @@ class Tools:
if domain: if domain:
if domain == "window_door": if domain == "window_door":
# Строгий фильтр: только binary_sensor с правильным device_class
if not eid.startswith("binary_sensor."): if not eid.startswith("binary_sensor."):
continue continue
if dc not in ("door", "window", "garage_door", "lock"): if dc not in ("door", "window", "garage_door", "lock"):