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

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-29 11:57:54 +03:00
sirmrak 8b65cb43eb Новая версия 2026-06-29 11:35:48 +03:00
sirmrak 02a3d821e3 Пропуск повторной загрузки существующих архивов/скриншотов, иконка в README
— archive/screenshot: проверка exists+st_size>0 перед скачиванием
— README: добавлена иконка интеграции в заголовок
2026-06-29 11:19:58 +03:00
sirmrak 0e07bc8c9d Длительность архива = archive_duration, Camera без STREAM для архивов
— duration = archive_duration (не сумма lag+pre+archive)
— RTKeyEventCamera: убран STREAM (HLS обрезает видео + reconnect loop)
— RTKeyEventCamera: async_camera_image() для thumbnail (fix NotImplementedError)
— Убраны эмодзи из статусов, убран CameraEntityFeature импорт из EventCamera
2026-06-28 22:33:17 +03:00
sirmrak 8472b91cd1 Критические баг-фиксы и подготовка к релизу v1.1.0
— _ApiResponse dataclass: body читается внутри async with (Connection closed fix)
— _mkdir_parents helper: keyword args вместо positional (FileExistsError fix)
— async_get_clientsession(hass): HA managed session, убран self._session/_close_session
— manifest.json: version 1.1.0, requirements [] (убраны transliterate, pyjwt)
2026-06-28 19:33:33 +03:00
sirmrak ff5406828c Создание структуры медиа-папок при инициализации интеграции
— _ensure_media_dirs(): базовые archives/ и screenshots/ создаются сразу в async_initialize
— _ensure_intercom_dirs(): подпапки per-intercom создаются после загрузки домофонов
— Папки готовы до первого события, не нужно ждать download_event_*
2026-06-28 17:53:51 +03:00
sirmrak c121261f3d Исправление media path: убрано двойное media, правильный HA API
— Default archive_path: 'media/RTKey' → 'RTKey' (без дублирования /media/media/)
— Fallback: '/media' → hass.config.path('media') (HA API вместо hardcoded)
— Поддержка ключа 'local' в media_dirs (если 'media' отсутствует)
— Обновлены подсказки в переводах en/ru
2026-06-28 17:31:02 +03:00
sirmrak 15a97be8e2 Оптимизация и модернизация __init__.py
— aiohttp вместо requests: реальный async, единая ClientSession, connection pooling
— async file I/O через executor (write_bytes, mkdir, cleanup)
— asyncio.gather для параллельного опроса событий
— asyncio.wait_for(timeout=5) на FFmpeg check
— CameraTokenInfo dataclass вместо dict
— yarl.URL для безопасной кодировки query-параметров
— Общие _api_get/_api_post, уменьшение дублирования
— CONF_ARCHIVE_DURATION вынесена в настройки
— Удалена бессмысленная ветка в get_intercoms_info
— _close_session() cleanup при unload
— sensor.py: archive_url → archive_path
— Обновлены переводы и config_flow для archive_duration
2026-06-28 17:14:49 +03:00
sirmrak 97624d6750 Изменение максимального времени опроса событий до 3600 секунд 2026-06-28 16:19:54 +03:00
sirmrak a12554c010 Хранение скриншотов и архивов на диск вместо in-memory
— Замена event_screenshot_enabled/event_archive_enabled на screenshot_copies/archive_copies (0 = отключено)
— Новый параметр archive_path для пути сохранения (относительно /media/)
— Скачивание скриншотов и видео-архивов событий на локальный диск с ограничением копий
— Проверка FFmpeg при инициализации, поддержка file:// для stream_source
— Обновлены переводы (en/ru) и config_flow
2026-06-28 16:09:47 +03:00
9 changed files with 655 additions and 395 deletions
+1
View File
@@ -1,3 +1,4 @@
<img src="custom_components/rtkey/icon.png" width="128"> </img>
# RT Key Integration for Home Assistant
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/)
File diff suppressed because it is too large Load Diff
+41 -56
View File
@@ -1,14 +1,13 @@
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
from . import _LOGGER, DOMAIN, RTKeyCamerasApi
async def async_setup_entry(
@@ -24,15 +23,14 @@ async def async_setup_entry(
for camera_info in cameras_info["data"]["items"]
]
# Добавляем архивные камеры событий для домофонов
if cameras_api.event_archive_enabled:
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)
@@ -40,6 +38,8 @@ async def async_setup_entry(
class RTKeyCamera(Camera):
"""Live-камера (для всех устройств — и камер, и домофонов)."""
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
hass: HomeAssistant,
@@ -64,39 +64,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:
# Если токены протухли — обновляем их перед получением URL
if self.cameras_api._tokens_invalid:
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
await self.cameras_api.refresh_tokens()
url = await self.stream_source()
if url and self.stream and getattr(self.stream, "source", None) != url:
_LOGGER.debug(f"Обновляем URL стрима для {self.device_name}")
self.stream.update_source(url)
except Exception as e:
_LOGGER.error(f"Ошибка обновления стрима для {self.camera_id}: {e}")
async def stream_source(self) -> str | None:
return await self.cameras_api.get_camera_stream_url(self.camera_id)
@@ -108,16 +75,14 @@ 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:
status = "❌ Нет связи"
status = "Нет связи"
attrs = {"Статус": status}
if self._device_model:
@@ -145,10 +110,11 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события."""
"""Архив события: проигрывание сохранённого видео."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
@@ -172,11 +138,11 @@ 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
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 +165,37 @@ 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)
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
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
event_info = self.cameras_api.last_events.get(self.intercom_id)
if event_info and event_info.get("screenshot_path"):
path = Path(event_info["screenshot_path"])
if path.exists():
return await self.hass.async_add_executor_job(path.read_bytes)
return None
@property
def available(self) -> bool:
return self._archive_path is not None and Path(self._archive_path).exists()
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -221,8 +204,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
+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.3",
"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")
+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": "Максимальное количество сохранённых скриншотов на домофон"
}
}
}