9 Commits
Author SHA1 Message Date
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
8 changed files with 518 additions and 442 deletions
+1
View File
@@ -1,3 +1,4 @@
<img src="custom_components/rtkey/icon.png" width="128"> </img>
# RT Key Integration for Home Assistant
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/)
+368 -263
View File
@@ -1,16 +1,20 @@
import asyncio
import functools
import json
import logging
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
import aiohttp
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
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from yarl import URL
DOMAIN = "rtkey"
PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR]
@@ -31,9 +35,10 @@ 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_ARCHIVE_DURATION = "archive_duration"
CONF_SCREENSHOT_COPIES = "screenshot_copies"
TOKEN_REFRESH_BUFFER = 300
@@ -108,14 +113,21 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
# 🆕 Настройки локального хранения
vol.Optional(CONF_ARCHIVE_PATH, default="media/RTKey"): str,
# Настройки локального хранения
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(
selector.NumberSelectorConfig(
min=10, max=120, step=10,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек",
)
),
vol.Optional(CONF_SCREENSHOT_COPIES, default=10): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=10, step=1,
@@ -126,6 +138,42 @@ OPTIONS_SCHEMA = {
}
@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:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.hass = hass
@@ -141,25 +189,28 @@ class RTKeyCamerasApi:
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_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")
_LOGGER.setLevel(getattr(logging, log_level, logging.INFO))
_LOGGER.info(f"📊 Уровень логирования: {log_level}")
_LOGGER.info(f"Уровень логирования: {log_level}")
self.user_token = None
self.lock = asyncio.Lock()
self.user_token: str | None = None
# 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_ts = 0
# Кэш картинок
self._image_cache = {}
self._image_cache: dict[str, tuple[bytes, float]] = {}
self._image_cache_lock = asyncio.Lock()
# Отслеживание недоступных камер (404)
@@ -170,61 +221,73 @@ class RTKeyCamerasApi:
self._tokens_refresh_lock = asyncio.Lock()
# Данные событий
self.last_events = {}
self._last_request_ids = {}
self.rfid_names = {}
self.intercom_camera_map = {}
self.intercom_ids = []
self.last_events: dict[str, dict] = {}
self._last_request_ids: dict[str, str] = {}
self.rfid_names: dict[str, str] = {}
self.intercom_camera_map: dict[str, str] = {}
self.intercom_ids: list[str] = []
self._events_polling_task: asyncio.Task | None = None
self._event_listeners = []
self._event_listeners: list = []
self._local_tz = ZoneInfo(hass.config.time_zone)
_LOGGER.info(f"🕐 Временная зона HA: {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()
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):
"""Проверяем наличие FFmpeg в системе."""
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
await asyncio.wait_for(proc.communicate(), timeout=5)
self.ffmpeg_available = True
_LOGGER.info("✅ FFmpeg доступен")
_LOGGER.info("FFmpeg доступен")
except FileNotFoundError:
self.ffmpeg_available = False
_LOGGER.error("❌ FFmpeg не найден! Архивное видео будет недоступно.")
except Exception as e:
_LOGGER.error("FFmpeg не найден! Архивное видео будет недоступно.")
except (asyncio.TimeoutError, Exception) as e:
self.ffmpeg_available = False
_LOGGER.error(f"❌ Ошибка проверки FFmpeg: {e}")
_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")
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:
"""Скачиваем архивное видео через FFmpeg и сохраняем локально."""
if not self.ffmpeg_available:
_LOGGER.error("FFmpeg недоступен, пропускаем скачивание архива")
return None
@@ -234,21 +297,20 @@ class RTKeyCamerasApi:
_LOGGER.error(f"Не удалось получить URL архива для камеры {camera_id}")
return None
# Создаём папку
archive_dir = self._get_archive_dir(intercom_id)
archive_dir.mkdir(parents=True, exist_ok=True)
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
# Вычисляем длительность: pre_event + lag + post_event (archive_duration)
# archive_duration по умолчанию 30 сек
archive_duration = 30 # Можно вынести в настройки позже
duration = self.pre_event_seconds + self.event_time_lag + archive_duration
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)
_LOGGER.info(f" Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
duration = self.archive_duration
_LOGGER.info(f"Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
cmd = [
"ffmpeg", "-y",
@@ -258,6 +320,7 @@ class RTKeyCamerasApi:
str(filepath),
]
proc: asyncio.subprocess.Process | None = None
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
@@ -266,20 +329,26 @@ class RTKeyCamerasApi:
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=duration + 30 # Запас на задержки сети
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
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}")
_LOGGER.error(f"Превышено время скачивания архива для {intercom_id}")
if proc:
proc.kill()
return None
@@ -288,61 +357,51 @@ class RTKeyCamerasApi:
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)
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:
filepath.write_bytes(screenshot_data)
_LOGGER.info(f"✅ Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)")
self.cleanup_old_screenshots(intercom_id)
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
def cleanup_old_archives(self, intercom_id: str):
"""Удаляем старые архивные видео, если превышено количество копий."""
archive_dir = self._get_archive_dir(intercom_id)
if not archive_dir.exists():
return
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}")
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}")
await self.hass.async_add_executor_job(_cleanup)
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 "Неизвестная камера"
info = self.camera_tokens.get(camera_id)
return info.title if info else "Неизвестная камера"
def is_camera_available(self, camera_id: str) -> bool:
"""Проверяем, доступна ли камера (не в списке недоступных)."""
return camera_id not in self._unavailable_cameras
def register_event_listener(self, callback):
@@ -360,23 +419,55 @@ class RTKeyCamerasApi:
except Exception as 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):
"""Получаем информацию о камерах (токены для стрима и скриншотов)."""
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()
url = URL("https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list").with_query({
"paging.limit": 100,
"paging.offset": 0,
})
r = await self._api_get(url, {"Authorization": f"Bearer {self.token}"})
if r is None:
return []
if r.status == 200:
data = json.loads(r.body)
devices = data.get("data", [])
if devices:
@@ -385,66 +476,65 @@ class RTKeyCamerasApi:
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.camera_tokens[uid] = CameraTokenInfo(
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)} устройств")
_LOGGER.info(f"Загружено {len(self.camera_tokens)} устройств")
return devices
else:
_LOGGER.error(f"camera_video_data/list вернул {r.status_code}")
_LOGGER.error(f"camera_video_data/list вернул {r.status}")
except Exception as e:
_LOGGER.error(f"Ошибка получения камер: {e}")
return []
async def refresh_tokens(self):
"""🔄 Принудительно обновляем все токены (сбрасываем кэш)."""
async with self._tokens_refresh_lock:
_LOGGER.info(" Принудительное обновление токенов...")
_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)} устройств")
_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,
)
r = await self._api_get(
"https://household.key.rt.ru/api/v2/app/devices/intercom",
{"Authorization": f"Bearer {self.token}"},
timeout=15,
)
if r.status_code == 200:
data = r.json()
if r is None:
return {"data": {"devices": []}}
if r.status == 200:
data = json.loads(r.body)
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}")
_LOGGER.info(f"Загружено {len(self.intercom_ids)} домофонов: {self.intercom_ids}")
return data
else:
_LOGGER.error(f"intercom вернул {r.status_code}")
_LOGGER.error(f"intercom вернул {r.status}")
except Exception as e:
_LOGGER.error(f"Ошибка получения домофонов: {e}")
return {"data": {"devices": []}}
@@ -475,176 +565,193 @@ class RTKeyCamerasApi:
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"],
"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
info = self.camera_tokens.get(camera_id)
screenshot_token = info.screenshot_token if info else None
user_token = (info.user_token or self.user_token) if 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"
url = 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)}"
url = url.with_query({"token": screenshot_token, "_": str(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,
)
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id})")
r = await self._api_get(
url,
{**_DEFAULT_HEADERS, "X-UTOKEN": user_token or self.token},
timeout=15,
)
if r is None:
return None
if r.status_code == 403 and not retry:
_LOGGER.warning(
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
)
if r.status == 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}) — проблема с авторизацией"
)
if r.status == 403 and retry:
_LOGGER.error(f"Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией")
return None
if r.status_code == 404:
if r.status == 404:
if camera_id not in self._unavailable_cameras:
_LOGGER.warning(
f"📷 Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline"
)
_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 r.status == 200:
image_data = r.body
if len(image_data) > 2000:
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}) снова доступна")
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
_LOGGER.info(f"Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт")
return image_data
else:
_LOGGER.warning(f"Скриншот для {camera_name} ({camera_id}): размер {len(image_data)} слишком мал")
else:
_LOGGER.warning(
f"Скриншот для {camera_name} ({camera_id}): статус {r.status_code}, размер {len(r.content)}"
)
_LOGGER.warning(f"Скриншот для {camera_name} ({camera_id}): статус {r.status}")
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
info = self.camera_tokens.get(camera_id)
screenshot_token = info.screenshot_token if info else None
user_token = (info.user_token or self.user_token) if 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"
url = 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}"
url = url.with_query({"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,
)
_LOGGER.debug(f"Запрос скриншота события для {camera_id}")
r = await self._api_get(
url,
{**_DEFAULT_HEADERS, "X-UTOKEN": user_token or self.token},
timeout=15,
)
if r is None:
return None
if r.status_code == 200 and len(r.content) > 1000:
_LOGGER.info(f"✅ Скриншот события получен {camera_id} ({len(r.content)} байт)")
return r.content
if r.status == 200:
content = r.body
if len(content) > 1000:
_LOGGER.info(f"Скриншот события получен {camera_id} ({len(content)} байт)")
return content
else:
_LOGGER.warning(f"Скриншот события {camera_id}: размер {len(content)} слишком мал")
else:
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status_code}")
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status}")
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
info = self.camera_tokens.get(camera_id)
streamer_token = info.streamer_token if 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"
url = URL(f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/{adjusted_timestamp}.mp4").with_query({
"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}"
url = url.update_query({"token": streamer_token})
return str(url)
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
async def get_camera_stream_url(self, camera_id: str, retry: bool = False) -> str | None:
if self._tokens_invalid and not retry:
_LOGGER.debug(f"Токены протухшие, обновляем перед запросом стрима для {camera_id}")
await self.refresh_tokens()
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"
info = self.camera_tokens.get(camera_id)
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:
return f"{base}?{params}&token={streamer_token}"
return f"{base}?{params}"
url = url.update_query({"token": streamer_token})
r = await self._api_get(url, _DEFAULT_HEADERS, timeout=10)
if r is None:
return str(url)
if r.status == 403 and not retry:
camera_name = self.get_camera_name(camera_id)
_LOGGER.warning(f"Токен стрима для {camera_name} ({camera_id}) протух (403), запускаем обновление...")
self._tokens_invalid = True
await self.refresh_tokens()
return await self.get_camera_stream_url(camera_id, retry=True)
if r.status == 403 and retry:
camera_name = self.get_camera_name(camera_id)
_LOGGER.error(f"Повторный 403 для стрима {camera_name} ({camera_id}) — проблема с авторизацией")
return None
if r.status == 200:
return str(url)
_LOGGER.warning(f"Стрим для {camera_id}: статус {r.status}")
return str(url)
# === Домофон: открытие ===
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,
)
r = await self._api_post(
f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open",
{"Authorization": f"Bearer {self.token}"},
)
_LOGGER.info(f"✅ Открыт домофон {intercom_id}")
if r and r.status in (200, 204):
_LOGGER.info(f"Открыт домофон {intercom_id}")
elif r:
_LOGGER.error(f"Ошибка открытия домофона {intercom_id}: статус {r.status}")
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"))
@@ -686,35 +793,30 @@ class RTKeyCamerasApi:
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"
)
url = URL("https://events.key.rt.ru/api/v2/events/list").with_query({
"begin_raised_at": "2018-01-01T00:00:00Z",
"end_raised_at": now_iso,
"device_ids": intercom_id,
"limit": "1",
"sort_by": "raised_at",
"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,
)
r = await self._api_get(
url,
{"Authorization": f"Bearer {self.token}", "Accept": "application/json"},
timeout=15,
)
if r is None:
return None
if r.status_code == 200:
data = r.json()
if r.status == 200:
data = json.loads(r.body)
items = data.get("data", {}).get("items", [])
if items:
return items[0]
else:
_LOGGER.warning(f"Events API вернул {r.status_code} для {intercom_id}")
_LOGGER.warning(f"Events API вернул {r.status} для {intercom_id}")
except Exception as e:
_LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}")
return None
@@ -729,7 +831,6 @@ class RTKeyCamerasApi:
if not event_timestamp:
return
# 🆕 Скачиваем скриншот на диск (если включено)
screenshot_path = None
screenshot_error = None
if self.screenshot_copies > 0:
@@ -739,7 +840,6 @@ class RTKeyCamerasApi:
screenshot_error = str(e)
_LOGGER.error(f"Ошибка скачивания скриншота для {intercom_id}: {e}")
# 🆕 Скачиваем архив на диск (если включено)
archive_path = None
archive_error = None
if self.archive_copies > 0:
@@ -763,7 +863,7 @@ class RTKeyCamerasApi:
}
_LOGGER.info(
f"🔔 Новое событие для домофона {intercom_id}: "
f"Новое событие для домофона {intercom_id}: "
f"{self.last_events[intercom_id]['description']} "
f"в {self.last_events[intercom_id]['local_time']}"
)
@@ -771,21 +871,26 @@ class RTKeyCamerasApi:
self._notify_event_listeners(intercom_id)
async def _events_polling_loop(self):
_LOGGER.info(f"🔄 Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)")
_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:
tasks = [self.get_last_event(id) for id in self.intercom_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
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
request_id = last_event.get("request_id")
request_id = result.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)
await self.update_last_event(intercom_id, result)
except Exception as e:
_LOGGER.error(f"Ошибка в цикле опроса событий: {e}")
@@ -801,6 +906,7 @@ class RTKeyCamerasApi:
return
await self._fetch_intercoms()
await self._ensure_intercom_dirs(self.intercom_ids)
self._events_polling_task = asyncio.create_task(self._events_polling_loop())
@@ -820,7 +926,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"cameras_api": cameras_api
}
# 🆕 Инициализация (проверка FFmpeg)
await cameras_api.async_initialize()
entry.add_update_listener(update_listener)
+13 -52
View File
@@ -1,13 +1,13 @@
import datetime
from pathlib import Path
from typing import Any
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory
from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER
from . import _LOGGER, DOMAIN, RTKeyCamerasApi
async def async_setup_entry(
@@ -23,7 +23,6 @@ async def async_setup_entry(
for camera_info in cameras_info["data"]["items"]
]
# 🆕 Добавляем архивные камеры только если archive_copies > 0
if cameras_api.archive_copies > 0:
intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []):
@@ -63,38 +62,6 @@ class RTKeyCamera(Camera):
self._attr_unique_id = f"camera_{self.camera_id}"
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:
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:
return await self.cameras_api.get_camera_stream_url(self.camera_id)
@@ -113,7 +80,7 @@ class RTKeyCamera(Camera):
if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title
else:
status = "❌ Нет связи"
status = "Нет связи"
attrs = {"Статус": status}
if self._device_model:
@@ -141,7 +108,7 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события (локальный файл)."""
"""Архив события: thumbnail-скриншот + ссылка на видео-файл."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -168,7 +135,6 @@ class RTKeyEventCamera(Camera):
self._attr_unique_id = f"event_camera_{self.intercom_id}"
self._attr_name = "Архив события"
self._attr_supported_features = CameraEntityFeature.STREAM
self._event_description = None
self._event_time = None
@@ -204,27 +170,22 @@ class RTKeyEventCamera(Camera):
self._archive_error = None
self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time")
if self.stream:
_LOGGER.debug(f"Обновляем путь архива для {self.device_name}")
# Для локального файла stream не обновляем через update_source
# stream_source() будет вызван при запросе
self.async_write_ha_state()
elif archive_error:
self._archive_error = archive_error
self.async_write_ha_state()
async def stream_source(self) -> str | None:
"""Возвращаем путь к локальному файлу."""
if self._archive_path and Path(self._archive_path).exists():
return f"file://{self._archive_path}"
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Скриншот камеры как thumbnail для карточки."""
if self.camera_id:
return await self.cameras_api.get_camera_image(self.camera_id)
return None
@property
def available(self) -> bool:
"""Доступна, если есть локальный файл."""
return self._archive_path is not None and Path(self._archive_path).exists()
return self.camera_id is not None and self.cameras_api.is_camera_available(self.camera_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
+1
View File
@@ -19,6 +19,7 @@ from . import (
CONF_EVENT_SENSOR_ENABLED,
CONF_ARCHIVE_PATH,
CONF_ARCHIVE_COPIES,
CONF_ARCHIVE_DURATION,
CONF_SCREENSHOT_COPIES,
DATA_SCHEMA,
OPTIONS_SCHEMA,
+2 -2
View File
@@ -4,8 +4,8 @@
"documentation": "https://github.com/sirmrak/RTKey",
"issue_tracker": "https://github.com/sirmrak/RTKey/issues",
"iot_class": "cloud_polling",
"version": "1.0.1",
"requirements": ["transliterate", "pyjwt"],
"version": "1.2.2",
"requirements": [],
"config_flow": true,
"codeowners": ["@sirmrak"]
}
+3 -3
View File
@@ -100,9 +100,9 @@ class RTKeyEventSensor(SensorEntity):
"Тип события": self._event_data.get("event_type", ""),
}
archive_url = self._event_data.get("archive_url")
if archive_url:
attrs["Архив видео"] = archive_url
archive_path = self._event_data.get("archive_path")
if archive_path:
attrs["Архив видео"] = archive_path
event = self._event_data.get("event", {})
rfid = event.get("rfid")
+6 -2
View File
@@ -18,13 +18,15 @@
"event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
},
"data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru",
"camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Relative to /media/ folder (e.g.: RTKey)",
"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"
}
}
@@ -49,13 +51,15 @@
"event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
},
"data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru",
"camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Relative to /media/ folder (e.g.: RTKey)",
"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"
}
}
+6 -2
View File
@@ -18,13 +18,15 @@
"event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
},
"data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Относительно папки /media/ (например: RTKey)",
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
}
}
@@ -49,13 +51,15 @@
"event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
},
"data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Относительно папки /media/ (например: RTKey)",
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
}
}