12 Commits
Author SHA1 Message Date
sirmrak 6a44a996b1 Изменения в потоке камер 2026-06-30 20:45:31 +03:00
sirmrak e1f1ff89d8 Новая версия 2026-06-29 12:31:51 +03:00
sirmrakandQwen-Coder 75d985f64a Обновление токенов стрима по 403 вместо периодического таймера
— Убран async_track_time_interval из RTKeyCamera (NameError + лишняя нагрузка)
— get_camera_stream_url: проверка 403 с retry, как в get_camera_image
— Токены обновляются только по факту ошибки, не каждые 300 сек

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-29 11:57:54 +03:00
sirmrak 8b65cb43eb Новая версия 2026-06-29 11:35:48 +03:00
sirmrak 02a3d821e3 Пропуск повторной загрузки существующих архивов/скриншотов, иконка в README
— archive/screenshot: проверка exists+st_size>0 перед скачиванием
— README: добавлена иконка интеграции в заголовок
2026-06-29 11:19:58 +03:00
sirmrak 0e07bc8c9d Длительность архива = archive_duration, Camera без STREAM для архивов
— duration = archive_duration (не сумма lag+pre+archive)
— RTKeyEventCamera: убран STREAM (HLS обрезает видео + reconnect loop)
— RTKeyEventCamera: async_camera_image() для thumbnail (fix NotImplementedError)
— Убраны эмодзи из статусов, убран CameraEntityFeature импорт из EventCamera
2026-06-28 22:33:17 +03:00
sirmrak 8472b91cd1 Критические баг-фиксы и подготовка к релизу v1.1.0
— _ApiResponse dataclass: body читается внутри async with (Connection closed fix)
— _mkdir_parents helper: keyword args вместо positional (FileExistsError fix)
— async_get_clientsession(hass): HA managed session, убран self._session/_close_session
— manifest.json: version 1.1.0, requirements [] (убраны transliterate, pyjwt)
2026-06-28 19:33:33 +03:00
sirmrak ff5406828c Создание структуры медиа-папок при инициализации интеграции
— _ensure_media_dirs(): базовые archives/ и screenshots/ создаются сразу в async_initialize
— _ensure_intercom_dirs(): подпапки per-intercom создаются после загрузки домофонов
— Папки готовы до первого события, не нужно ждать download_event_*
2026-06-28 17:53:51 +03:00
sirmrak c121261f3d Исправление media path: убрано двойное media, правильный HA API
— Default archive_path: 'media/RTKey' → 'RTKey' (без дублирования /media/media/)
— Fallback: '/media' → hass.config.path('media') (HA API вместо hardcoded)
— Поддержка ключа 'local' в media_dirs (если 'media' отсутствует)
— Обновлены подсказки в переводах en/ru
2026-06-28 17:31:02 +03:00
sirmrak 15a97be8e2 Оптимизация и модернизация __init__.py
— aiohttp вместо requests: реальный async, единая ClientSession, connection pooling
— async file I/O через executor (write_bytes, mkdir, cleanup)
— asyncio.gather для параллельного опроса событий
— asyncio.wait_for(timeout=5) на FFmpeg check
— CameraTokenInfo dataclass вместо dict
— yarl.URL для безопасной кодировки query-параметров
— Общие _api_get/_api_post, уменьшение дублирования
— CONF_ARCHIVE_DURATION вынесена в настройки
— Удалена бессмысленная ветка в get_intercoms_info
— _close_session() cleanup при unload
— sensor.py: archive_url → archive_path
— Обновлены переводы и config_flow для archive_duration
2026-06-28 17:14:49 +03:00
sirmrak 97624d6750 Изменение максимального времени опроса событий до 3600 секунд 2026-06-28 16:19:54 +03:00
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
9 changed files with 655 additions and 395 deletions
+1
View File
@@ -1,3 +1,4 @@
<img src="custom_components/rtkey/icon.png" width="128"> </img>
# RT Key Integration for Home Assistant # RT Key Integration for Home Assistant
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/) [![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/)
+456 -218
View File
@@ -1,21 +1,23 @@
import asyncio import asyncio
import functools import json
import logging import logging
import time import time
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import requests import aiohttp
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from yarl import URL
DOMAIN = "rtkey" DOMAIN = "rtkey"
PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR] PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# === Константы настроек === # === Константы настроек ===
@@ -30,12 +32,15 @@ CONF_EVENTS_ENABLED = "events_enabled"
CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval" CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval"
CONF_EVENT_TIME_LAG = "event_time_lag" CONF_EVENT_TIME_LAG = "event_time_lag"
CONF_PRE_EVENT_SECONDS = "pre_event_seconds" CONF_PRE_EVENT_SECONDS = "pre_event_seconds"
CONF_EVENT_SCREENSHOT_ENABLED = "event_screenshot_enabled"
CONF_EVENT_SCREENSHOT_QUALITY = "event_screenshot_quality" 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" CONF_EVENT_SENSOR_ENABLED = "event_sensor_enabled"
# Настройки локального хранения
CONF_ARCHIVE_PATH = "archive_path"
CONF_ARCHIVE_COPIES = "archive_copies"
CONF_ARCHIVE_DURATION = "archive_duration"
CONF_SCREENSHOT_COPIES = "screenshot_copies"
TOKEN_REFRESH_BUFFER = 300 TOKEN_REFRESH_BUFFER = 300
DATA_SCHEMA = { DATA_SCHEMA = {
@@ -46,7 +51,6 @@ DATA_SCHEMA = {
OPTIONS_SCHEMA = { OPTIONS_SCHEMA = {
vol.Optional(CONF_NAME): str, vol.Optional(CONF_NAME): str,
vol.Optional(CONF_TOKEN): str, vol.Optional(CONF_TOKEN): str,
vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector( vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0, max=600, step=30, min=0, max=600, step=30,
@@ -54,7 +58,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector( vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=[ options=[
@@ -66,7 +69,6 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
), ),
vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector( vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=[ options=[
@@ -78,17 +80,14 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
), ),
vol.Optional(CONF_EVENTS_ENABLED, default=True): bool, vol.Optional(CONF_EVENTS_ENABLED, default=True): bool,
vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector( vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=5, max=60, step=5, min=5, max=3600, step=5,
mode=selector.NumberSelectorMode.SLIDER, mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector( vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0, max=60, step=5, min=0, max=60, step=5,
@@ -96,7 +95,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector( vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0, max=30, step=5, min=0, max=30, step=5,
@@ -104,9 +102,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_EVENT_SCREENSHOT_ENABLED, default=True): bool,
vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector( vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=[ options=[
@@ -118,9 +113,14 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
), ),
# Настройки локального хранения
vol.Optional(CONF_EVENT_ARCHIVE_ENABLED, default=True): bool, vol.Optional(CONF_ARCHIVE_PATH, default="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_ARCHIVE_DURATION, default=30): selector.NumberSelector( vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=10, max=120, step=10, min=10, max=120, step=10,
@@ -128,11 +128,52 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
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, vol.Optional(CONF_EVENT_SENSOR_ENABLED, default=True): bool,
} }
@dataclass
class CameraTokenInfo:
title: str = "Устройство"
category_type: str = "unknown"
category_title: str = ""
model: str = ""
vendor: str = ""
serial_number: str = ""
mac: str = ""
ip: str = ""
status_type: str = "unknown"
status_title: str = ""
streamer_token: str | None = None
screenshot_token: str | None = None
user_token: str | None = None
@dataclass
class _ApiResponse:
status: int
body: bytes
_DEFAULT_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",
}
def _mkdir_parents(p: Path):
"""Path.mkdir с keyword args для executor."""
p.mkdir(parents=True, exist_ok=True)
class RTKeyCamerasApi: class RTKeyCamerasApi:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry): def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.hass = hass self.hass = hass
@@ -145,59 +186,222 @@ class RTKeyCamerasApi:
self.events_refresh_interval = entry.options.get(CONF_EVENTS_REFRESH_INTERVAL, 15) 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.event_time_lag = entry.options.get(CONF_EVENT_TIME_LAG, 25)
self.pre_event_seconds = entry.options.get(CONF_PRE_EVENT_SECONDS, 10) 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_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) self.event_sensor_enabled = entry.options.get(CONF_EVENT_SENSOR_ENABLED, True)
# Настройки локального хранения
self.archive_path = entry.options.get(CONF_ARCHIVE_PATH, "RTKey")
self.archive_copies = entry.options.get(CONF_ARCHIVE_COPIES, 5)
self.archive_duration = entry.options.get(CONF_ARCHIVE_DURATION, 30)
self.screenshot_copies = entry.options.get(CONF_SCREENSHOT_COPIES, 10)
log_level = entry.options.get(CONF_LOG_LEVEL, "INFO") log_level = entry.options.get(CONF_LOG_LEVEL, "INFO")
_LOGGER.setLevel(getattr(logging, log_level, logging.INFO)) _LOGGER.setLevel(getattr(logging, log_level, logging.INFO))
_LOGGER.info(f"📊 Уровень логирования: {log_level}") _LOGGER.info(f"Уровень логирования: {log_level}")
self.user_token = None self.user_token: str | None = None
self.lock = asyncio.Lock()
# HA managed aiohttp session
self._session: aiohttp.ClientSession = async_get_clientsession(hass)
# Информация о камерах # Информация о камерах
self.camera_tokens = {} self.camera_tokens: dict[str, CameraTokenInfo] = {}
self._cameras_cache = None self._cameras_cache = None
self._cameras_cache_ts = 0 self._cameras_cache_ts = 0
# Кэш картинок # Кэш картинок
self._image_cache = {} self._image_cache: dict[str, tuple[bytes, float]] = {}
self._image_cache_lock = asyncio.Lock() self._image_cache_lock = asyncio.Lock()
# ✅ НОВОЕ: Отслеживание недоступных камер (404) # Отслеживание недоступных камер (404)
self._unavailable_cameras: set[str] = set() self._unavailable_cameras: set[str] = set()
# ✅ НОВОЕ: Флаг протухших токенов (403) # Флаг протухших токенов (403)
self._tokens_invalid = False self._tokens_invalid = False
self._tokens_refresh_lock = asyncio.Lock() self._tokens_refresh_lock = asyncio.Lock()
# Данные событий # Данные событий
self.last_events = {} self.last_events: dict[str, dict] = {}
self._last_request_ids = {} self._last_request_ids: dict[str, str] = {}
self.rfid_names = {} self.rfid_names: dict[str, str] = {}
self.intercom_camera_map = {} self.intercom_camera_map: dict[str, str] = {}
self.intercom_ids = [] self.intercom_ids: list[str] = []
self._events_polling_task: asyncio.Task | None = None self._events_polling_task: asyncio.Task | None = None
self._event_listeners = [] self._event_listeners: list = []
self._local_tz = ZoneInfo(hass.config.time_zone) self._local_tz = ZoneInfo(hass.config.time_zone)
_LOGGER.info(f"🕐 Временная зона HA: {hass.config.time_zone}") _LOGGER.info(f"Временная зона HA: {hass.config.time_zone}")
self.ffmpeg_available = False
async def async_initialize(self):
await self._check_ffmpeg()
await self._ensure_media_dirs()
async def _ensure_media_dirs(self):
base = self._get_media_base_path() / self.archive_path
dirs_to_create = [base / "archives", base / "screenshots"]
for dir_path in dirs_to_create:
await self.hass.async_add_executor_job(_mkdir_parents, dir_path)
_LOGGER.info(f"Медиа-папка: {dir_path}")
_LOGGER.info(f"Базовый путь для медиа: {base}")
async def _ensure_intercom_dirs(self, intercom_ids: list[str]):
for intercom_id in intercom_ids:
for dir_path in (self._get_archive_dir(intercom_id), self._get_screenshot_dir(intercom_id)):
await self.hass.async_add_executor_job(_mkdir_parents, dir_path)
async def _check_ffmpeg(self):
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=5)
self.ffmpeg_available = True
_LOGGER.info("FFmpeg доступен")
except FileNotFoundError:
self.ffmpeg_available = False
_LOGGER.error("FFmpeg не найден! Архивное видео будет недоступно.")
except (asyncio.TimeoutError, Exception) as e:
self.ffmpeg_available = False
_LOGGER.error(f"Ошибка проверки FFmpeg: {e}")
# === Медиа-пути ===
def _get_media_base_path(self) -> Path:
media_dirs = self.hass.config.media_dirs
if media_dirs:
if "media" in media_dirs:
return Path(media_dirs["media"])
if "local" in media_dirs:
return Path(media_dirs["local"])
return Path(self.hass.config.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
# === Скачивание и cleanup ===
async def download_event_archive(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
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)
await self.hass.async_add_executor_job(_mkdir_parents, archive_dir)
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4")
filepath = archive_dir / filename
if await self.hass.async_add_executor_job(lambda p: p.exists() and p.stat().st_size > 0, filepath):
_LOGGER.info(f"Архив уже существует: {filepath}")
return str(filepath)
duration = self.archive_duration
_LOGGER.info(f"Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
cmd = [
"ffmpeg", "-y",
"-i", url,
"-t", str(duration),
"-c", "copy",
str(filepath),
]
proc: asyncio.subprocess.Process | None = None
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:
size = await self.hass.async_add_executor_job(
lambda p: p.stat().st_size if p.exists() else 0, filepath
)
if size > 1000:
_LOGGER.info(f"Архив сохранён: {filepath} ({size} байт)")
await self.cleanup_old_files(
self._get_archive_dir(intercom_id), "*.mp4", self.archive_copies
)
return str(filepath)
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)
await self.hass.async_add_executor_job(_mkdir_parents, screenshot_dir)
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg")
filepath = screenshot_dir / filename
if await self.hass.async_add_executor_job(lambda p: p.exists() and p.stat().st_size > 0, filepath):
_LOGGER.info(f"Скриншот уже существует: {filepath}")
return str(filepath)
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
if screenshot_data:
await self.hass.async_add_executor_job(filepath.write_bytes, screenshot_data)
_LOGGER.info(f"Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)")
await self.cleanup_old_files(
self._get_screenshot_dir(intercom_id), "*.jpg", self.screenshot_copies
)
return str(filepath)
return None
async def cleanup_old_files(self, directory: Path, pattern: str, max_copies: int):
def _cleanup():
if not directory.exists():
return
files = sorted(directory.glob(pattern), key=lambda f: f.stat().st_mtime)
while len(files) > max_copies:
oldest = files.pop(0)
oldest.unlink()
_LOGGER.debug(f"Удалён старый файл: {oldest.name}")
await self.hass.async_add_executor_job(_cleanup)
# === Утилиты ===
def build_device_name(self, name: str) -> str: def build_device_name(self, name: str) -> str:
return name.strip() if name else "RT Key Device" return name.strip() if name else "RT Key Device"
def get_camera_name(self, camera_id: str) -> str: def get_camera_name(self, camera_id: str) -> str:
"""Получаем название камеры по её ID.""" info = self.camera_tokens.get(camera_id)
token_info = self.camera_tokens.get(camera_id) return info.title if info else "Неизвестная камера"
if token_info:
return token_info.get("title", "Неизвестная камера")
return "Неизвестная камера"
def is_camera_available(self, camera_id: str) -> bool: def is_camera_available(self, camera_id: str) -> bool:
"""Проверяем, доступна ли камера (не в списке недоступных)."""
return camera_id not in self._unavailable_cameras return camera_id not in self._unavailable_cameras
def register_event_listener(self, callback): def register_event_listener(self, callback):
@@ -215,23 +419,55 @@ class RTKeyCamerasApi:
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка в event listener: {e}") _LOGGER.error(f"Ошибка в event listener: {e}")
# === HTTP API (aiohttp, HA managed session) ===
async def _api_get(self, url: str | URL, headers: dict, timeout: int = 20) -> _ApiResponse | None:
"""GET-запрос. Читает body внутри context manager, возвращает status + bytes."""
try:
async with self._session.get(
url,
headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as r:
body = await r.read()
return _ApiResponse(status=r.status, body=body)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
_LOGGER.error(f"HTTP ошибка GET {url}: {e}")
return None
async def _api_post(self, url: str | URL, headers: dict, json_data: dict | None = None, timeout: int = 10) -> _ApiResponse | None:
"""POST-запрос. Читает body внутри context manager."""
try:
async with self._session.post(
url,
headers=headers,
json=json_data or {},
timeout=aiohttp.ClientTimeout(total=timeout),
) as r:
body = await r.read()
return _ApiResponse(status=r.status, body=body)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
_LOGGER.error(f"HTTP ошибка POST {url}: {e}")
return None
# === Камеры ===
async def _fetch_cameras(self): async def _fetch_cameras(self):
"""Получаем информацию о камерах (токены для стрима и скриншотов)."""
if self._cameras_cache and (time.time() - self._cameras_cache_ts < 60): if self._cameras_cache and (time.time() - self._cameras_cache_ts < 60):
return self._cameras_cache return self._cameras_cache
try: try:
_LOGGER.debug("Запрос camera_video_data/list...") _LOGGER.debug("Запрос camera_video_data/list...")
r = await self.hass.async_add_executor_job( url = URL("https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list").with_query({
functools.partial( "paging.limit": 100,
requests.get, "paging.offset": 0,
"https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list?paging.limit=100&paging.offset=0", })
headers={"Authorization": f"Bearer {self.token}"}, r = await self._api_get(url, {"Authorization": f"Bearer {self.token}"})
timeout=20, if r is None:
) return []
)
if r.status_code == 200: if r.status == 200:
data = r.json() data = json.loads(r.body)
devices = data.get("data", []) devices = data.get("data", [])
if devices: if devices:
@@ -240,66 +476,65 @@ class RTKeyCamerasApi:
for device in devices: for device in devices:
uid = device.get("uid") uid = device.get("uid")
if uid: if uid:
self.camera_tokens[uid] = { self.camera_tokens[uid] = CameraTokenInfo(
"title": device.get("title", "Устройство"), title=device.get("title", "Устройство"),
"category_type": device.get("category", {}).get("type", "unknown"), category_type=device.get("category", {}).get("type", "unknown"),
"category_title": device.get("category", {}).get("title", ""), category_title=device.get("category", {}).get("title", ""),
"model": device.get("model", ""), model=device.get("model", ""),
"vendor": device.get("vendor", ""), vendor=device.get("vendor", ""),
"serial_number": device.get("serialNumber", ""), serial_number=device.get("serialNumber", ""),
"mac": device.get("mac", ""), mac=device.get("mac", ""),
"ip": device.get("ip", ""), ip=device.get("ip", ""),
"status_type": device.get("status", {}).get("type", "unknown"), status_type=device.get("status", {}).get("type", "unknown"),
"status_title": device.get("status", {}).get("title", ""), status_title=device.get("status", {}).get("title", ""),
"streamer_token": device.get("streamerToken"), streamer_token=device.get("streamerToken"),
"screenshot_token": device.get("screenshotToken"), screenshot_token=device.get("screenshotToken"),
"user_token": device.get("userToken"), user_token=device.get("userToken"),
} )
self._cameras_cache = devices self._cameras_cache = devices
self._cameras_cache_ts = time.time() self._cameras_cache_ts = time.time()
_LOGGER.info(f"✅ Загружено {len(self.camera_tokens)} устройств") _LOGGER.info(f"Загружено {len(self.camera_tokens)} устройств")
return devices return devices
else: else:
_LOGGER.error(f"camera_video_data/list вернул {r.status_code}") _LOGGER.error(f"camera_video_data/list вернул {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка получения камер: {e}") _LOGGER.error(f"Ошибка получения камер: {e}")
return [] return []
async def refresh_tokens(self): async def refresh_tokens(self):
"""🔄 Принудительно обновляем все токены (сбрасываем кэш)."""
async with self._tokens_refresh_lock: async with self._tokens_refresh_lock:
_LOGGER.info("🔄 Принудительное обновление токенов...") _LOGGER.info("Принудительное обновление токенов...")
self._cameras_cache = None self._cameras_cache = None
self._cameras_cache_ts = 0 self._cameras_cache_ts = 0
await self._fetch_cameras() await self._fetch_cameras()
self._tokens_invalid = False self._tokens_invalid = False
_LOGGER.info(f"✅ Токены обновлены для {len(self.camera_tokens)} устройств") _LOGGER.info(f"Токены обновлены для {len(self.camera_tokens)} устройств")
# === Домофоны ===
async def _fetch_intercoms(self): async def _fetch_intercoms(self):
"""Получаем информацию о домофонах из отдельного API."""
try: try:
_LOGGER.debug("Запрос домофонов из household.key.rt.ru...") _LOGGER.debug("Запрос домофонов из household.key.rt.ru...")
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
"https://household.key.rt.ru/api/v2/app/devices/intercom", "https://household.key.rt.ru/api/v2/app/devices/intercom",
headers={"Authorization": f"Bearer {self.token}"}, {"Authorization": f"Bearer {self.token}"},
timeout=15, timeout=15,
) )
) if r is None:
if r.status_code == 200: return {"data": {"devices": []}}
data = r.json()
if r.status == 200:
data = json.loads(r.body)
self._build_intercom_mappings(data) self._build_intercom_mappings(data)
devices = data.get("data", {}).get("devices", []) devices = data.get("data", {}).get("devices", [])
self.intercom_ids = [str(d.get("id")) for d in devices if d.get("id")] 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}") _LOGGER.info(f"Загружено {len(self.intercom_ids)} домофонов: {self.intercom_ids}")
return data return data
else: else:
_LOGGER.error(f"intercom вернул {r.status_code}") _LOGGER.error(f"intercom вернул {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка получения домофонов: {e}") _LOGGER.error(f"Ошибка получения домофонов: {e}")
return {"data": {"devices": []}} return {"data": {"devices": []}}
@@ -330,183 +565,172 @@ class RTKeyCamerasApi:
for uid, info in self.camera_tokens.items(): for uid, info in self.camera_tokens.items():
items.append({ items.append({
"id": uid, "id": uid,
"title": info["title"], "title": info.title,
"category_type": info["category_type"], "category_type": info.category_type,
"status_title": info["status_title"], "status_title": info.status_title,
"model": info["model"], "model": info.model,
"vendor": info["vendor"], "vendor": info.vendor,
"serial_number": info["serial_number"], "serial_number": info.serial_number,
"mac": info["mac"], "mac": info.mac,
"ip": info["ip"], "ip": info.ip,
# ✅ Добавляем флаг доступности
"available": self.is_camera_available(uid), "available": self.is_camera_available(uid),
}) })
return {"data": {"items": items}} return {"data": {"items": items}}
async def get_intercoms_info(self): async def get_intercoms_info(self):
if not self.intercom_ids:
return await self._fetch_intercoms()
return await self._fetch_intercoms() return await self._fetch_intercoms()
# === Скриншоты ===
async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None: async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None:
"""Получаем скриншот камеры с обработкой ошибок 403 и 404."""
async with self._image_cache_lock: async with self._image_cache_lock:
if camera_id in self._image_cache: if camera_id in self._image_cache:
img, ts = self._image_cache[camera_id] img, ts = self._image_cache[camera_id]
if time.time() - ts < 4: if time.time() - ts < 4:
return img return img
token_info = self.camera_tokens.get(camera_id) info = self.camera_tokens.get(camera_id)
screenshot_token = token_info.get("screenshot_token") if token_info else None screenshot_token = info.screenshot_token if info else None
user_token = (token_info.get("user_token") or self.user_token) if token_info else None user_token = (info.user_token or self.user_token) if info else None
# ✅ Получаем название камеры для логов
camera_name = self.get_camera_name(camera_id) camera_name = self.get_camera_name(camera_id)
try: try:
url = f"https://key.rt.ru/screenshot/image/{self.screenshot_quality}/{camera_id}/last.jpg" url = URL(f"https://key.rt.ru/screenshot/image/{self.screenshot_quality}/{camera_id}/last.jpg")
if screenshot_token: if screenshot_token:
url += f"?token={screenshot_token}&_={int(time.time() * 1000)}" url = url.with_query({"token": screenshot_token, "_": str(int(time.time() * 1000))})
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}") _LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id})")
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
url, url,
headers={ {**_DEFAULT_HEADERS, "X-UTOKEN": user_token or self.token},
"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, timeout=15,
) )
) if r is None:
return None
# ✅ Обработка 403 — токен протух if r.status == 403 and not retry:
if r.status_code == 403 and not retry: _LOGGER.warning(f"Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление...")
_LOGGER.warning(
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
)
self._tokens_invalid = True self._tokens_invalid = True
await self.refresh_tokens() await self.refresh_tokens()
return await self.get_camera_image(camera_id, retry=True) return await self.get_camera_image(camera_id, retry=True)
if r.status_code == 403 and retry: if r.status == 403 and retry:
_LOGGER.error( _LOGGER.error(f"Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией")
f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией"
)
return None return None
# ✅ Обработка 404 — камера недоступна if r.status == 404:
if r.status_code == 404:
if camera_id not in self._unavailable_cameras: if camera_id not in self._unavailable_cameras:
_LOGGER.warning( _LOGGER.warning(f"Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline")
f"📷 Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline"
)
self._unavailable_cameras.add(camera_id) self._unavailable_cameras.add(camera_id)
return None return None
if r.status_code == 200 and len(r.content) > 2000: if r.status == 200:
image_data = r.content image_data = r.body
if len(image_data) > 2000:
async with self._image_cache_lock: async with self._image_cache_lock:
self._image_cache[camera_id] = (image_data, time.time()) self._image_cache[camera_id] = (image_data, time.time())
# ✅ Если камера была недоступна — помечаем как восстановленную
if camera_id in self._unavailable_cameras: if camera_id in self._unavailable_cameras:
self._unavailable_cameras.discard(camera_id) self._unavailable_cameras.discard(camera_id)
_LOGGER.info(f"✅ Камера \"{camera_name}\" ({camera_id}) снова доступна") _LOGGER.info(f"Камера \"{camera_name}\" ({camera_id}) снова доступна")
_LOGGER.info(f"✅ Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт") _LOGGER.info(f"Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт")
return image_data return image_data
else: else:
_LOGGER.warning( _LOGGER.warning(f"Скриншот для {camera_name} ({camera_id}): размер {len(image_data)} слишком мал")
f"Скриншот для {camera_name} ({camera_id}): статус {r.status_code}, размер {len(r.content)}" else:
) _LOGGER.warning(f"Скриншот для {camera_name} ({camera_id}): статус {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}") _LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}")
return None return None
async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None: async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None:
"""Получаем скриншот на момент события (с учётом лага).""" info = self.camera_tokens.get(camera_id)
token_info = self.camera_tokens.get(camera_id) screenshot_token = info.screenshot_token if info else None
screenshot_token = token_info.get("screenshot_token") if token_info else None user_token = (info.user_token or self.user_token) if 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 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" url = URL(f"https://key.rt.ru/screenshot/image/{self.event_screenshot_quality}/{camera_id}/{adjusted_timestamp}.jpg")
if screenshot_token: if screenshot_token:
url += f"?token={screenshot_token}" url = url.with_query({"token": screenshot_token})
try: try:
_LOGGER.debug(f"Запрос скриншота события для {camera_id}: {url}") _LOGGER.debug(f"Запрос скриншота события для {camera_id}")
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
url, url,
headers={ {**_DEFAULT_HEADERS, "X-UTOKEN": user_token or self.token},
"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, timeout=15,
) )
) if r is None:
return None
if r.status_code == 200 and len(r.content) > 1000: if r.status == 200:
_LOGGER.info(f"✅ Скриншот события получен {camera_id} ({len(r.content)} байт)") content = r.body
return r.content if len(content) > 1000:
_LOGGER.info(f"Скриншот события получен {camera_id} ({len(content)} байт)")
return content
else: else:
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status_code}") _LOGGER.warning(f"Скриншот события {camera_id}: размер {len(content)} слишком мал")
else:
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка скриншота события {camera_id}: {e}") _LOGGER.error(f"Ошибка скриншота события {camera_id}: {e}")
return None return None
# === Стримы и архивы ===
def get_event_archive_url(self, camera_id: str, event_timestamp: int) -> str | None: def get_event_archive_url(self, camera_id: str, event_timestamp: int) -> str | None:
token_info = self.camera_tokens.get(camera_id) info = self.camera_tokens.get(camera_id)
streamer_token = token_info.get("streamer_token") if token_info else None streamer_token = info.streamer_token if info else None
adjusted_timestamp = event_timestamp - self.event_time_lag - self.pre_event_seconds 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" url = URL(f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/{adjusted_timestamp}.mp4").with_query({
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1" "mp4-fragment-length": "0.5",
"mp4-use-speed": "0",
"mp4-afiller": "1",
})
if streamer_token: if streamer_token:
return f"{base}?{params}&token={streamer_token}" url = url.update_query({"token": streamer_token})
return f"{base}?{params}" return str(url)
async def get_camera_stream_url(self, camera_id: str) -> str | None: async def get_camera_stream_url(self, camera_id: str) -> str | None:
token_info = self.camera_tokens.get(camera_id) if self._tokens_invalid:
streamer_token = token_info.get("streamer_token") if token_info else None _LOGGER.debug(f"Токены протухшие, обновляем перед запросом стрима для {camera_id}")
await self.refresh_tokens()
base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4" info = self.camera_tokens.get(camera_id)
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1" streamer_token = info.streamer_token if info else None
url = URL(f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4").with_query({
"mp4-fragment-length": "0.5",
"mp4-use-speed": "0",
"mp4-afiller": "1",
})
if streamer_token: if streamer_token:
return f"{base}?{params}&token={streamer_token}" url = url.update_query({"token": streamer_token})
return f"{base}?{params}" return str(url)
# === Домофон: открытие ===
async def open_intercom(self, intercom_id: str): async def open_intercom(self, intercom_id: str):
try: try:
await self.hass.async_add_executor_job( r = await self._api_post(
functools.partial(
requests.post,
f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open", f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open",
headers={"Authorization": f"Bearer {self.token}"}, {"Authorization": f"Bearer {self.token}"},
json={},
timeout=10,
) )
) if r and r.status in (200, 204):
_LOGGER.info(f"✅ Открыт домофон {intercom_id}") _LOGGER.info(f"Открыт домофон {intercom_id}")
elif r:
_LOGGER.error(f"Ошибка открытия домофона {intercom_id}: статус {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка открытия {intercom_id}: {e}") _LOGGER.error(f"Ошибка открытия {intercom_id}: {e}")
# === Время и события ===
def _parse_iso_to_timestamp(self, iso_str: str) -> int: def _parse_iso_to_timestamp(self, iso_str: str) -> int:
try: try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
@@ -548,35 +772,30 @@ class RTKeyCamerasApi:
async def get_last_event(self, intercom_id: str) -> dict | None: async def get_last_event(self, intercom_id: str) -> dict | None:
try: try:
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
url = ( url = URL("https://events.key.rt.ru/api/v2/events/list").with_query({
f"https://events.key.rt.ru/api/v2/events/list?" "begin_raised_at": "2018-01-01T00:00:00Z",
f"begin_raised_at=2018-01-01T00:00:00Z&" "end_raised_at": now_iso,
f"end_raised_at={now_iso}&" "device_ids": intercom_id,
f"device_ids={intercom_id}&" "limit": "1",
f"limit=1&" "sort_by": "raised_at",
f"sort_by=raised_at&" "offset": "0",
f"offset=0" })
)
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
url, url,
headers={ {"Authorization": f"Bearer {self.token}", "Accept": "application/json"},
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
},
timeout=15, timeout=15,
) )
) if r is None:
return None
if r.status_code == 200: if r.status == 200:
data = r.json() data = json.loads(r.body)
items = data.get("data", {}).get("items", []) items = data.get("data", {}).get("items", [])
if items: if items:
return items[0] return items[0]
else: else:
_LOGGER.warning(f"Events API вернул {r.status_code} для {intercom_id}") _LOGGER.warning(f"Events API вернул {r.status} для {intercom_id}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}") _LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}")
return None return None
@@ -591,13 +810,23 @@ class RTKeyCamerasApi:
if not event_timestamp: if not event_timestamp:
return return
screenshot_data = None screenshot_path = None
if self.event_screenshot_enabled: screenshot_error = None
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp) 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_url = None archive_path = None
if self.event_archive_enabled: archive_error = None
archive_url = self.get_event_archive_url(camera_id, event_timestamp) 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] = { self.last_events[intercom_id] = {
"event": event, "event": event,
@@ -606,12 +835,14 @@ class RTKeyCamerasApi:
"local_time": self._format_local_time(event.get("raised_at", "")), "local_time": self._format_local_time(event.get("raised_at", "")),
"description": self._get_event_description(event), "description": self._get_event_description(event),
"event_type": event.get("event_type", ""), "event_type": event.get("event_type", ""),
"screenshot_data": screenshot_data, "screenshot_path": screenshot_path,
"archive_url": archive_url, "archive_path": archive_path,
"screenshot_error": screenshot_error,
"archive_error": archive_error,
} }
_LOGGER.info( _LOGGER.info(
f"🔔 Новое событие для домофона {intercom_id}: " f"Новое событие для домофона {intercom_id}: "
f"{self.last_events[intercom_id]['description']} " f"{self.last_events[intercom_id]['description']} "
f"в {self.last_events[intercom_id]['local_time']}" f"в {self.last_events[intercom_id]['local_time']}"
) )
@@ -619,21 +850,26 @@ class RTKeyCamerasApi:
self._notify_event_listeners(intercom_id) self._notify_event_listeners(intercom_id)
async def _events_polling_loop(self): async def _events_polling_loop(self):
_LOGGER.info(f"🔄 Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)") _LOGGER.info(f"Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)")
while True: while True:
try: try:
for intercom_id in self.intercom_ids: tasks = [self.get_last_event(id) for id in self.intercom_ids]
last_event = await self.get_last_event(intercom_id) results = await asyncio.gather(*tasks, return_exceptions=True)
if not last_event:
for intercom_id, result in zip(self.intercom_ids, results):
if isinstance(result, Exception):
_LOGGER.error(f"Ошибка опроса событий для {intercom_id}: {result}")
continue
if not result:
continue continue
request_id = last_event.get("request_id") request_id = result.get("request_id")
last_request_id = self._last_request_ids.get(intercom_id) last_request_id = self._last_request_ids.get(intercom_id)
if request_id and request_id != last_request_id: if request_id and request_id != last_request_id:
self._last_request_ids[intercom_id] = request_id self._last_request_ids[intercom_id] = request_id
await self.update_last_event(intercom_id, last_event) await self.update_last_event(intercom_id, result)
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка в цикле опроса событий: {e}") _LOGGER.error(f"Ошибка в цикле опроса событий: {e}")
@@ -649,6 +885,7 @@ class RTKeyCamerasApi:
return return
await self._fetch_intercoms() await self._fetch_intercoms()
await self._ensure_intercom_dirs(self.intercom_ids)
self._events_polling_task = asyncio.create_task(self._events_polling_loop()) self._events_polling_task = asyncio.create_task(self._events_polling_loop())
@@ -668,6 +905,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"cameras_api": cameras_api "cameras_api": cameras_api
} }
await cameras_api.async_initialize()
entry.add_update_listener(update_listener) entry.add_update_listener(update_listener)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -679,7 +918,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"] cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"]
await cameras_api.stop_events_polling() await cameras_api.stop_events_polling()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+41 -56
View File
@@ -1,14 +1,13 @@
import datetime from pathlib import Path
from typing import Any from typing import Any
from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER from . import _LOGGER, DOMAIN, RTKeyCamerasApi
async def async_setup_entry( async def async_setup_entry(
@@ -24,15 +23,14 @@ async def async_setup_entry(
for camera_info in cameras_info["data"]["items"] for camera_info in cameras_info["data"]["items"]
] ]
# Добавляем архивные камеры событий для домофонов if cameras_api.archive_copies > 0:
if cameras_api.event_archive_enabled:
intercoms_info = await cameras_api.get_intercoms_info() intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []): for intercom_info in intercoms_info.get("data", {}).get("devices", []):
entities.append( entities.append(
RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info) RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info)
) )
else: else:
_LOGGER.info("Архивные видео событий отключены") _LOGGER.info("Архивные видео событий отключены (archive_copies=0)")
async_add_entities(entities) async_add_entities(entities)
@@ -40,6 +38,8 @@ async def async_setup_entry(
class RTKeyCamera(Camera): class RTKeyCamera(Camera):
"""Live-камера (для всех устройств — и камер, и домофонов).""" """Live-камера (для всех устройств — и камер, и домофонов)."""
_attr_supported_features = CameraEntityFeature.STREAM
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
@@ -64,39 +64,6 @@ class RTKeyCamera(Camera):
self._attr_unique_id = f"camera_{self.camera_id}" self._attr_unique_id = f"camera_{self.camera_id}"
self._attr_name = self.device_name self._attr_name = self.device_name
self._attr_supported_features = CameraEntityFeature.STREAM
self._unsub_stream_refresh: Any = None
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self._unsub_stream_refresh = async_track_time_interval(
self.hass,
self._stream_refresh,
datetime.timedelta(seconds=TOKEN_REFRESH_BUFFER),
)
_LOGGER.debug(f"Таймер обновления стрима запущен для {self.device_name}")
async def async_will_remove_from_hass(self) -> None:
if self._unsub_stream_refresh:
self._unsub_stream_refresh()
self._unsub_stream_refresh = None
await super().async_will_remove_from_hass()
async def _stream_refresh(self, now: datetime.datetime) -> None:
try:
# Если токены протухли — обновляем их перед получением URL
if self.cameras_api._tokens_invalid:
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
await self.cameras_api.refresh_tokens()
url = await self.stream_source()
if url and self.stream and getattr(self.stream, "source", None) != url:
_LOGGER.debug(f"Обновляем URL стрима для {self.device_name}")
self.stream.update_source(url)
except Exception as e:
_LOGGER.error(f"Ошибка обновления стрима для {self.camera_id}: {e}")
async def stream_source(self) -> str | None: async def stream_source(self) -> str | None:
return await self.cameras_api.get_camera_stream_url(self.camera_id) return await self.cameras_api.get_camera_stream_url(self.camera_id)
@@ -108,16 +75,14 @@ class RTKeyCamera(Camera):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Камера доступна, если она не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id) return self.cameras_api.is_camera_available(self.camera_id)
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id): if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title status = self._device_status_title
else: else:
status = "❌ Нет связи" status = "Нет связи"
attrs = {"Статус": status} attrs = {"Статус": status}
if self._device_model: if self._device_model:
@@ -145,10 +110,11 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera): class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события.""" """Архив события: проигрывание сохранённого видео."""
_attr_should_poll = False _attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = CameraEntityFeature.STREAM
def __init__( def __init__(
self, self,
@@ -172,11 +138,11 @@ class RTKeyEventCamera(Camera):
self._attr_unique_id = f"event_camera_{self.intercom_id}" self._attr_unique_id = f"event_camera_{self.intercom_id}"
self._attr_name = "Архив события" self._attr_name = "Архив события"
self._attr_supported_features = CameraEntityFeature.STREAM
self._event_description = None self._event_description = None
self._event_time = None self._event_time = None
self._archive_url = None self._archive_path = None
self._archive_error = None
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
await super().async_added_to_hass() await super().async_added_to_hass()
@@ -199,20 +165,37 @@ class RTKeyEventCamera(Camera):
if not event_info: if not event_info:
return return
archive_url = event_info.get("archive_url") archive_path = event_info.get("archive_path")
if archive_url: archive_error = event_info.get("archive_error")
self._archive_url = archive_url
if archive_path:
self._archive_path = archive_path
self._archive_error = None
self._event_description = event_info.get("description") self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time") self._event_time = event_info.get("local_time")
self.async_write_ha_state()
if self.stream: elif archive_error:
_LOGGER.debug(f"Обновляем URL архива для {self.device_name}") self._archive_error = archive_error
self.stream.update_source(archive_url)
self.async_write_ha_state() self.async_write_ha_state()
async def stream_source(self) -> str | None: async def stream_source(self) -> str | None:
return self._archive_url if self._archive_path and Path(self._archive_path).exists():
return f"file://{self._archive_path}"
return None
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
event_info = self.cameras_api.last_events.get(self.intercom_id)
if event_info and event_info.get("screenshot_path"):
path = Path(event_info["screenshot_path"])
if path.exists():
return await self.hass.async_add_executor_job(path.read_bytes)
return None
@property
def available(self) -> bool:
return self._archive_path is not None and Path(self._archive_path).exists()
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
@@ -221,8 +204,10 @@ class RTKeyEventCamera(Camera):
attrs["Событие"] = self._event_description attrs["Событие"] = self._event_description
if self._event_time: if self._event_time:
attrs["Время"] = self._event_time attrs["Время"] = self._event_time
if self._archive_url: if self._archive_path:
attrs["URL архива"] = self._archive_url attrs["Путь к архиву"] = self._archive_path
if self._archive_error:
attrs["Ошибка скачивания"] = self._archive_error
return attrs return attrs
@property @property
+5 -6
View File
@@ -5,7 +5,6 @@ from homeassistant.config_entries import (
OptionsFlowWithConfigEntry, OptionsFlowWithConfigEntry,
) )
from homeassistant.core import callback from homeassistant.core import callback
from . import ( from . import (
CONF_NAME, CONF_NAME,
CONF_TOKEN, CONF_TOKEN,
@@ -16,11 +15,12 @@ from . import (
CONF_EVENTS_REFRESH_INTERVAL, CONF_EVENTS_REFRESH_INTERVAL,
CONF_EVENT_TIME_LAG, CONF_EVENT_TIME_LAG,
CONF_PRE_EVENT_SECONDS, CONF_PRE_EVENT_SECONDS,
CONF_EVENT_SCREENSHOT_ENABLED,
CONF_EVENT_SCREENSHOT_QUALITY, CONF_EVENT_SCREENSHOT_QUALITY,
CONF_EVENT_ARCHIVE_ENABLED,
CONF_ARCHIVE_DURATION,
CONF_EVENT_SENSOR_ENABLED, CONF_EVENT_SENSOR_ENABLED,
CONF_ARCHIVE_PATH,
CONF_ARCHIVE_COPIES,
CONF_ARCHIVE_DURATION,
CONF_SCREENSHOT_COPIES,
DATA_SCHEMA, DATA_SCHEMA,
OPTIONS_SCHEMA, OPTIONS_SCHEMA,
DOMAIN, DOMAIN,
@@ -64,11 +64,10 @@ class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN):
options=user_input, options=user_input,
) )
# ✅ Добавляем add_suggested_values_to_schema для корректной работы переводов
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA), vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA),
{}, # Пустые значения по умолчанию для первой настройки {},
), ),
) )
+34 -17
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
from typing import Any from typing import Any
from homeassistant.components.image import ImageEntity from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -8,7 +8,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER
@@ -20,7 +19,6 @@ async def async_setup_entry(
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"] cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
cameras_info = await cameras_api.get_cameras_info() cameras_info = await cameras_api.get_cameras_info()
# Проверяем, включены ли обычные скриншоты
interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60) interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60)
entities = [] entities = []
@@ -34,15 +32,15 @@ async def async_setup_entry(
else: else:
_LOGGER.info("Обычные скриншоты отключены (interval=0)") _LOGGER.info("Обычные скриншоты отключены (interval=0)")
# Скриншоты событий # 🆕 Скриншоты событий только если screenshot_copies > 0
if cameras_api.event_screenshot_enabled: if cameras_api.screenshot_copies > 0:
intercoms_info = await cameras_api.get_intercoms_info() intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []): for intercom_info in intercoms_info.get("data", {}).get("devices", []):
entities.append( entities.append(
RTKeyEventImage(hass, config_entry, cameras_api, intercom_info) RTKeyEventImage(hass, config_entry, cameras_api, intercom_info)
) )
else: else:
_LOGGER.info("Скриншоты событий отключены") _LOGGER.info("Скриншоты событий отключены (screenshot_copies=0)")
async_add_entities(entities) async_add_entities(entities)
@@ -97,8 +95,6 @@ class RTKeyCameraImageEntity(ImageEntity):
await super().async_will_remove_from_hass() await super().async_will_remove_from_hass()
async def _async_update_image(self, now: datetime | None = None) -> None: async def _async_update_image(self, now: datetime | None = None) -> None:
"""Продолжаем опрашивать API даже если камера недоступна,
чтобы узнать, когда она снова появится."""
try: try:
image = await self.cameras_api.get_camera_image(self.camera_id) image = await self.cameras_api.get_camera_image(self.camera_id)
if image: if image:
@@ -114,12 +110,10 @@ class RTKeyCameraImageEntity(ImageEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Сущность доступна, если камера не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id) return self.cameras_api.is_camera_available(self.camera_id)
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id): if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title status = self._device_status_title
else: else:
@@ -147,7 +141,7 @@ class RTKeyCameraImageEntity(ImageEntity):
class RTKeyEventImage(ImageEntity): class RTKeyEventImage(ImageEntity):
"""Скриншот на момент последнего события домофона.""" """Скриншот на момент последнего события домофона (локальный файл)."""
_attr_should_poll = False _attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -176,7 +170,8 @@ class RTKeyEventImage(ImageEntity):
self._attr_name = "Скриншот события" self._attr_name = "Скриншот события"
self._attr_image_last_updated = None self._attr_image_last_updated = None
self._cached_image: bytes | None = None self._screenshot_path = None
self._screenshot_error = None
self._event_description = None self._event_description = None
self._event_time = None self._event_time = None
@@ -201,13 +196,15 @@ class RTKeyEventImage(ImageEntity):
if not event_info: if not event_info:
return return
screenshot_data = event_info.get("screenshot_data") screenshot_path = event_info.get("screenshot_path")
if screenshot_data: screenshot_error = event_info.get("screenshot_error")
self._cached_image = screenshot_data
if screenshot_path:
self._screenshot_path = screenshot_path
self._screenshot_error = None
self._event_description = event_info.get("description") self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time") self._event_time = event_info.get("local_time")
# ✅ Используем время события, а не текущее время
event_timestamp = event_info.get("timestamp") event_timestamp = event_info.get("timestamp")
if event_timestamp: if event_timestamp:
self._attr_image_last_updated = datetime.fromtimestamp( self._attr_image_last_updated = datetime.fromtimestamp(
@@ -219,9 +216,25 @@ class RTKeyEventImage(ImageEntity):
self.async_write_ha_state() self.async_write_ha_state()
_LOGGER.debug(f"Обновлён скриншот события для {self.device_name}") _LOGGER.debug(f"Обновлён скриншот события для {self.device_name}")
elif screenshot_error:
self._screenshot_error = screenshot_error
self.async_write_ha_state()
async def async_image(self) -> bytes | None: async def async_image(self) -> bytes | None:
return self._cached_image """Читаем скриншот с диска."""
if self._screenshot_path and Path(self._screenshot_path).exists():
try:
return await self.hass.async_add_executor_job(
Path(self._screenshot_path).read_bytes
)
except Exception as e:
_LOGGER.error(f"Ошибка чтения скриншота {self._screenshot_path}: {e}")
return None
@property
def available(self) -> bool:
"""Доступна, если есть локальный файл."""
return self._screenshot_path is not None and Path(self._screenshot_path).exists()
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
@@ -230,6 +243,10 @@ class RTKeyEventImage(ImageEntity):
attrs["Событие"] = self._event_description attrs["Событие"] = self._event_description
if self._event_time: if self._event_time:
attrs["Время"] = self._event_time attrs["Время"] = self._event_time
if self._screenshot_path:
attrs["Путь к скриншоту"] = self._screenshot_path
if self._screenshot_error:
attrs["Ошибка скачивания"] = self._screenshot_error
return attrs return attrs
@property @property
+2 -2
View File
@@ -4,8 +4,8 @@
"documentation": "https://github.com/sirmrak/RTKey", "documentation": "https://github.com/sirmrak/RTKey",
"issue_tracker": "https://github.com/sirmrak/RTKey/issues", "issue_tracker": "https://github.com/sirmrak/RTKey/issues",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"version": "1.0.1", "version": "1.2.3",
"requirements": ["transliterate", "pyjwt"], "requirements": [],
"config_flow": true, "config_flow": true,
"codeowners": ["@sirmrak"] "codeowners": ["@sirmrak"]
} }
+3 -3
View File
@@ -100,9 +100,9 @@ class RTKeyEventSensor(SensorEntity):
"Тип события": self._event_data.get("event_type", ""), "Тип события": self._event_data.get("event_type", ""),
} }
archive_url = self._event_data.get("archive_url") archive_path = self._event_data.get("archive_path")
if archive_url: if archive_path:
attrs["Архив видео"] = archive_url attrs["Архив видео"] = archive_path
event = self._event_data.get("event", {}) event = self._event_data.get("event", {})
rfid = event.get("rfid") rfid = event.get("rfid")
+20 -10
View File
@@ -14,15 +14,20 @@
"events_refresh_interval": "Event polling interval (sec)", "events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)", "event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show", "pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality", "event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera", "event_sensor_enabled": "Create event sensor entity",
"archive_duration": "Archive duration (sec)", "archive_path": "Archive storage path",
"event_sensor_enabled": "Create event sensor entity" "archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
}, },
"data_description": { "data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru", "token": "Get token via browser developer tools (F12) on key.rt.ru",
"camera_image_refresh_interval": "0 — disable periodic screenshots" "camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom",
"archive_duration": "Duration of archived video fragment per event",
"screenshot_copies": "Maximum number of saved screenshots per intercom"
} }
} }
} }
@@ -42,15 +47,20 @@
"events_refresh_interval": "Event polling interval (sec)", "events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)", "event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show", "pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality", "event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera", "event_sensor_enabled": "Create event sensor entity",
"archive_duration": "Archive duration (sec)", "archive_path": "Archive storage path",
"event_sensor_enabled": "Create event sensor entity" "archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
}, },
"data_description": { "data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru", "token": "Get token via browser developer tools (F12) on key.rt.ru",
"camera_image_refresh_interval": "0 — disable periodic screenshots" "camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom",
"archive_duration": "Duration of archived video fragment per event",
"screenshot_copies": "Maximum number of saved screenshots per intercom"
} }
} }
} }
+20 -10
View File
@@ -14,15 +14,20 @@
"events_refresh_interval": "Интервал опроса событий (сек)", "events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)", "event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа", "pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события", "event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_duration": "Длительность архива (сек)", "archive_path": "Путь для сохранения архивов",
"event_sensor_enabled": "Создавать сенсор последнего события" "archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
}, },
"data_description": { "data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru", "token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
"camera_image_refresh_interval": "0 — отключить периодические скриншоты" "camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
} }
} }
} }
@@ -42,15 +47,20 @@
"events_refresh_interval": "Интервал опроса событий (сек)", "events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)", "event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа", "pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события", "event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_duration": "Длительность архива (сек)", "archive_path": "Путь для сохранения архивов",
"event_sensor_enabled": "Создавать сенсор последнего события" "archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
}, },
"data_description": { "data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru", "token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
"camera_image_refresh_interval": "0 — отключить периодические скриншоты" "camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
} }
} }
} }