diff --git a/tasks/ha-availability-dashboard/appdaemon/availability.py b/tasks/ha-availability-dashboard/appdaemon/availability.py index db8c3c3..dcf04a7 100644 --- a/tasks/ha-availability-dashboard/appdaemon/availability.py +++ b/tasks/ha-availability-dashboard/appdaemon/availability.py @@ -21,6 +21,14 @@ HA_URL = "http://supervisor/core/api" class Availability(hass.Hass): def initialize(self): + # Ensure websocket-client is available for registry queries + try: + import websocket + except ImportError: + import subprocess + subprocess.check_call(["pip", "install", "websocket-client", "-q"]) + self.log("Installed websocket-client") + self._token = None self._entities_cache = [] self._areas_cache = {} @@ -91,63 +99,94 @@ class Availability(hass.Hass): return entities def _fetch_areas(self): - """Fetch area registry and build entity_id -> area mapping.""" - data = self._api_get("/config/area_registry") - if not data: + """Fetch area registry via WebSocket and build entity_id -> area mapping. + + HA registry APIs are only available via WebSocket, not REST. + We use a synchronous websocket-client to fetch the data. + """ + try: + import websocket + import json as _json + except ImportError: + self.log("websocket-client not available, areas will be unavailable", level="WARNING") self._areas_cache = {} return {} - # Build device_id -> area map - device_areas = {} - for area in data: - area_id = area.get("area_id") - area_name = area.get("name", "Без комнаты") - device_areas[area_id] = area_name - - # Now get entity registry to map entity -> device -> area - reg = self._api_get("/config/entity_registry") - if not reg: + token = self._get_token() + if not token: self._areas_cache = {} return {} - eid_to_area = {} - for entry in reg: - eid = entry.get("entity_id", "") - device_id = entry.get("device_id") - area_id = entry.get("area_id") - # Use entity-level area if set, otherwise device-level - if area_id and area_id in device_areas: - eid_to_area[eid] = device_areas[area_id] - elif device_id: - # Try to find device's area via device registry - dev_data = self._api_get(f"/config/device_registry") - # This is expensive; fallback to simple mapping - pass - else: - eid_to_area[eid] = "Без комнаты" + # Connect to HA WebSocket + # From addon, use homeassistant:8123 (not supervisor proxy) + ws_url = "ws://homeassistant:8123/api/websocket" + try: + ws = websocket.create_connection(ws_url, timeout=10, + header=["Authorization: Bearer " + token]) + except Exception as e: + self.log(f"WebSocket connect failed: {e}", level="WARNING") + self._areas_cache = {} + return {} - # Fill from device registry if available - dev_reg = self._api_get("/config/device_registry") - if dev_reg: - dev_area_map = {} - for dev in dev_reg: + def _ws_call(msg_type): + """Send a WebSocket command and return the result.""" + # Auth + result = _json.loads(ws.recv()) + if result.get("type") == "auth_required": + ws.send(_json.dumps({"type": "auth", "access_token": token})) + result = _json.loads(ws.recv()) + if result.get("type") != "auth_ok": + return None + # Send command + ws.send(_json.dumps({"id": msg_type.__hash__() % 10000, "type": msg_type})) + result = _json.loads(ws.recv()) + return result.get("result") if result.get("success") else None + + # Fetch area registry + areas_data = _ws_call("config/area_registry/list") + if not areas_data: + self.log("Failed to fetch area registry via WebSocket", level="WARNING") + ws.close() + self._areas_cache = {} + return {} + + # Build area_id -> area_name map + area_map = {} + for area in areas_data: + area_map[area["area_id"]] = area.get("name", "Без комнаты") + + # Fetch device registry + dev_data = _ws_call("config/device_registry/list") + dev_area = {} + if dev_data and isinstance(dev_data, dict): + dev_data = dev_data.get("devices", dev_data) if isinstance(dev_data, dict) else dev_data + if dev_data and isinstance(dev_data, list): + for dev in dev_data: dev_id = dev.get("id") a_id = dev.get("area_id") - if dev_id and a_id and a_id in device_areas: - dev_area_map[dev_id] = device_areas[a_id] + if dev_id and a_id and a_id in area_map: + dev_area[dev_id] = area_map[a_id] - for entry in reg: + # Fetch entity registry + ent_data = _ws_call("config/entity_registry/list") + eid_to_area = {} + if ent_data and isinstance(ent_data, dict): + ent_data = ent_data.get("entities", ent_data) + if ent_data and isinstance(ent_data, list): + for entry in ent_data: eid = entry.get("entity_id", "") - device_id = entry.get("device_id") - area_id = entry.get("area_id") - if area_id and area_id in device_areas: - eid_to_area[eid] = device_areas[area_id] - elif device_id and device_id in dev_area_map: - eid_to_area[eid] = dev_area_map[device_id] + a_id = entry.get("area_id") + dev_id = entry.get("device_id") + if a_id and a_id in area_map: + eid_to_area[eid] = area_map[a_id] + elif dev_id and dev_id in dev_area: + eid_to_area[eid] = dev_area[dev_id] else: - eid_to_area[eid] = eid_to_area.get(eid, "Без комнаты") + eid_to_area[eid] = "Без комнаты" + ws.close() self._areas_cache = eid_to_area + self.log(f"Fetched {len(area_map)} areas, {len(dev_area)} device-areas, {len(eid_to_area)} entity-areas") return eid_to_area # ── History Fetch ── @@ -176,7 +215,7 @@ class Availability(hass.Hass): self._set_progress(progress) if i < total_chunks - 1: - self.sleep(BATCH_DELAY) + import time; time.sleep(BATCH_DELAY) self._set_progress("idle") return all_history diff --git a/tasks/ha-availability-dashboard/appdaemon/availability_utils.py b/tasks/ha-availability-dashboard/appdaemon/availability_utils.py index 3ad9a31..a8d93d4 100644 --- a/tasks/ha-availability-dashboard/appdaemon/availability_utils.py +++ b/tasks/ha-availability-dashboard/appdaemon/availability_utils.py @@ -58,10 +58,23 @@ def sanitize_entity_id(entity_id: str) -> str: return entity_id.replace(".", "_") +# Cyrillic transliteration table +_CYRILLIC_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch', + 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', +} + + def sanitize_area_name(name: str) -> str: - """Convert area name to safe sensor suffix.""" + """Convert area name to safe sensor suffix (Latin only).""" s = name.lower().strip() - s = re.sub(r"[^a-zа-яё0-9_]", "_", s) + # Transliterate Cyrillic to Latin + s = ''.join(_CYRILLIC_MAP.get(ch, ch) for ch in s) + # Keep only a-z, 0-9, _; replace everything else + s = re.sub(r"[^a-z0-9_]", "_", s) s = re.sub(r"_+", "_", s) return s.strip("_")