From 000d7a26347b01bf562860d47cd32f69299fd1d6 Mon Sep 17 00:00:00 2001 From: host Date: Wed, 29 Jul 2026 20:20:29 +0300 Subject: [PATCH] =?UTF-8?q?v2.5.3=20=E2=80=94=20=D0=BF=D0=BE=D0=B4=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85?= =?UTF-8?q?=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2:=20Ye?= =?UTF-8?q?elight,=20=D1=91=D0=BB=D0=BA=D0=B0,=20=D0=B3=D0=B8=D1=80=D0=BB?= =?UTF-8?q?=D1=8F=D0=BD=D0=B4=D0=B0,=20=D0=A2=D0=92;=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=20sub-entities=20switch;=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D1=81=D1=86?= =?UTF-8?q?=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- home_assistant_tools.py | 115 +++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 30 deletions(-) diff --git a/home_assistant_tools.py b/home_assistant_tools.py index f4a8137..96afd4d 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.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,12 +247,10 @@ 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 a in self._DOMAIN_ALIASES.get(domain, []): + if a in cmd: + return domain for domain, aliases in self._DOMAIN_ALIASES.items(): if domain in ("scene", "script", "automation", "binary_sensor"): continue @@ -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: