Хранение скриншотов и архивов на диск вместо in-memory
— Замена event_screenshot_enabled/event_archive_enabled на screenshot_copies/archive_copies (0 = отключено) — Новый параметр archive_path для пути сохранения (относительно /media/) — Скачивание скриншотов и видео-архивов событий на локальный диск с ограничением копий — Проверка FFmpeg при инициализации, поддержка file:// для stream_source — Обновлены переводы (en/ru) и config_flow
This commit is contained in:
@@ -3,11 +3,10 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
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 requests
|
||||||
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
|
||||||
@@ -15,7 +14,6 @@ from homeassistant.helpers import selector
|
|||||||
|
|
||||||
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 +28,14 @@ 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_SCREENSHOT_COPIES = "screenshot_copies"
|
||||||
|
|
||||||
TOKEN_REFRESH_BUFFER = 300
|
TOKEN_REFRESH_BUFFER = 300
|
||||||
|
|
||||||
DATA_SCHEMA = {
|
DATA_SCHEMA = {
|
||||||
@@ -46,7 +46,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 +53,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 +64,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,9 +75,7 @@ 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=60, step=5,
|
||||||
@@ -88,7 +83,6 @@ OPTIONS_SCHEMA = {
|
|||||||
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 +90,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 +97,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,17 +108,20 @@ 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="media/RTKey"): str,
|
||||||
|
vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector(
|
||||||
vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
selector.NumberSelectorConfig(
|
||||||
min=10, max=120, step=10,
|
min=0, max=10, step=1,
|
||||||
mode=selector.NumberSelectorMode.SLIDER,
|
mode=selector.NumberSelectorMode.BOX,
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,32 +138,34 @@ 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, "media/RTKey")
|
||||||
|
self.archive_copies = entry.options.get(CONF_ARCHIVE_COPIES, 5)
|
||||||
|
self.screenshot_copies = entry.options.get(CONF_SCREENSHOT_COPIES, 10)
|
||||||
|
|
||||||
log_level = entry.options.get(CONF_LOG_LEVEL, "INFO")
|
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 = None
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
# Информация о камерах
|
# Информация о камерах
|
||||||
self.camera_tokens = {}
|
self.camera_tokens = {}
|
||||||
self._cameras_cache = None
|
self._cameras_cache = None
|
||||||
self._cameras_cache_ts = 0
|
self._cameras_cache_ts = 0
|
||||||
|
|
||||||
# Кэш картинок
|
# Кэш картинок
|
||||||
self._image_cache = {}
|
self._image_cache = {}
|
||||||
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()
|
||||||
|
|
||||||
@@ -185,21 +180,171 @@ class RTKeyCamerasApi:
|
|||||||
|
|
||||||
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}")
|
||||||
|
|
||||||
|
# 🆕 Проверка FFmpeg
|
||||||
|
self.ffmpeg_available = False
|
||||||
|
|
||||||
|
async def async_initialize(self):
|
||||||
|
"""Инициализация после создания (для async операций)."""
|
||||||
|
await self._check_ffmpeg()
|
||||||
|
|
||||||
|
async def _check_ffmpeg(self):
|
||||||
|
"""Проверяем наличие FFmpeg в системе."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ffmpeg", "-version",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
await proc.communicate()
|
||||||
|
self.ffmpeg_available = True
|
||||||
|
_LOGGER.info("✅ FFmpeg доступен")
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.ffmpeg_available = False
|
||||||
|
_LOGGER.error("❌ FFmpeg не найден! Архивное видео будет недоступно.")
|
||||||
|
except Exception as e:
|
||||||
|
self.ffmpeg_available = False
|
||||||
|
_LOGGER.error(f"❌ Ошибка проверки FFmpeg: {e}")
|
||||||
|
|
||||||
|
def _get_media_base_path(self) -> Path:
|
||||||
|
"""Получаем базовый путь для медиафайлов."""
|
||||||
|
# HA хранит медиа в /media/ (media_dirs)
|
||||||
|
media_dirs = self.hass.config.media_dirs
|
||||||
|
if media_dirs and "media" in media_dirs:
|
||||||
|
return Path(media_dirs["media"])
|
||||||
|
# Fallback
|
||||||
|
return Path("/media")
|
||||||
|
|
||||||
|
def _get_archive_dir(self, intercom_id: str) -> Path:
|
||||||
|
"""Путь к папке архивов для домофона."""
|
||||||
|
return self._get_media_base_path() / self.archive_path / "archives" / intercom_id
|
||||||
|
|
||||||
|
def _get_screenshot_dir(self, intercom_id: str) -> Path:
|
||||||
|
"""Путь к папке скриншотов для домофона."""
|
||||||
|
return self._get_media_base_path() / self.archive_path / "screenshots" / intercom_id
|
||||||
|
|
||||||
|
async def download_event_archive(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
|
||||||
|
"""Скачиваем архивное видео через FFmpeg и сохраняем локально."""
|
||||||
|
if not self.ffmpeg_available:
|
||||||
|
_LOGGER.error("FFmpeg недоступен, пропускаем скачивание архива")
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = self.get_event_archive_url(camera_id, event_timestamp)
|
||||||
|
if not url:
|
||||||
|
_LOGGER.error(f"Не удалось получить URL архива для камеры {camera_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Создаём папку
|
||||||
|
archive_dir = self._get_archive_dir(intercom_id)
|
||||||
|
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Имя файла
|
||||||
|
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
|
||||||
|
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4")
|
||||||
|
filepath = archive_dir / filename
|
||||||
|
|
||||||
|
# Вычисляем длительность: pre_event + lag + post_event (archive_duration)
|
||||||
|
# archive_duration по умолчанию 30 сек
|
||||||
|
archive_duration = 30 # Можно вынести в настройки позже
|
||||||
|
duration = self.pre_event_seconds + self.event_time_lag + archive_duration
|
||||||
|
|
||||||
|
_LOGGER.info(f" Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", url,
|
||||||
|
"-t", str(duration),
|
||||||
|
"-c", "copy",
|
||||||
|
str(filepath),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
proc.communicate(),
|
||||||
|
timeout=duration + 30 # Запас на задержки сети
|
||||||
|
)
|
||||||
|
|
||||||
|
if proc.returncode == 0 and filepath.exists() and filepath.stat().st_size > 1000:
|
||||||
|
_LOGGER.info(f"✅ Архив сохранён: {filepath} ({filepath.stat().st_size} байт)")
|
||||||
|
self.cleanup_old_archives(intercom_id)
|
||||||
|
return str(filepath)
|
||||||
|
else:
|
||||||
|
error_msg = stderr.decode()[:200] if stderr else "Неизвестная ошибка"
|
||||||
|
_LOGGER.error(f"❌ Ошибка FFmpeg для {intercom_id}: {error_msg}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.error(f"⏱️ Превышено время скачивания архива для {intercom_id}")
|
||||||
|
if proc:
|
||||||
|
proc.kill()
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def download_event_screenshot(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
|
||||||
|
"""Скачиваем скриншот события и сохраняем локально."""
|
||||||
|
screenshot_dir = self._get_screenshot_dir(intercom_id)
|
||||||
|
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
|
||||||
|
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg")
|
||||||
|
filepath = screenshot_dir / filename
|
||||||
|
|
||||||
|
# Получаем скриншот через существующий метод
|
||||||
|
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
|
||||||
|
|
||||||
|
if screenshot_data:
|
||||||
|
filepath.write_bytes(screenshot_data)
|
||||||
|
_LOGGER.info(f"✅ Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)")
|
||||||
|
self.cleanup_old_screenshots(intercom_id)
|
||||||
|
return str(filepath)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cleanup_old_archives(self, intercom_id: str):
|
||||||
|
"""Удаляем старые архивные видео, если превышено количество копий."""
|
||||||
|
archive_dir = self._get_archive_dir(intercom_id)
|
||||||
|
if not archive_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
files = sorted(archive_dir.glob("*.mp4"), key=lambda f: f.stat().st_mtime)
|
||||||
|
while len(files) > self.archive_copies:
|
||||||
|
oldest = files.pop(0)
|
||||||
|
oldest.unlink()
|
||||||
|
_LOGGER.debug(f"🗑️ Удалён старый архив: {oldest.name}")
|
||||||
|
|
||||||
|
def cleanup_old_screenshots(self, intercom_id: str):
|
||||||
|
"""Удаляем старые скриншоты, если превышено количество копий."""
|
||||||
|
screenshot_dir = self._get_screenshot_dir(intercom_id)
|
||||||
|
if not screenshot_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
files = sorted(screenshot_dir.glob("*.jpg"), key=lambda f: f.stat().st_mtime)
|
||||||
|
while len(files) > self.screenshot_copies:
|
||||||
|
oldest = files.pop(0)
|
||||||
|
oldest.unlink()
|
||||||
|
_LOGGER.debug(f"🗑️ Удалён старый скриншот: {oldest.name}")
|
||||||
|
|
||||||
def build_device_name(self, name: str) -> str:
|
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."""
|
"""Получаем название камеры по её ID."""
|
||||||
token_info = self.camera_tokens.get(camera_id)
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
if token_info:
|
if token_info:
|
||||||
return token_info.get("title", "Неизвестная камера")
|
return token_info.get("title", "Неизвестная камера")
|
||||||
return "Неизвестная камера"
|
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):
|
||||||
if callback not in self._event_listeners:
|
if callback not in self._event_listeners:
|
||||||
self._event_listeners.append(callback)
|
self._event_listeners.append(callback)
|
||||||
@@ -214,12 +359,12 @@ class RTKeyCamerasApi:
|
|||||||
callback(intercom_id)
|
callback(intercom_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error(f"Ошибка в event listener: {e}")
|
_LOGGER.error(f"Ошибка в event listener: {e}")
|
||||||
|
|
||||||
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(
|
r = await self.hass.async_add_executor_job(
|
||||||
@@ -233,10 +378,10 @@ class RTKeyCamerasApi:
|
|||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
devices = data.get("data", [])
|
devices = data.get("data", [])
|
||||||
|
|
||||||
if devices:
|
if devices:
|
||||||
self.user_token = devices[0].get("userToken")
|
self.user_token = devices[0].get("userToken")
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
uid = device.get("uid")
|
uid = device.get("uid")
|
||||||
if uid:
|
if uid:
|
||||||
@@ -255,7 +400,7 @@ class RTKeyCamerasApi:
|
|||||||
"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)} устройств")
|
||||||
@@ -265,17 +410,17 @@ class RTKeyCamerasApi:
|
|||||||
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."""
|
"""Получаем информацию о домофонах из отдельного API."""
|
||||||
try:
|
try:
|
||||||
@@ -322,10 +467,10 @@ class RTKeyCamerasApi:
|
|||||||
f"Построены маппинги: {len(self.intercom_camera_map)} камер домофонов, "
|
f"Построены маппинги: {len(self.intercom_camera_map)} камер домофонов, "
|
||||||
f"{len(self.rfid_names)} ключей"
|
f"{len(self.rfid_names)} ключей"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_cameras_info(self):
|
async def get_cameras_info(self):
|
||||||
await self._fetch_cameras()
|
await self._fetch_cameras()
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for uid, info in self.camera_tokens.items():
|
for uid, info in self.camera_tokens.items():
|
||||||
items.append({
|
items.append({
|
||||||
@@ -338,18 +483,16 @@ class RTKeyCamerasApi:
|
|||||||
"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:
|
if not self.intercom_ids:
|
||||||
return await self._fetch_intercoms()
|
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."""
|
"""Получаем скриншот камеры с обработкой ошибок 403 и 404."""
|
||||||
async with self._image_cache_lock:
|
async with self._image_cache_lock:
|
||||||
@@ -357,19 +500,18 @@ class RTKeyCamerasApi:
|
|||||||
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)
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
screenshot_token = token_info.get("screenshot_token") if token_info else None
|
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
|
user_token = (token_info.get("user_token") or self.user_token) if token_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 = 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 += f"?token={screenshot_token}&_={int(time.time() * 1000)}"
|
||||||
|
|
||||||
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}")
|
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}")
|
||||||
r = await self.hass.async_add_executor_job(
|
r = await self.hass.async_add_executor_job(
|
||||||
functools.partial(
|
functools.partial(
|
||||||
@@ -385,8 +527,7 @@ class RTKeyCamerasApi:
|
|||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ Обработка 403 — токен протух
|
|
||||||
if r.status_code == 403 and not retry:
|
if r.status_code == 403 and not retry:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
|
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
|
||||||
@@ -400,8 +541,7 @@ class RTKeyCamerasApi:
|
|||||||
f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией"
|
f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ✅ Обработка 404 — камера недоступна
|
|
||||||
if r.status_code == 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(
|
||||||
@@ -409,13 +549,12 @@ class RTKeyCamerasApi:
|
|||||||
)
|
)
|
||||||
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_code == 200 and len(r.content) > 2000:
|
||||||
image_data = r.content
|
image_data = r.content
|
||||||
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}) снова доступна")
|
||||||
@@ -429,8 +568,7 @@ class RTKeyCamerasApi:
|
|||||||
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:
|
||||||
"""Получаем скриншот на момент события (с учётом лага)."""
|
"""Получаем скриншот на момент события (с учётом лага)."""
|
||||||
token_info = self.camera_tokens.get(camera_id)
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
@@ -481,17 +619,17 @@ class RTKeyCamerasApi:
|
|||||||
if streamer_token:
|
if streamer_token:
|
||||||
return f"{base}?{params}&token={streamer_token}"
|
return f"{base}?{params}&token={streamer_token}"
|
||||||
return f"{base}?{params}"
|
return f"{base}?{params}"
|
||||||
|
|
||||||
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)
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
streamer_token = token_info.get("streamer_token") if token_info else None
|
streamer_token = token_info.get("streamer_token") if token_info else None
|
||||||
|
|
||||||
base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4"
|
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"
|
params = "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}"
|
return f"{base}?{params}&token={streamer_token}"
|
||||||
return f"{base}?{params}"
|
return f"{base}?{params}"
|
||||||
|
|
||||||
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(
|
await self.hass.async_add_executor_job(
|
||||||
@@ -591,13 +729,25 @@ class RTKeyCamerasApi:
|
|||||||
if not event_timestamp:
|
if not event_timestamp:
|
||||||
return
|
return
|
||||||
|
|
||||||
screenshot_data = None
|
# 🆕 Скачиваем скриншот на диск (если включено)
|
||||||
if self.event_screenshot_enabled:
|
screenshot_path = None
|
||||||
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
|
screenshot_error = None
|
||||||
|
if self.screenshot_copies > 0:
|
||||||
|
try:
|
||||||
|
screenshot_path = await self.download_event_screenshot(camera_id, event_timestamp, intercom_id)
|
||||||
|
except Exception as e:
|
||||||
|
screenshot_error = str(e)
|
||||||
|
_LOGGER.error(f"Ошибка скачивания скриншота для {intercom_id}: {e}")
|
||||||
|
|
||||||
archive_url = None
|
# 🆕 Скачиваем архив на диск (если включено)
|
||||||
if self.event_archive_enabled:
|
archive_path = None
|
||||||
archive_url = self.get_event_archive_url(camera_id, event_timestamp)
|
archive_error = None
|
||||||
|
if self.archive_copies > 0:
|
||||||
|
try:
|
||||||
|
archive_path = await self.download_event_archive(camera_id, event_timestamp, intercom_id)
|
||||||
|
except Exception as e:
|
||||||
|
archive_error = str(e)
|
||||||
|
_LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}")
|
||||||
|
|
||||||
self.last_events[intercom_id] = {
|
self.last_events[intercom_id] = {
|
||||||
"event": event,
|
"event": event,
|
||||||
@@ -606,8 +756,10 @@ 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(
|
||||||
@@ -667,7 +819,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
||||||
"cameras_api": cameras_api
|
"cameras_api": cameras_api
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 🆕 Инициализация (проверка FFmpeg)
|
||||||
|
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 +834,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import datetime
|
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.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, TOKEN_REFRESH_BUFFER
|
||||||
|
|
||||||
|
|
||||||
@@ -18,21 +17,21 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
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()
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
RTKeyCamera(hass, config_entry, cameras_api, camera_info)
|
RTKeyCamera(hass, config_entry, cameras_api, camera_info)
|
||||||
for camera_info in cameras_info["data"]["items"]
|
for camera_info in cameras_info["data"]["items"]
|
||||||
]
|
]
|
||||||
|
|
||||||
# Добавляем архивные камеры событий для домофонов
|
# 🆕 Добавляем архивные камеры только если archive_copies > 0
|
||||||
if cameras_api.event_archive_enabled:
|
if cameras_api.archive_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(
|
||||||
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)
|
||||||
|
|
||||||
@@ -86,7 +85,6 @@ class RTKeyCamera(Camera):
|
|||||||
|
|
||||||
async def _stream_refresh(self, now: datetime.datetime) -> None:
|
async def _stream_refresh(self, now: datetime.datetime) -> None:
|
||||||
try:
|
try:
|
||||||
# Если токены протухли — обновляем их перед получением URL
|
|
||||||
if self.cameras_api._tokens_invalid:
|
if self.cameras_api._tokens_invalid:
|
||||||
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
|
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
|
||||||
await self.cameras_api.refresh_tokens()
|
await self.cameras_api.refresh_tokens()
|
||||||
@@ -108,12 +106,10 @@ 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:
|
||||||
@@ -145,7 +141,7 @@ 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
|
||||||
@@ -176,7 +172,8 @@ class RTKeyEventCamera(Camera):
|
|||||||
|
|
||||||
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 +196,35 @@ 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")
|
||||||
|
|
||||||
if self.stream:
|
if self.stream:
|
||||||
_LOGGER.debug(f"Обновляем URL архива для {self.device_name}")
|
_LOGGER.debug(f"Обновляем путь архива для {self.device_name}")
|
||||||
self.stream.update_source(archive_url)
|
# Для локального файла stream не обновляем через update_source
|
||||||
|
# stream_source() будет вызван при запросе
|
||||||
|
|
||||||
self.async_write_ha_state()
|
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:
|
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
|
||||||
|
|
||||||
|
@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 +233,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,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,11 @@ 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_SCREENSHOT_COPIES,
|
||||||
DATA_SCHEMA,
|
DATA_SCHEMA,
|
||||||
OPTIONS_SCHEMA,
|
OPTIONS_SCHEMA,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -64,11 +63,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),
|
||||||
{}, # Пустые значения по умолчанию для первой настройки
|
{},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +18,7 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
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
|
||||||
|
|||||||
@@ -14,15 +14,18 @@
|
|||||||
"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)",
|
||||||
|
"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": "Relative to /media/ folder (e.g.: RTKey)",
|
||||||
|
"archive_copies": "Maximum number of saved videos per intercom",
|
||||||
|
"screenshot_copies": "Maximum number of saved screenshots per intercom"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,15 +45,18 @@
|
|||||||
"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)",
|
||||||
|
"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": "Relative to /media/ folder (e.g.: RTKey)",
|
||||||
|
"archive_copies": "Maximum number of saved videos per intercom",
|
||||||
|
"screenshot_copies": "Maximum number of saved screenshots per intercom"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,18 @@
|
|||||||
"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 = отключено)",
|
||||||
|
"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": "Относительно папки /media/ (например: RTKey)",
|
||||||
|
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
||||||
|
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,15 +45,18 @@
|
|||||||
"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 = отключено)",
|
||||||
|
"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": "Относительно папки /media/ (например: RTKey)",
|
||||||
|
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
||||||
|
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user