Files
RTKey/custom_components/rtkey/__init__.py
T
sirmrak a12554c010 Хранение скриншотов и архивов на диск вместо in-memory
— Замена event_screenshot_enabled/event_archive_enabled на screenshot_copies/archive_copies (0 = отключено)
— Новый параметр archive_path для пути сохранения (относительно /media/)
— Скачивание скриншотов и видео-архивов событий на локальный диск с ограничением копий
— Проверка FFmpeg при инициализации, поддержка file:// для stream_source
— Обновлены переводы (en/ru) и config_flow
2026-06-28 16:09:47 +03:00

841 lines
37 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 pathlib import Path
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_QUALITY = "event_screenshot_quality"
CONF_EVENT_SENSOR_ENABLED = "event_sensor_enabled"
# Новые настройки для локального хранения
CONF_ARCHIVE_PATH = "archive_path"
CONF_ARCHIVE_COPIES = "archive_copies"
CONF_SCREENSHOT_COPIES = "screenshot_copies"
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_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_ARCHIVE_PATH, default="media/RTKey"): str,
vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=10, step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_SCREENSHOT_COPIES, default=10): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=10, step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
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_quality = entry.options.get(CONF_EVENT_SCREENSHOT_QUALITY, "large")
self.event_sensor_enabled = entry.options.get(CONF_EVENT_SENSOR_ENABLED, True)
# 🆕 Настройки локального хранения
self.archive_path = entry.options.get(CONF_ARCHIVE_PATH, "media/RTKey")
self.archive_copies = entry.options.get(CONF_ARCHIVE_COPIES, 5)
self.screenshot_copies = entry.options.get(CONF_SCREENSHOT_COPIES, 10)
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}")
# 🆕 Проверка FFmpeg
self.ffmpeg_available = False
async def async_initialize(self):
"""Инициализация после создания (для async операций)."""
await self._check_ffmpeg()
async def _check_ffmpeg(self):
"""Проверяем наличие FFmpeg в системе."""
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
self.ffmpeg_available = True
_LOGGER.info("✅ FFmpeg доступен")
except FileNotFoundError:
self.ffmpeg_available = False
_LOGGER.error("❌ FFmpeg не найден! Архивное видео будет недоступно.")
except Exception as e:
self.ffmpeg_available = False
_LOGGER.error(f"❌ Ошибка проверки FFmpeg: {e}")
def _get_media_base_path(self) -> Path:
"""Получаем базовый путь для медиафайлов."""
# HA хранит медиа в /media/ (media_dirs)
media_dirs = self.hass.config.media_dirs
if media_dirs and "media" in media_dirs:
return Path(media_dirs["media"])
# Fallback
return Path("/media")
def _get_archive_dir(self, intercom_id: str) -> Path:
"""Путь к папке архивов для домофона."""
return self._get_media_base_path() / self.archive_path / "archives" / intercom_id
def _get_screenshot_dir(self, intercom_id: str) -> Path:
"""Путь к папке скриншотов для домофона."""
return self._get_media_base_path() / self.archive_path / "screenshots" / intercom_id
async def download_event_archive(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
"""Скачиваем архивное видео через FFmpeg и сохраняем локально."""
if not self.ffmpeg_available:
_LOGGER.error("FFmpeg недоступен, пропускаем скачивание архива")
return None
url = self.get_event_archive_url(camera_id, event_timestamp)
if not url:
_LOGGER.error(f"Не удалось получить URL архива для камеры {camera_id}")
return None
# Создаём папку
archive_dir = self._get_archive_dir(intercom_id)
archive_dir.mkdir(parents=True, exist_ok=True)
# Имя файла
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4")
filepath = archive_dir / filename
# Вычисляем длительность: pre_event + lag + post_event (archive_duration)
# archive_duration по умолчанию 30 сек
archive_duration = 30 # Можно вынести в настройки позже
duration = self.pre_event_seconds + self.event_time_lag + archive_duration
_LOGGER.info(f" Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
cmd = [
"ffmpeg", "-y",
"-i", url,
"-t", str(duration),
"-c", "copy",
str(filepath),
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=duration + 30 # Запас на задержки сети
)
if proc.returncode == 0 and filepath.exists() and filepath.stat().st_size > 1000:
_LOGGER.info(f"✅ Архив сохранён: {filepath} ({filepath.stat().st_size} байт)")
self.cleanup_old_archives(intercom_id)
return str(filepath)
else:
error_msg = stderr.decode()[:200] if stderr else "Неизвестная ошибка"
_LOGGER.error(f"❌ Ошибка FFmpeg для {intercom_id}: {error_msg}")
return None
except asyncio.TimeoutError:
_LOGGER.error(f"⏱️ Превышено время скачивания архива для {intercom_id}")
if proc:
proc.kill()
return None
except Exception as e:
_LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}")
return None
async def download_event_screenshot(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
"""Скачиваем скриншот события и сохраняем локально."""
screenshot_dir = self._get_screenshot_dir(intercom_id)
screenshot_dir.mkdir(parents=True, exist_ok=True)
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg")
filepath = screenshot_dir / filename
# Получаем скриншот через существующий метод
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
if screenshot_data:
filepath.write_bytes(screenshot_data)
_LOGGER.info(f"✅ Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)")
self.cleanup_old_screenshots(intercom_id)
return str(filepath)
return None
def cleanup_old_archives(self, intercom_id: str):
"""Удаляем старые архивные видео, если превышено количество копий."""
archive_dir = self._get_archive_dir(intercom_id)
if not archive_dir.exists():
return
files = sorted(archive_dir.glob("*.mp4"), key=lambda f: f.stat().st_mtime)
while len(files) > self.archive_copies:
oldest = files.pop(0)
oldest.unlink()
_LOGGER.debug(f"🗑️ Удалён старый архив: {oldest.name}")
def cleanup_old_screenshots(self, intercom_id: str):
"""Удаляем старые скриншоты, если превышено количество копий."""
screenshot_dir = self._get_screenshot_dir(intercom_id)
if not screenshot_dir.exists():
return
files = sorted(screenshot_dir.glob("*.jpg"), key=lambda f: f.stat().st_mtime)
while len(files) > self.screenshot_copies:
oldest = files.pop(0)
oldest.unlink()
_LOGGER.debug(f"🗑️ Удалён старый скриншот: {oldest.name}")
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,
)
)
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
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_path = None
screenshot_error = None
if self.screenshot_copies > 0:
try:
screenshot_path = await self.download_event_screenshot(camera_id, event_timestamp, intercom_id)
except Exception as e:
screenshot_error = str(e)
_LOGGER.error(f"Ошибка скачивания скриншота для {intercom_id}: {e}")
# 🆕 Скачиваем архив на диск (если включено)
archive_path = None
archive_error = None
if self.archive_copies > 0:
try:
archive_path = await self.download_event_archive(camera_id, event_timestamp, intercom_id)
except Exception as e:
archive_error = str(e)
_LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}")
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_path": screenshot_path,
"archive_path": archive_path,
"screenshot_error": screenshot_error,
"archive_error": archive_error,
}
_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
}
# 🆕 Инициализация (проверка FFmpeg)
await cameras_api.async_initialize()
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)