diff --git a/home_assistant_tools.py b/home_assistant_tools.py index 102d13b..f4a8137 100644 --- a/home_assistant_tools.py +++ b/home_assistant_tools.py @@ -2,7 +2,7 @@ title: Home Assistant Smart Control description: Управление умным домом через Home Assistant REST API с интерпретацией естественного языка. author: ChatGPT (refactored by MiniMax-M3) -version: 2.5.1 +version: 2.5.2 license: MIT """ @@ -50,7 +50,7 @@ class Tools: "sensor": ["sensor", "показател", "значение", "значения"], "light": ["light", "свет", "лампочк", "лампа", "люстр", "свитч"], "switch": ["switch", "розетк", "выключател", "реле"], - "climate": ["climate", "климат", "кондиционер", "термостат"], + "climate": ["climate", "климат", "климата", "кондиционер", "термостат", "пол", "полотенц"], "cover": ["cover", "штор", "жалюз", "роллет", "гардин"], "media_player": ["media_player", "медиа", "плеер", "колонк", "телевизор"], "lock": ["lock", "замок"], @@ -60,7 +60,8 @@ class Tools: "device_tracker": ["device_tracker", "трекер", "геолокац"], "button": ["button", "кнопк"], "automation": ["automation", "автоматизац"], - "script": ["script", "скрипт", "сцен", "сценарий"], + "script": ["script", "скрипт"], + "scene": ["scene", "сцен"], "input_boolean": ["input_boolean", "переключател", "тумблер"], } @@ -80,8 +81,6 @@ class Tools: "domains": ["binary_sensor"], "open_states": ("open", "on", "unlocked", "detected"), "closed_states": ("closed", "off", "locked", "clear"), - # Допустимые device_class для окон/дверей/замков. - # Если в HA не задан device_class — сущность игнорируется. "valid_device_classes": ("door", "window", "garage_door", "lock"), }, "light_status": { @@ -89,7 +88,7 @@ class Tools: "keywords": ["свет", "лампочк", "люстр"], }, "climate_status": { - "domains": ["climate"], + "domains": ["climate", "sensor"], "keywords": ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"], }, "battery": { @@ -115,6 +114,15 @@ class Tools: "device_classes": ["volatile_organic_compounds", "carbon_dioxide", "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]: @@ -218,7 +226,15 @@ 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]: + if a in cmd: + return domain for domain, aliases in self._DOMAIN_ALIASES.items(): + if domain in ("scene", "script", "automation", "binary_sensor"): + continue for a in aliases: if a in cmd: return domain @@ -249,25 +265,24 @@ 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 "" - 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]: - """ - Какие окна/двери открыты. - - Строгий фильтр: только binary_sensor с device_class в - (door, window, garage_door, lock). Это исключает ложные - срабатывания на датчики движения, батареи штор, шторы Aqara и т.п. - """ cfg = self._MONITOR_DOMAINS["window_door"] valid_dc = cfg["valid_device_classes"] states = self._get_states() @@ -298,9 +313,13 @@ class Tools: for s in open_items: lines.append(self._format_entity(s)) if closed_items: - lines.append("Закрыто: " + str(len(closed_items)) + " шт.") + lines.append("**Закрыто:**") + for s in closed_items: + lines.append(self._format_entity(s)) if unknown: - lines.append("Неизвестно: " + str(len(unknown)) + " шт.") + lines.append("**Неизвестно** (state не в open/closed):") + for s in unknown: + lines.append(self._format_entity(s)) if tokens: filtered = [] @@ -327,14 +346,14 @@ class Tools: off_items.append(eid) lines: List[str] = [] if on_items: - lines.append("Горит: " + str(len(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)) + " шт.") + lines.append("**Выключено:** " + str(len(off_items)) + " шт.") return lines def _monitor_climate(self, tokens: List[str]) -> List[str]: @@ -491,6 +510,91 @@ class Tools: lines.append(" (информация отсутствует)") 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]: lines = ["**Отчёт по дому:**", ""] lines.extend(self._monitor_presence([])) @@ -499,6 +603,8 @@ class Tools: lines.append("") lines.extend(self._monitor_lights([])) lines.append("") + lines.extend(self._monitor_switches([])) + lines.append("") lines.extend(self._monitor_climate([])) lines.append("") lines.extend(self._monitor_air_quality([])) @@ -506,6 +612,8 @@ class Tools: lines.extend(self._monitor_motion([])) lines.append("") lines.extend(self._monitor_battery([])) + lines.append("") + lines.extend(self._monitor_system()) return lines # ======================================================================== @@ -575,9 +683,15 @@ 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 ( "горит" in cmd or "включ" in cmd or "лишн" in cmd ): @@ -589,18 +703,22 @@ class Tools: elif any(k in cmd for k in ["движен", "двигает", "хот", "кто-то ход"]): actions.extend(self._monitor_motion(tokens)) matched = True - elif any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]): - actions.extend(self._monitor_climate(tokens)) - matched = True - elif "батарейк" in cmd or "заряд" in cmd: + # БАТАРЕЙКИ — раньше климата! иначе "батарейки" → "батаре" → климат + elif "батарейк" in cmd or "аккумулятор" in cmd or "заряд" in cmd: actions.extend(self._monitor_battery(tokens)) 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 ["музык", "медиа", "плеер", "колонк", "телевизор"]): actions.extend(self._monitor_media(tokens)) matched = True - elif "дома" in cmd or "присутств" in cmd: + elif "дома" in cmd or "присутств" in cmd or "кто" in cmd: actions.extend(self._monitor_presence(tokens)) matched = True + elif any(k in cmd for k in ["систем", "нод", "бэкап", "backup", "обновлен", "мост"]): + actions.extend(self._monitor_system()) + matched = True if not matched: actions.append("**Общий статус дома:**") @@ -623,6 +741,8 @@ class Tools: " - где есть движение?\n" " - кто дома?\n" " - батарейки садятся?\n" + " - что в розетках включено?\n" + " - как система? есть ли обновления?\n" " - покажи все binary_sensor\n" " - переименуй automation.сертификат в Обновление SSL\n" " - выключи автоматизацию Гирлянда крыльцо\n" @@ -661,7 +781,7 @@ class Tools: t in cmd for t in [ "спальн", "гостин", "кухн", "детск", "ванн", "прихож", "коридор", "кабинет", "зал", "веранд", - "дима", "диан", "елен", "анатоли", + "дима", "диан", "елен", "анатоли", "милен", ] ) and len(entities) > 1 @@ -869,9 +989,27 @@ class Tools: else: warnings.append("кнопки не найдены") - if any(k in cmd for k in ["сцен", "режим", "запуст"]): - all_scripts = self._find_entities([], "script") + # Сцены (домен scene — мгновенное применение) + if any(k in cmd for k in ["сцен"]) and "запуст" in cmd: + all_scenes = self._find_entities([], "scene") 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: sl = s.lower() for t in tokens: @@ -883,7 +1021,7 @@ class Tools: self._call_service("script", "turn_on", {"entity_id": s}) actions.append("SCRIPT " + s) else: - warnings.append("подходящая сцена не найдена среди " + str(len(all_scripts))) + warnings.append("подходящий скрипт не найден среди " + str(len(all_scripts))) return actions @@ -918,7 +1056,6 @@ class Tools: if domain: if domain == "window_door": - # Строгий фильтр: только binary_sensor с правильным device_class if not eid.startswith("binary_sensor."): continue if dc not in ("door", "window", "garage_door", "lock"):