Files
RTKey/custom_components/rtkey/__init__.py
T

687 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import functools
import logging
import time
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector
DOMAIN = "rtkey"
PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
# === Константы настроек ===
CONF_NAME = "name"
CONF_TOKEN = "token"
CONF_CAMERA_IMAGE_REFRESH_INTERVAL = "camera_image_refresh_interval"
CONF_SCREENSHOT_QUALITY = "screenshot_quality"
CONF_LOG_LEVEL = "log_level"
# Настройки событий
CONF_EVENTS_ENABLED = "events_enabled"
CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval"
CONF_EVENT_TIME_LAG = "event_time_lag"
CONF_PRE_EVENT_SECONDS = "pre_event_seconds"
CONF_EVENT_SCREENSHOT_ENABLED = "event_screenshot_enabled"
CONF_EVENT_SCREENSHOT_QUALITY = "event_screenshot_quality"
CONF_EVENT_ARCHIVE_ENABLED = "event_archive_enabled"
CONF_ARCHIVE_DURATION = "archive_duration"
CONF_EVENT_SENSOR_ENABLED = "event_sensor_enabled"
TOKEN_REFRESH_BUFFER = 300
DATA_SCHEMA = {
vol.Required(CONF_NAME): str,
vol.Required(CONF_TOKEN): str,
}
OPTIONS_SCHEMA = {
vol.Optional(CONF_NAME): str,
vol.Optional(CONF_TOKEN): str,
vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=600, step=30,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек",
)
),
vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="small", label="Маленькое (быстрее)"),
selector.SelectOptionDict(value="medium", label="Среднее"),
selector.SelectOptionDict(value="large", label="Большое"),
selector.SelectOptionDict(value="precise", label="Точное (медленнее)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="DEBUG", label="DEBUG (подробно)"),
selector.SelectOptionDict(value="INFO", label="INFO (обычный)"),
selector.SelectOptionDict(value="WARNING", label="WARNING (только предупреждения)"),
selector.SelectOptionDict(value="ERROR", label="ERROR (только ошибки)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_EVENTS_ENABLED, default=True): bool,
vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector(
selector.NumberSelectorConfig(
min=5, max=60, step=5,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек",
)
),
vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=60, step=5,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек",
)
),
vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=30, step=5,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек",
)
),
vol.Optional(CONF_EVENT_SCREENSHOT_ENABLED, default=True): bool,
vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="small", label="Маленькое"),
selector.SelectOptionDict(value="medium", label="Среднее"),
selector.SelectOptionDict(value="large", label="Большое"),
selector.SelectOptionDict(value="precise", label="Точное"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_EVENT_ARCHIVE_ENABLED, default=True): bool,
vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector(
selector.NumberSelectorConfig(
min=10, max=120, step=10,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек",
)
),
vol.Optional(CONF_EVENT_SENSOR_ENABLED, default=True): bool,
}
class RTKeyCamerasApi:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.hass = hass
self.entry = entry
self.token = entry.options.get(CONF_TOKEN) or entry.data.get(CONF_TOKEN, "")
self.screenshot_quality = entry.options.get(CONF_SCREENSHOT_QUALITY, "medium")
# Настройки событий
self.events_enabled = entry.options.get(CONF_EVENTS_ENABLED, True)
self.events_refresh_interval = entry.options.get(CONF_EVENTS_REFRESH_INTERVAL, 15)
self.event_time_lag = entry.options.get(CONF_EVENT_TIME_LAG, 25)
self.pre_event_seconds = entry.options.get(CONF_PRE_EVENT_SECONDS, 10)
self.event_screenshot_enabled = entry.options.get(CONF_EVENT_SCREENSHOT_ENABLED, True)
self.event_screenshot_quality = entry.options.get(CONF_EVENT_SCREENSHOT_QUALITY, "large")
self.event_archive_enabled = entry.options.get(CONF_EVENT_ARCHIVE_ENABLED, True)
self.archive_duration = entry.options.get(CONF_ARCHIVE_DURATION, 30)
self.event_sensor_enabled = entry.options.get(CONF_EVENT_SENSOR_ENABLED, True)
log_level = entry.options.get(CONF_LOG_LEVEL, "INFO")
_LOGGER.setLevel(getattr(logging, log_level, logging.INFO))
_LOGGER.info(f"📊 Уровень логирования: {log_level}")
self.user_token = None
self.lock = asyncio.Lock()
# Информация о камерах
self.camera_tokens = {}
self._cameras_cache = None
self._cameras_cache_ts = 0
# Кэш картинок
self._image_cache = {}
self._image_cache_lock = asyncio.Lock()
# ✅ НОВОЕ: Отслеживание недоступных камер (404)
self._unavailable_cameras: set[str] = set()
# ✅ НОВОЕ: Флаг протухших токенов (403)
self._tokens_invalid = False
self._tokens_refresh_lock = asyncio.Lock()
# Данные событий
self.last_events = {}
self._last_request_ids = {}
self.rfid_names = {}
self.intercom_camera_map = {}
self.intercom_ids = []
self._events_polling_task: asyncio.Task | None = None
self._event_listeners = []
self._local_tz = ZoneInfo(hass.config.time_zone)
_LOGGER.info(f"🕐 Временная зона HA: {hass.config.time_zone}")
def build_device_name(self, name: str) -> str:
return name.strip() if name else "RT Key Device"
def get_camera_name(self, camera_id: str) -> str:
"""Получаем название камеры по её ID."""
token_info = self.camera_tokens.get(camera_id)
if token_info:
return token_info.get("title", "Неизвестная камера")
return "Неизвестная камера"
def is_camera_available(self, camera_id: str) -> bool:
"""Проверяем, доступна ли камера (не в списке недоступных)."""
return camera_id not in self._unavailable_cameras
def register_event_listener(self, callback):
if callback not in self._event_listeners:
self._event_listeners.append(callback)
def unregister_event_listener(self, callback):
if callback in self._event_listeners:
self._event_listeners.remove(callback)
def _notify_event_listeners(self, intercom_id: str):
for callback in self._event_listeners:
try:
callback(intercom_id)
except Exception as e:
_LOGGER.error(f"Ошибка в event listener: {e}")
async def _fetch_cameras(self):
"""Получаем информацию о камерах (токены для стрима и скриншотов)."""
if self._cameras_cache and (time.time() - self._cameras_cache_ts < 60):
return self._cameras_cache
try:
_LOGGER.debug("Запрос camera_video_data/list...")
r = await self.hass.async_add_executor_job(
functools.partial(
requests.get,
"https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list?paging.limit=100&paging.offset=0",
headers={"Authorization": f"Bearer {self.token}"},
timeout=20,
)
)
if r.status_code == 200:
data = r.json()
devices = data.get("data", [])
if devices:
self.user_token = devices[0].get("userToken")
for device in devices:
uid = device.get("uid")
if uid:
self.camera_tokens[uid] = {
"title": device.get("title", "Устройство"),
"category_type": device.get("category", {}).get("type", "unknown"),
"category_title": device.get("category", {}).get("title", ""),
"model": device.get("model", ""),
"vendor": device.get("vendor", ""),
"serial_number": device.get("serialNumber", ""),
"mac": device.get("mac", ""),
"ip": device.get("ip", ""),
"status_type": device.get("status", {}).get("type", "unknown"),
"status_title": device.get("status", {}).get("title", ""),
"streamer_token": device.get("streamerToken"),
"screenshot_token": device.get("screenshotToken"),
"user_token": device.get("userToken"),
}
self._cameras_cache = devices
self._cameras_cache_ts = time.time()
_LOGGER.info(f"✅ Загружено {len(self.camera_tokens)} устройств")
return devices
else:
_LOGGER.error(f"camera_video_data/list вернул {r.status_code}")
except Exception as e:
_LOGGER.error(f"Ошибка получения камер: {e}")
return []
async def refresh_tokens(self):
"""🔄 Принудительно обновляем все токены (сбрасываем кэш)."""
async with self._tokens_refresh_lock:
_LOGGER.info("🔄 Принудительное обновление токенов...")
self._cameras_cache = None
self._cameras_cache_ts = 0
await self._fetch_cameras()
self._tokens_invalid = False
_LOGGER.info(f"✅ Токены обновлены для {len(self.camera_tokens)} устройств")
async def _fetch_intercoms(self):
"""Получаем информацию о домофонах из отдельного API."""
try:
_LOGGER.debug("Запрос домофонов из household.key.rt.ru...")
r = await self.hass.async_add_executor_job(
functools.partial(
requests.get,
"https://household.key.rt.ru/api/v2/app/devices/intercom",
headers={"Authorization": f"Bearer {self.token}"},
timeout=15,
)
)
if r.status_code == 200:
data = r.json()
self._build_intercom_mappings(data)
devices = data.get("data", {}).get("devices", [])
self.intercom_ids = [str(d.get("id")) for d in devices if d.get("id")]
_LOGGER.info(f"✅ Загружено {len(self.intercom_ids)} домофонов: {self.intercom_ids}")
return data
else:
_LOGGER.error(f"intercom вернул {r.status_code}")
except Exception as e:
_LOGGER.error(f"Ошибка получения домофонов: {e}")
return {"data": {"devices": []}}
def _build_intercom_mappings(self, data: dict):
devices = data.get("data", {}).get("devices", [])
for device in devices:
intercom_id = device.get("id")
camera_id = device.get("camera_id")
if intercom_id and camera_id:
self.intercom_camera_map[str(intercom_id)] = camera_id
for code in device.get("inter_codes", []):
rfid_id = code.get("id")
name = code.get("name_by_user", "")
if rfid_id and name:
self.rfid_names[rfid_id] = name
_LOGGER.debug(
f"Построены маппинги: {len(self.intercom_camera_map)} камер домофонов, "
f"{len(self.rfid_names)} ключей"
)
async def get_cameras_info(self):
await self._fetch_cameras()
items = []
for uid, info in self.camera_tokens.items():
items.append({
"id": uid,
"title": info["title"],
"category_type": info["category_type"],
"status_title": info["status_title"],
"model": info["model"],
"vendor": info["vendor"],
"serial_number": info["serial_number"],
"mac": info["mac"],
"ip": info["ip"],
# ✅ Добавляем флаг доступности
"available": self.is_camera_available(uid),
})
return {"data": {"items": items}}
async def get_intercoms_info(self):
if not self.intercom_ids:
return await self._fetch_intercoms()
return await self._fetch_intercoms()
async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None:
"""Получаем скриншот камеры с обработкой ошибок 403 и 404."""
async with self._image_cache_lock:
if camera_id in self._image_cache:
img, ts = self._image_cache[camera_id]
if time.time() - ts < 4:
return img
token_info = self.camera_tokens.get(camera_id)
screenshot_token = token_info.get("screenshot_token") if token_info else None
user_token = (token_info.get("user_token") or self.user_token) if token_info else None
# ✅ Получаем название камеры для логов
camera_name = self.get_camera_name(camera_id)
try:
url = f"https://key.rt.ru/screenshot/image/{self.screenshot_quality}/{camera_id}/last.jpg"
if screenshot_token:
url += f"?token={screenshot_token}&_={int(time.time() * 1000)}"
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}")
r = await self.hass.async_add_executor_job(
functools.partial(
requests.get,
url,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://key.rt.ru/",
"Origin": "https://key.rt.ru",
"X-UTOKEN": user_token or self.token,
},
timeout=15,
)
)
# ✅ Обработка 403 — токен протух
if r.status_code == 403 and not retry:
_LOGGER.warning(
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
)
self._tokens_invalid = True
await self.refresh_tokens()
return await self.get_camera_image(camera_id, retry=True)
if r.status_code == 403 and retry:
_LOGGER.error(
f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией"
)
return None
# ✅ Обработка 404 — камера недоступна
if r.status_code == 404:
if camera_id not in self._unavailable_cameras:
_LOGGER.warning(
f"📷 Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline"
)
self._unavailable_cameras.add(camera_id)
return None
if r.status_code == 200 and len(r.content) > 2000:
image_data = r.content
async with self._image_cache_lock:
self._image_cache[camera_id] = (image_data, time.time())
# ✅ Если камера была недоступна — помечаем как восстановленную
if camera_id in self._unavailable_cameras:
self._unavailable_cameras.discard(camera_id)
_LOGGER.info(f"✅ Камера \"{camera_name}\" ({camera_id}) снова доступна")
_LOGGER.info(f"✅ Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт")
return image_data
else:
_LOGGER.warning(
f"Скриншот для {camera_name} ({camera_id}): статус {r.status_code}, размер {len(r.content)}"
)
except Exception as e:
_LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}")
return None
async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None:
"""Получаем скриншот на момент события (с учётом лага)."""
token_info = self.camera_tokens.get(camera_id)
screenshot_token = token_info.get("screenshot_token") if token_info else None
user_token = (token_info.get("user_token") or self.user_token) if token_info else None
adjusted_timestamp = event_timestamp - self.event_time_lag
url = f"https://key.rt.ru/screenshot/image/{self.event_screenshot_quality}/{camera_id}/{adjusted_timestamp}.jpg"
if screenshot_token:
url += f"?token={screenshot_token}"
try:
_LOGGER.debug(f"Запрос скриншота события для {camera_id}: {url}")
r = await self.hass.async_add_executor_job(
functools.partial(
requests.get,
url,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://key.rt.ru/",
"Origin": "https://key.rt.ru",
"X-UTOKEN": user_token or self.token,
},
timeout=15,
)
)
if r.status_code == 200 and len(r.content) > 1000:
_LOGGER.info(f"✅ Скриншот события получен {camera_id} ({len(r.content)} байт)")
return r.content
else:
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status_code}")
except Exception as e:
_LOGGER.error(f"Ошибка скриншота события {camera_id}: {e}")
return None
def get_event_archive_url(self, camera_id: str, event_timestamp: int) -> str | None:
token_info = self.camera_tokens.get(camera_id)
streamer_token = token_info.get("streamer_token") if token_info else None
adjusted_timestamp = event_timestamp - self.event_time_lag - self.pre_event_seconds
base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/{adjusted_timestamp}.mp4"
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1"
if streamer_token:
return f"{base}?{params}&token={streamer_token}"
return f"{base}?{params}"
async def get_camera_stream_url(self, camera_id: str) -> str | None:
token_info = self.camera_tokens.get(camera_id)
streamer_token = token_info.get("streamer_token") if token_info else None
base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4"
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1"
if streamer_token:
return f"{base}?{params}&token={streamer_token}"
return f"{base}?{params}"
async def open_intercom(self, intercom_id: str):
try:
await self.hass.async_add_executor_job(
functools.partial(
requests.post,
f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open",
headers={"Authorization": f"Bearer {self.token}"},
json={},
timeout=10,
)
)
_LOGGER.info(f"✅ Открыт домофон {intercom_id}")
except Exception as e:
_LOGGER.error(f"Ошибка открытия {intercom_id}: {e}")
def _parse_iso_to_timestamp(self, iso_str: str) -> int:
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return int(dt.timestamp())
except Exception as e:
_LOGGER.error(f"Ошибка парсинга времени {iso_str}: {e}")
return 0
def _format_local_time(self, iso_str: str) -> str:
try:
dt_utc = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
dt_local = dt_utc.astimezone(self._local_tz)
return dt_local.strftime("%Y-%m-%d %H:%M:%S")
except Exception as e:
_LOGGER.error(f"Ошибка форматирования времени {iso_str}: {e}")
return iso_str
def _get_event_description(self, event: dict) -> str:
event_type = event.get("event_type", "")
rfid_id = event.get("rfid_id")
if rfid_id and rfid_id in self.rfid_names:
return self.rfid_names[rfid_id]
descriptions = {
"api_open_remote": "Открытие через приложение",
"face_open_remote": "Открытие по лицу",
"pin_code_open_remote": "Открытие по пин-коду",
"code_open_local": "Открытие по коду",
"rfid_open_local": "Открытие по ключу",
"dtmf_open_local": "Открытие через телефон",
}
if event.get("rfid") and event_type == "rfid_open_local":
return f"Ключ {event.get('rfid')}"
return descriptions.get(event_type, event_type)
async def get_last_event(self, intercom_id: str) -> dict | None:
try:
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
url = (
f"https://events.key.rt.ru/api/v2/events/list?"
f"begin_raised_at=2018-01-01T00:00:00Z&"
f"end_raised_at={now_iso}&"
f"device_ids={intercom_id}&"
f"limit=1&"
f"sort_by=raised_at&"
f"offset=0"
)
r = await self.hass.async_add_executor_job(
functools.partial(
requests.get,
url,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
},
timeout=15,
)
)
if r.status_code == 200:
data = r.json()
items = data.get("data", {}).get("items", [])
if items:
return items[0]
else:
_LOGGER.warning(f"Events API вернул {r.status_code} для {intercom_id}")
except Exception as e:
_LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}")
return None
async def update_last_event(self, intercom_id: str, event: dict):
camera_id = self.intercom_camera_map.get(str(intercom_id))
if not camera_id:
_LOGGER.warning(f"Не найден camera_id для домофона {intercom_id}")
return
event_timestamp = self._parse_iso_to_timestamp(event.get("raised_at", ""))
if not event_timestamp:
return
screenshot_data = None
if self.event_screenshot_enabled:
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
archive_url = None
if self.event_archive_enabled:
archive_url = self.get_event_archive_url(camera_id, event_timestamp)
self.last_events[intercom_id] = {
"event": event,
"camera_id": camera_id,
"timestamp": event_timestamp,
"local_time": self._format_local_time(event.get("raised_at", "")),
"description": self._get_event_description(event),
"event_type": event.get("event_type", ""),
"screenshot_data": screenshot_data,
"archive_url": archive_url,
}
_LOGGER.info(
f"🔔 Новое событие для домофона {intercom_id}: "
f"{self.last_events[intercom_id]['description']} "
f"в {self.last_events[intercom_id]['local_time']}"
)
self._notify_event_listeners(intercom_id)
async def _events_polling_loop(self):
_LOGGER.info(f"🔄 Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)")
while True:
try:
for intercom_id in self.intercom_ids:
last_event = await self.get_last_event(intercom_id)
if not last_event:
continue
request_id = last_event.get("request_id")
last_request_id = self._last_request_ids.get(intercom_id)
if request_id and request_id != last_request_id:
self._last_request_ids[intercom_id] = request_id
await self.update_last_event(intercom_id, last_event)
except Exception as e:
_LOGGER.error(f"Ошибка в цикле опроса событий: {e}")
await asyncio.sleep(self.events_refresh_interval)
async def start_events_polling(self):
if not self.events_enabled:
_LOGGER.info("Отслеживание событий отключено")
return
if self._events_polling_task and not self._events_polling_task.done():
return
await self._fetch_intercoms()
self._events_polling_task = asyncio.create_task(self._events_polling_loop())
async def stop_events_polling(self):
if self._events_polling_task and not self._events_polling_task.done():
self._events_polling_task.cancel()
try:
await self._events_polling_task
except asyncio.CancelledError:
pass
self._events_polling_task = None
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
cameras_api = RTKeyCamerasApi(hass, entry)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"cameras_api": cameras_api
}
entry.add_update_listener(update_listener)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await cameras_api.start_events_polling()
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"]
await cameras_api.stop_events_polling()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)