5 Commits
Author SHA1 Message Date
sirmrak ff5406828c Создание структуры медиа-папок при инициализации интеграции
— _ensure_media_dirs(): базовые archives/ и screenshots/ создаются сразу в async_initialize
— _ensure_intercom_dirs(): подпапки per-intercom создаются после загрузки домофонов
— Папки готовы до первого события, не нужно ждать download_event_*
2026-06-28 17:53:51 +03:00
sirmrak c121261f3d Исправление media path: убрано двойное media, правильный HA API
— Default archive_path: 'media/RTKey' → 'RTKey' (без дублирования /media/media/)
— Fallback: '/media' → hass.config.path('media') (HA API вместо hardcoded)
— Поддержка ключа 'local' в media_dirs (если 'media' отсутствует)
— Обновлены подсказки в переводах en/ru
2026-06-28 17:31:02 +03:00
sirmrak 15a97be8e2 Оптимизация и модернизация __init__.py
— aiohttp вместо requests: реальный async, единая ClientSession, connection pooling
— async file I/O через executor (write_bytes, mkdir, cleanup)
— asyncio.gather для параллельного опроса событий
— asyncio.wait_for(timeout=5) на FFmpeg check
— CameraTokenInfo dataclass вместо dict
— yarl.URL для безопасной кодировки query-параметров
— Общие _api_get/_api_post, уменьшение дублирования
— CONF_ARCHIVE_DURATION вынесена в настройки
— Удалена бессмысленная ветка в get_intercoms_info
— _close_session() cleanup при unload
— sensor.py: archive_url → archive_path
— Обновлены переводы и config_flow для archive_duration
2026-06-28 17:14:49 +03:00
sirmrak 97624d6750 Изменение максимального времени опроса событий до 3600 секунд 2026-06-28 16:19:54 +03:00
sirmrak a12554c010 Хранение скриншотов и архивов на диск вместо in-memory
— Замена event_screenshot_enabled/event_archive_enabled на screenshot_copies/archive_copies (0 = отключено)
— Новый параметр archive_path для пути сохранения (относительно /media/)
— Скачивание скриншотов и видео-архивов событий на локальный диск с ограничением копий
— Проверка FFmpeg при инициализации, поддержка file:// для stream_source
— Обновлены переводы (en/ru) и config_flow
2026-06-28 16:09:47 +03:00
7 changed files with 643 additions and 355 deletions
File diff suppressed because it is too large Load Diff
+32 -18
View File
@@ -1,13 +1,12 @@
import datetime
from pathlib import Path
from typing import Any
from homeassistant.components.camera import Camera, CameraEntityFeature
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
@@ -24,15 +23,15 @@ async def async_setup_entry(
for camera_info in cameras_info["data"]["items"]
]
# Добавляем архивные камеры событий для домофонов
if cameras_api.event_archive_enabled:
# 🆕 Добавляем архивные камеры только если 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", []):
entities.append(
RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info)
)
else:
_LOGGER.info("Архивные видео событий отключены")
_LOGGER.info("Архивные видео событий отключены (archive_copies=0)")
async_add_entities(entities)
@@ -86,7 +85,6 @@ class RTKeyCamera(Camera):
async def _stream_refresh(self, now: datetime.datetime) -> None:
try:
# Если токены протухли — обновляем их перед получением URL
if self.cameras_api._tokens_invalid:
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
await self.cameras_api.refresh_tokens()
@@ -108,12 +106,10 @@ class RTKeyCamera(Camera):
@property
def available(self) -> bool:
"""Камера доступна, если она не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title
else:
@@ -145,7 +141,7 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события."""
"""Камера для просмотра архивного видео на момент последнего события (локальный файл)."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -176,7 +172,8 @@ class RTKeyEventCamera(Camera):
self._event_description = 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:
await super().async_added_to_hass()
@@ -199,20 +196,35 @@ class RTKeyEventCamera(Camera):
if not event_info:
return
archive_url = event_info.get("archive_url")
if archive_url:
self._archive_url = archive_url
archive_path = event_info.get("archive_path")
archive_error = event_info.get("archive_error")
if archive_path:
self._archive_path = archive_path
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"Обновляем URL архива для {self.device_name}")
self.stream.update_source(archive_url)
_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:
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
def extra_state_attributes(self) -> dict[str, Any]:
@@ -221,8 +233,10 @@ class RTKeyEventCamera(Camera):
attrs["Событие"] = self._event_description
if self._event_time:
attrs["Время"] = self._event_time
if self._archive_url:
attrs["URL архива"] = self._archive_url
if self._archive_path:
attrs["Путь к архиву"] = self._archive_path
if self._archive_error:
attrs["Ошибка скачивания"] = self._archive_error
return attrs
@property
+5 -6
View File
@@ -5,7 +5,6 @@ from homeassistant.config_entries import (
OptionsFlowWithConfigEntry,
)
from homeassistant.core import callback
from . import (
CONF_NAME,
CONF_TOKEN,
@@ -16,11 +15,12 @@ from . import (
CONF_EVENTS_REFRESH_INTERVAL,
CONF_EVENT_TIME_LAG,
CONF_PRE_EVENT_SECONDS,
CONF_EVENT_SCREENSHOT_ENABLED,
CONF_EVENT_SCREENSHOT_QUALITY,
CONF_EVENT_ARCHIVE_ENABLED,
CONF_ARCHIVE_DURATION,
CONF_EVENT_SENSOR_ENABLED,
CONF_ARCHIVE_PATH,
CONF_ARCHIVE_COPIES,
CONF_ARCHIVE_DURATION,
CONF_SCREENSHOT_COPIES,
DATA_SCHEMA,
OPTIONS_SCHEMA,
DOMAIN,
@@ -64,11 +64,10 @@ class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN):
options=user_input,
)
# ✅ Добавляем add_suggested_values_to_schema для корректной работы переводов
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA),
{}, # Пустые значения по умолчанию для первой настройки
{},
),
)
+34 -17
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
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.entity import EntityCategory
from zoneinfo import ZoneInfo
from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER
@@ -20,7 +19,6 @@ async def async_setup_entry(
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
cameras_info = await cameras_api.get_cameras_info()
# Проверяем, включены ли обычные скриншоты
interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60)
entities = []
@@ -34,15 +32,15 @@ async def async_setup_entry(
else:
_LOGGER.info("Обычные скриншоты отключены (interval=0)")
# Скриншоты событий
if cameras_api.event_screenshot_enabled:
# 🆕 Скриншоты событий только если screenshot_copies > 0
if cameras_api.screenshot_copies > 0:
intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []):
entities.append(
RTKeyEventImage(hass, config_entry, cameras_api, intercom_info)
)
else:
_LOGGER.info("Скриншоты событий отключены")
_LOGGER.info("Скриншоты событий отключены (screenshot_copies=0)")
async_add_entities(entities)
@@ -97,8 +95,6 @@ class RTKeyCameraImageEntity(ImageEntity):
await super().async_will_remove_from_hass()
async def _async_update_image(self, now: datetime | None = None) -> None:
"""Продолжаем опрашивать API даже если камера недоступна,
чтобы узнать, когда она снова появится."""
try:
image = await self.cameras_api.get_camera_image(self.camera_id)
if image:
@@ -114,12 +110,10 @@ class RTKeyCameraImageEntity(ImageEntity):
@property
def available(self) -> bool:
"""Сущность доступна, если камера не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title
else:
@@ -147,7 +141,7 @@ class RTKeyCameraImageEntity(ImageEntity):
class RTKeyEventImage(ImageEntity):
"""Скриншот на момент последнего события домофона."""
"""Скриншот на момент последнего события домофона (локальный файл)."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -176,7 +170,8 @@ class RTKeyEventImage(ImageEntity):
self._attr_name = "Скриншот события"
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_time = None
@@ -201,13 +196,15 @@ class RTKeyEventImage(ImageEntity):
if not event_info:
return
screenshot_data = event_info.get("screenshot_data")
if screenshot_data:
self._cached_image = screenshot_data
screenshot_path = event_info.get("screenshot_path")
screenshot_error = event_info.get("screenshot_error")
if screenshot_path:
self._screenshot_path = screenshot_path
self._screenshot_error = None
self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time")
# ✅ Используем время события, а не текущее время
event_timestamp = event_info.get("timestamp")
if event_timestamp:
self._attr_image_last_updated = datetime.fromtimestamp(
@@ -219,9 +216,25 @@ class RTKeyEventImage(ImageEntity):
self.async_write_ha_state()
_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:
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
def extra_state_attributes(self) -> dict[str, Any]:
@@ -230,6 +243,10 @@ class RTKeyEventImage(ImageEntity):
attrs["Событие"] = self._event_description
if 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
@property
+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")
+20 -10
View File
@@ -14,15 +14,20 @@
"events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera",
"archive_duration": "Archive duration (sec)",
"event_sensor_enabled": "Create event sensor entity"
"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"
"camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom",
"archive_duration": "Duration of archived video fragment per event",
"screenshot_copies": "Maximum number of saved screenshots per intercom"
}
}
}
@@ -42,15 +47,20 @@
"events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera",
"archive_duration": "Archive duration (sec)",
"event_sensor_enabled": "Create event sensor entity"
"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"
"camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom",
"archive_duration": "Duration of archived video fragment per event",
"screenshot_copies": "Maximum number of saved screenshots per intercom"
}
}
}
+20 -10
View File
@@ -14,15 +14,20 @@
"events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий",
"archive_duration": "Длительность архива (сек)",
"event_sensor_enabled": "Создавать сенсор последнего события"
"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 — отключить периодические скриншоты"
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
}
}
}
@@ -42,15 +47,20 @@
"events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий",
"archive_duration": "Длительность архива (сек)",
"event_sensor_enabled": "Создавать сенсор последнего события"
"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 — отключить периодические скриншоты"
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
}
}
}