v2.4.0 — мониторинг воздуха/движения, фикс батареек, маршрутизация 'покажи', кнопки, общий отчёт
This commit is contained in:
+226
-47
@@ -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.3.0
|
version: 2.4.0
|
||||||
license: MIT
|
license: MIT
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -62,6 +62,28 @@ class Tools:
|
|||||||
"комнате", "квартире", "доме", "гостиной", "спальне", "кухне",
|
"комнате", "квартире", "доме", "гостиной", "спальне", "кухне",
|
||||||
"детской", "ванной", "прихожей", "коридоре", "кабинете",
|
"детской", "ванной", "прихожей", "коридоре", "кабинете",
|
||||||
"и", "а", "но", "или", "же", "бы", "ли",
|
"и", "а", "но", "или", "же", "бы", "ли",
|
||||||
|
"покажи", "список", "какие", "есть", "устройств", "устройства",
|
||||||
|
"устройство", "все", "весь", "вся",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Домены и их алиасы (русский -> домен HA)
|
||||||
|
_DOMAIN_ALIASES = {
|
||||||
|
"binary_sensor": ["binary_sensor", "бинарн", "датчик", "датчики", "сенсор", "сенсоры"],
|
||||||
|
"sensor": ["sensor", "показател", "значение", "значения"],
|
||||||
|
"light": ["light", "свет", "лампочк", "лампа", "люстр", "свитч"],
|
||||||
|
"switch": ["switch", "розетк", "выключател", "реле"],
|
||||||
|
"climate": ["climate", "климат", "кондиционер", "термостат"],
|
||||||
|
"cover": ["cover", "штор", "жалюз", "роллет", "гардин"],
|
||||||
|
"media_player": ["media_player", "медиа", "плеер", "колонк", "телевизор"],
|
||||||
|
"lock": ["lock", "замок"],
|
||||||
|
"fan": ["fan", "вентилятор", "кулер"],
|
||||||
|
"vacuum": ["vacuum", "пылесос", "робот"],
|
||||||
|
"person": ["person", "люди", "пользовател"],
|
||||||
|
"device_tracker": ["device_tracker", "трекер", "геолокац"],
|
||||||
|
"button": ["button", "кнопк"],
|
||||||
|
"automation": ["automation", "автоматизац"],
|
||||||
|
"script": ["script", "скрипт", "сцен", "сценарий"],
|
||||||
|
"input_boolean": ["input_boolean", "переключател", "тумблер"],
|
||||||
}
|
}
|
||||||
|
|
||||||
_RU_NUMS = {
|
_RU_NUMS = {
|
||||||
@@ -102,6 +124,17 @@ class Tools:
|
|||||||
"domains": ["person", "device_tracker"],
|
"domains": ["person", "device_tracker"],
|
||||||
"keywords": ["кто", "дома", "присутств"],
|
"keywords": ["кто", "дома", "присутств"],
|
||||||
},
|
},
|
||||||
|
"motion": {
|
||||||
|
"domains": ["binary_sensor"],
|
||||||
|
"keywords": ["движен", "двигать", "присутств"],
|
||||||
|
"device_classes": ["motion", "occupancy"],
|
||||||
|
},
|
||||||
|
"air_quality": {
|
||||||
|
"domains": ["sensor"],
|
||||||
|
"keywords": ["воздух", "tvoc", "co2", "pm2", "pm10", "влажн", "увлажн"],
|
||||||
|
"device_classes": ["volatile_organic_compounds", "carbon_dioxide",
|
||||||
|
"pm25", "pm10", "humidity", "aqi"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -220,6 +253,14 @@ 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]:
|
||||||
|
"""Определить домен HA по русской команде."""
|
||||||
|
for domain, aliases in self._DOMAIN_ALIASES.items():
|
||||||
|
for a in aliases:
|
||||||
|
if a in cmd:
|
||||||
|
return domain
|
||||||
|
return None
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Парсинг чисел
|
# Парсинг чисел
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -330,62 +371,137 @@ class Tools:
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
def _monitor_climate(self, tokens: List[str]) -> List[str]:
|
def _monitor_climate(self, tokens: List[str]) -> List[str]:
|
||||||
"""Какая температура и состояние климата."""
|
"""Какая температура и состояние климата.
|
||||||
cfg = self._MONITOR_DOMAINS["climate_status"]
|
Берём ТОЛЬКО climate.* или sensor с device_class=temperature/humidity.
|
||||||
entities = self._find_entities(tokens if tokens else cfg["keywords"], "climate")
|
"""
|
||||||
|
|
||||||
if not entities:
|
|
||||||
entities = self._find_entities(tokens if tokens else ["температур"], "sensor")
|
|
||||||
|
|
||||||
if not entities:
|
|
||||||
return ["климат-устройства не найдены"]
|
|
||||||
|
|
||||||
lines = ["**Климат:**"]
|
|
||||||
for eid in entities[:20]:
|
|
||||||
for s in self._get_states():
|
|
||||||
if s["entity_id"] == eid:
|
|
||||||
lines.append(self._format_entity(s))
|
|
||||||
break
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _monitor_battery(self, tokens: List[str]) -> List[str]:
|
|
||||||
"""Какие батарейки садятся."""
|
|
||||||
states = self._get_states()
|
states = self._get_states()
|
||||||
low_battery: List[Tuple[Dict[str, Any], float]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for s in states:
|
for s in states:
|
||||||
eid = s["entity_id"]
|
eid = s["entity_id"]
|
||||||
device_class = self._attr(s, "device_class", "")
|
dc = self._attr(s, "device_class", "")
|
||||||
unit = self._attr(s, "unit_of_measurement", "")
|
|
||||||
|
|
||||||
is_battery = (
|
# Только climate или sensor с релевантным device_class
|
||||||
device_class == "battery"
|
if eid.startswith("climate."):
|
||||||
or "battery" in eid
|
items.append(s)
|
||||||
or unit == "%"
|
elif eid.startswith("sensor.") and dc in (
|
||||||
) and (
|
"temperature", "humidity", "current_temperature",
|
||||||
"battery" in eid
|
):
|
||||||
or device_class == "battery"
|
items.append(s)
|
||||||
or "батарейк" in self._entity_text(s)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_battery:
|
# Фильтр по комнате/токенам
|
||||||
|
if tokens:
|
||||||
|
items = [
|
||||||
|
s for s in items
|
||||||
|
if any(t in self._entity_text(s) for t in tokens)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return ["климат-устройства не найдены"]
|
||||||
|
|
||||||
|
lines = ["**Климат:**"]
|
||||||
|
for s in items[:20]:
|
||||||
|
lines.append(self._format_entity(s))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _monitor_battery(self, tokens: List[str]) -> List[str]:
|
||||||
|
"""Какие батарейки садятся.
|
||||||
|
Учитываем:
|
||||||
|
- sensor.* с device_class=battery и state как число
|
||||||
|
- binary_sensor.* с device_class=battery (on = садится)
|
||||||
|
"""
|
||||||
|
states = self._get_states()
|
||||||
|
low_battery: List[Tuple[Dict[str, Any], float]] = []
|
||||||
|
binary_low: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for s in states:
|
||||||
|
eid = s["entity_id"]
|
||||||
|
dc = self._attr(s, "device_class", "")
|
||||||
|
cur = s.get("state", "")
|
||||||
|
|
||||||
|
# Числовой сенсор батареи
|
||||||
|
if eid.startswith("sensor.") and dc == "battery":
|
||||||
|
if cur in ("unavailable", "unknown", "none", ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
level = float(cur)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if level < 30:
|
||||||
|
low_battery.append((s, level))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
# Бинарный сенсор "батарея разряжена"
|
||||||
level = float(s.get("state", ""))
|
if eid.startswith("binary_sensor.") and dc == "battery":
|
||||||
except (ValueError, TypeError):
|
if cur == "on":
|
||||||
|
binary_low.append(s)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if level < 30:
|
if not low_battery and not binary_low:
|
||||||
low_battery.append((s, level))
|
|
||||||
|
|
||||||
if not low_battery:
|
|
||||||
return ["Все батарейки в норме (>=30%)"]
|
return ["Все батарейки в норме (>=30%)"]
|
||||||
|
|
||||||
low_battery.sort(key=lambda x: x[1])
|
|
||||||
lines = ["**Низкий заряд:**"]
|
lines = ["**Низкий заряд:**"]
|
||||||
|
# Сортируем по возрастанию
|
||||||
|
low_battery.sort(key=lambda x: x[1])
|
||||||
for s, level in low_battery[:15]:
|
for s, level in low_battery[:15]:
|
||||||
lines.append(self._format_entity(s))
|
lines.append(self._format_entity(s))
|
||||||
|
for s in binary_low[:10]:
|
||||||
|
lines.append(" ! " + self._attr(s, "friendly_name", s["entity_id"]) + ": разряжена")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _monitor_motion(self, tokens: List[str]) -> List[str]:
|
||||||
|
"""Где есть движение прямо сейчас."""
|
||||||
|
states = self._get_states()
|
||||||
|
active: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for s in states:
|
||||||
|
eid = s["entity_id"]
|
||||||
|
if not eid.startswith("binary_sensor."):
|
||||||
|
continue
|
||||||
|
dc = self._attr(s, "device_class", "")
|
||||||
|
if dc not in ("motion", "occupancy"):
|
||||||
|
continue
|
||||||
|
if s.get("state") == "on":
|
||||||
|
active.append(s)
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
return ["Нигде нет движения (все датчики молчат)"]
|
||||||
|
|
||||||
|
if tokens:
|
||||||
|
active = [s for s in active if any(t in self._entity_text(s) for t in tokens)]
|
||||||
|
|
||||||
|
lines = ["**Движение обнаружено:**"]
|
||||||
|
for s in active[:15]:
|
||||||
|
lines.append(self._format_entity(s))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _monitor_air_quality(self, tokens: List[str]) -> List[str]:
|
||||||
|
"""Качество воздуха: TVOC, CO2, PM2.5/10, влажность."""
|
||||||
|
states = self._get_states()
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
target_dc = {
|
||||||
|
"volatile_organic_compounds", "carbon_dioxide",
|
||||||
|
"pm25", "pm10", "humidity", "aqi",
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in states:
|
||||||
|
eid = s["entity_id"]
|
||||||
|
if not eid.startswith("sensor."):
|
||||||
|
continue
|
||||||
|
dc = self._attr(s, "device_class", "")
|
||||||
|
name = (self._attr(s, "friendly_name", "") or "").lower()
|
||||||
|
if dc in target_dc or any(k in name for k in ["tvoc", "co2", "влажн", "воздух", "pm2", "pm10"]):
|
||||||
|
items.append(s)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return ["датчики качества воздуха не найдены"]
|
||||||
|
|
||||||
|
if tokens:
|
||||||
|
items = [s for s in items if any(t in self._entity_text(s) for t in tokens)]
|
||||||
|
|
||||||
|
lines = ["**Качество воздуха:**"]
|
||||||
|
for s in items[:15]:
|
||||||
|
lines.append(self._format_entity(s))
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def _monitor_media(self, tokens: List[str]) -> List[str]:
|
def _monitor_media(self, tokens: List[str]) -> List[str]:
|
||||||
@@ -448,6 +564,24 @@ class Tools:
|
|||||||
lines.append(" (информация отсутствует)")
|
lines.append(" (информация отсутствует)")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
def _monitor_full_report(self) -> List[str]:
|
||||||
|
"""Общий отчёт по дому одной фразой."""
|
||||||
|
lines = ["**Отчёт по дому:**", ""]
|
||||||
|
lines.extend(self._monitor_presence([]))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(self._monitor_windows_doors([]))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(self._monitor_lights([]))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(self._monitor_climate([]))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(self._monitor_air_quality([]))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(self._monitor_motion([]))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(self._monitor_battery([]))
|
||||||
|
return lines
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Главная точка входа
|
# Главная точка входа
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -465,6 +599,25 @@ class Tools:
|
|||||||
warnings: List[str] = []
|
warnings: List[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# ============================================================
|
||||||
|
# Маршрутизация 'покажи все / список устройств' -> list_devices
|
||||||
|
# ============================================================
|
||||||
|
if any(w in cmd for w in [
|
||||||
|
"покажи", "список устройств", "какие устройств",
|
||||||
|
"что есть", "инвентар", "все устройств",
|
||||||
|
]):
|
||||||
|
target = self._resolve_domain(cmd)
|
||||||
|
return self.list_devices(domain=target or "", limit=80)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Общий отчёт
|
||||||
|
# ============================================================
|
||||||
|
if "отчет" in cmd or "отчёт" in cmd or "обзор" in cmd or "все вместе" in cmd:
|
||||||
|
return "\n".join(self._monitor_full_report())
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# РЕЖИМ МОНИТОРИНГА (вопросы о состоянии)
|
||||||
|
# ============================================================
|
||||||
is_question = (
|
is_question = (
|
||||||
cmd.startswith(("как", "сколько", "что", "кто", "где", "какая", "какой", "какое", "какие"))
|
cmd.startswith(("как", "сколько", "что", "кто", "где", "какая", "какой", "какое", "какие"))
|
||||||
or "?" in cmd
|
or "?" in cmd
|
||||||
@@ -486,6 +639,14 @@ class Tools:
|
|||||||
actions.extend(self._monitor_lights(tokens))
|
actions.extend(self._monitor_lights(tokens))
|
||||||
matched = True
|
matched = True
|
||||||
|
|
||||||
|
elif any(k in cmd for k in ["воздух", "tvoc", "co2", "влажн", "качеств", "pm2", "pm10"]):
|
||||||
|
actions.extend(self._monitor_air_quality(tokens))
|
||||||
|
matched = True
|
||||||
|
|
||||||
|
elif any(k in cmd for k in ["движен", "двигает", "хот", "кто-то ход"]):
|
||||||
|
actions.extend(self._monitor_motion(tokens))
|
||||||
|
matched = True
|
||||||
|
|
||||||
elif any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]):
|
elif any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]):
|
||||||
actions.extend(self._monitor_climate(tokens))
|
actions.extend(self._monitor_climate(tokens))
|
||||||
matched = True
|
matched = True
|
||||||
@@ -517,9 +678,15 @@ class Tools:
|
|||||||
" - выключи весь свет\n"
|
" - выключи весь свет\n"
|
||||||
" - поставь 23 градуса в детской\n"
|
" - поставь 23 градуса в детской\n"
|
||||||
" - открой шторы в гостиной\n"
|
" - открой шторы в гостиной\n"
|
||||||
|
" - нажми кнопку в спальне\n"
|
||||||
" - какие окна открыты?\n"
|
" - какие окна открыты?\n"
|
||||||
" - какая температура в спальне?\n"
|
" - какая температура в спальне?\n"
|
||||||
" - кто дома?"
|
" - как воздух в квартире?\n"
|
||||||
|
" - где есть движение?\n"
|
||||||
|
" - кто дома?\n"
|
||||||
|
" - батарейки садятся?\n"
|
||||||
|
" - покажи все binary_sensor\n"
|
||||||
|
" - отчёт по дому"
|
||||||
)
|
)
|
||||||
if warnings:
|
if warnings:
|
||||||
return "Не удалось выполнить. " + ". ".join(warnings) + "\n\n" + hint
|
return "Не удалось выполнить. " + ". ".join(warnings) + "\n\n" + hint
|
||||||
@@ -555,7 +722,8 @@ class Tools:
|
|||||||
is_global = not any(
|
is_global = not any(
|
||||||
t in cmd for t in [
|
t in cmd for t in [
|
||||||
"спальн", "гостин", "кухн", "детск", "ванн",
|
"спальн", "гостин", "кухн", "детск", "ванн",
|
||||||
"прихож", "коридор", "кабинет", "зал",
|
"прихож", "коридор", "кабинет", "зал", "веранд",
|
||||||
|
"дима", "диан", "елен", "анатоли",
|
||||||
]
|
]
|
||||||
) and len(entities) > 1
|
) and len(entities) > 1
|
||||||
|
|
||||||
@@ -591,8 +759,8 @@ 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")
|
||||||
if not entities:
|
if not entities:
|
||||||
warnings.append("розетки не найдены")
|
warnings.append("розетки не найдены")
|
||||||
@@ -763,6 +931,18 @@ class Tools:
|
|||||||
self._call_service("vacuum", "start", {"entity_id": e})
|
self._call_service("vacuum", "start", {"entity_id": e})
|
||||||
actions.append("пылесос уборка")
|
actions.append("пылесос уборка")
|
||||||
|
|
||||||
|
# Кнопки (button.press)
|
||||||
|
if "кнопк" in cmd and any(w in cmd for w in ("нажм", "кликн", "тапн", "триггерн", "активир")):
|
||||||
|
entities = self._find_entities(tokens, "button")
|
||||||
|
if entities:
|
||||||
|
count = 0
|
||||||
|
for e in entities:
|
||||||
|
self._call_service("button", "press", {"entity_id": e})
|
||||||
|
count += 1
|
||||||
|
actions.append("кнопки нажаты (" + str(count) + " шт.)")
|
||||||
|
else:
|
||||||
|
warnings.append("кнопки не найдены")
|
||||||
|
|
||||||
# Сцены / скрипты
|
# Сцены / скрипты
|
||||||
if any(k in cmd for k in ["сцен", "режим", "запуст"]):
|
if any(k in cmd for k in ["сцен", "режим", "запуст"]):
|
||||||
all_scripts = self._find_entities([], "script")
|
all_scripts = self._find_entities([], "script")
|
||||||
@@ -837,7 +1017,6 @@ class Tools:
|
|||||||
# Фильтр по домену
|
# Фильтр по домену
|
||||||
if domain:
|
if domain:
|
||||||
if domain == "window_door":
|
if domain == "window_door":
|
||||||
# Спецкатегория: всё что похоже на окно/дверь
|
|
||||||
if dc not in ("door", "window", "garage_door", "lock") and \
|
if dc not in ("door", "window", "garage_door", "lock") and \
|
||||||
not any(k in (eid + " " + (attrs.get("friendly_name", "") or "").lower())
|
not any(k in (eid + " " + (attrs.get("friendly_name", "") or "").lower())
|
||||||
for k in ["окн", "двер", "форточ", "балкон", "калитк", "ворота", "гараж"]):
|
for k in ["окн", "двер", "форточ", "балкон", "калитк", "ворота", "гараж"]):
|
||||||
|
|||||||
Reference in New Issue
Block a user