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

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-29 11:57:54 +03:00
sirmrak 8b65cb43eb Новая версия 2026-06-29 11:35:48 +03:00
sirmrak 02a3d821e3 Пропуск повторной загрузки существующих архивов/скриншотов, иконка в README
— archive/screenshot: проверка exists+st_size>0 перед скачиванием
— README: добавлена иконка интеграции в заголовок
2026-06-29 11:19:58 +03:00
sirmrak 0e07bc8c9d Длительность архива = archive_duration, Camera без STREAM для архивов
— duration = archive_duration (не сумма lag+pre+archive)
— RTKeyEventCamera: убран STREAM (HLS обрезает видео + reconnect loop)
— RTKeyEventCamera: async_camera_image() для thumbnail (fix NotImplementedError)
— Убраны эмодзи из статусов, убран CameraEntityFeature импорт из EventCamera
2026-06-28 22:33:17 +03:00
sirmrak 8472b91cd1 Критические баг-фиксы и подготовка к релизу v1.1.0
— _ApiResponse dataclass: body читается внутри async with (Connection closed fix)
— _mkdir_parents helper: keyword args вместо positional (FileExistsError fix)
— async_get_clientsession(hass): HA managed session, убран self._session/_close_session
— manifest.json: version 1.1.0, requirements [] (убраны transliterate, pyjwt)
2026-06-28 19:33:33 +03:00
sirmrak ff5406828c Создание структуры медиа-папок при инициализации интеграции
— _ensure_media_dirs(): базовые archives/ и screenshots/ создаются сразу в async_initialize
— _ensure_intercom_dirs(): подпапки per-intercom создаются после загрузки домофонов
— Папки готовы до первого события, не нужно ждать download_event_*
2026-06-28 17:53:51 +03:00
sirmrak c121261f3d Исправление media path: убрано двойное media, правильный HA API
— Default archive_path: 'media/RTKey' → 'RTKey' (без дублирования /media/media/)
— Fallback: '/media' → hass.config.path('media') (HA API вместо hardcoded)
— Поддержка ключа 'local' в media_dirs (если 'media' отсутствует)
— Обновлены подсказки в переводах en/ru
2026-06-28 17:31:02 +03:00
sirmrak 15a97be8e2 Оптимизация и модернизация __init__.py
— aiohttp вместо requests: реальный async, единая ClientSession, connection pooling
— async file I/O через executor (write_bytes, mkdir, cleanup)
— asyncio.gather для параллельного опроса событий
— asyncio.wait_for(timeout=5) на FFmpeg check
— CameraTokenInfo dataclass вместо dict
— yarl.URL для безопасной кодировки query-параметров
— Общие _api_get/_api_post, уменьшение дублирования
— CONF_ARCHIVE_DURATION вынесена в настройки
— Удалена бессмысленная ветка в get_intercoms_info
— _close_session() cleanup при unload
— sensor.py: archive_url → archive_path
— Обновлены переводы и config_flow для archive_duration
2026-06-28 17:14:49 +03:00
8 changed files with 518 additions and 442 deletions
+1
View File
@@ -1,3 +1,4 @@
<img src="custom_components/rtkey/icon.png" width="128"> </img>
# RT Key Integration for Home Assistant # RT Key Integration for Home Assistant
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/) [![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/)
File diff suppressed because it is too large Load Diff
+16 -55
View File
@@ -1,13 +1,13 @@
import datetime
from pathlib import Path 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
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER
from . import _LOGGER, DOMAIN, RTKeyCamerasApi
async def async_setup_entry( async def async_setup_entry(
@@ -17,13 +17,12 @@ 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.archive_copies > 0: 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", []):
@@ -63,38 +62,6 @@ class RTKeyCamera(Camera):
self._attr_unique_id = f"camera_{self.camera_id}" self._attr_unique_id = f"camera_{self.camera_id}"
self._attr_name = self.device_name self._attr_name = self.device_name
self._attr_supported_features = CameraEntityFeature.STREAM
self._unsub_stream_refresh: Any = None
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self._unsub_stream_refresh = async_track_time_interval(
self.hass,
self._stream_refresh,
datetime.timedelta(seconds=TOKEN_REFRESH_BUFFER),
)
_LOGGER.debug(f"Таймер обновления стрима запущен для {self.device_name}")
async def async_will_remove_from_hass(self) -> None:
if self._unsub_stream_refresh:
self._unsub_stream_refresh()
self._unsub_stream_refresh = None
await super().async_will_remove_from_hass()
async def _stream_refresh(self, now: datetime.datetime) -> None:
try:
if self.cameras_api._tokens_invalid:
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
await self.cameras_api.refresh_tokens()
url = await self.stream_source()
if url and self.stream and getattr(self.stream, "source", None) != url:
_LOGGER.debug(f"Обновляем URL стрима для {self.device_name}")
self.stream.update_source(url)
except Exception as e:
_LOGGER.error(f"Ошибка обновления стрима для {self.camera_id}: {e}")
async def stream_source(self) -> str | None: async def stream_source(self) -> str | None:
return await self.cameras_api.get_camera_stream_url(self.camera_id) return await self.cameras_api.get_camera_stream_url(self.camera_id)
@@ -113,7 +80,7 @@ class RTKeyCamera(Camera):
if self.cameras_api.is_camera_available(self.camera_id): if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title status = self._device_status_title
else: else:
status = "❌ Нет связи" status = "Нет связи"
attrs = {"Статус": status} attrs = {"Статус": status}
if self._device_model: if self._device_model:
@@ -141,7 +108,7 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera): class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события (локальный файл).""" """Архив события: thumbnail-скриншот + ссылка на видео-файл."""
_attr_should_poll = False _attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -168,7 +135,6 @@ class RTKeyEventCamera(Camera):
self._attr_unique_id = f"event_camera_{self.intercom_id}" self._attr_unique_id = f"event_camera_{self.intercom_id}"
self._attr_name = "Архив события" self._attr_name = "Архив события"
self._attr_supported_features = CameraEntityFeature.STREAM
self._event_description = None self._event_description = None
self._event_time = None self._event_time = None
@@ -198,33 +164,28 @@ class RTKeyEventCamera(Camera):
archive_path = event_info.get("archive_path") archive_path = event_info.get("archive_path")
archive_error = event_info.get("archive_error") archive_error = event_info.get("archive_error")
if archive_path: if archive_path:
self._archive_path = archive_path self._archive_path = archive_path
self._archive_error = None 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:
_LOGGER.debug(f"Обновляем путь архива для {self.device_name}")
# Для локального файла stream не обновляем через update_source
# stream_source() будет вызван при запросе
self.async_write_ha_state() self.async_write_ha_state()
elif archive_error: elif archive_error:
self._archive_error = archive_error self._archive_error = archive_error
self.async_write_ha_state() self.async_write_ha_state()
async def stream_source(self) -> str | None: async def async_camera_image(
"""Возвращаем путь к локальному файлу.""" self, width: int | None = None, height: int | None = None
if self._archive_path and Path(self._archive_path).exists(): ) -> bytes | None:
return f"file://{self._archive_path}" """Скриншот камеры как thumbnail для карточки."""
if self.camera_id:
return await self.cameras_api.get_camera_image(self.camera_id)
return None return None
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Доступна, если есть локальный файл.""" return self.camera_id is not None and self.cameras_api.is_camera_available(self.camera_id)
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]:
@@ -247,4 +208,4 @@ class RTKeyEventCamera(Camera):
"name": self.device_name, "name": self.device_name,
"manufacturer": "RT Key", "manufacturer": "RT Key",
"model": "Домофон", "model": "Домофон",
} }
+1
View File
@@ -19,6 +19,7 @@ from . import (
CONF_EVENT_SENSOR_ENABLED, CONF_EVENT_SENSOR_ENABLED,
CONF_ARCHIVE_PATH, CONF_ARCHIVE_PATH,
CONF_ARCHIVE_COPIES, CONF_ARCHIVE_COPIES,
CONF_ARCHIVE_DURATION,
CONF_SCREENSHOT_COPIES, CONF_SCREENSHOT_COPIES,
DATA_SCHEMA, DATA_SCHEMA,
OPTIONS_SCHEMA, OPTIONS_SCHEMA,
+2 -2
View File
@@ -4,8 +4,8 @@
"documentation": "https://github.com/sirmrak/RTKey", "documentation": "https://github.com/sirmrak/RTKey",
"issue_tracker": "https://github.com/sirmrak/RTKey/issues", "issue_tracker": "https://github.com/sirmrak/RTKey/issues",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"version": "1.0.1", "version": "1.2.2",
"requirements": ["transliterate", "pyjwt"], "requirements": [],
"config_flow": true, "config_flow": true,
"codeowners": ["@sirmrak"] "codeowners": ["@sirmrak"]
} }
+3 -3
View File
@@ -100,9 +100,9 @@ class RTKeyEventSensor(SensorEntity):
"Тип события": self._event_data.get("event_type", ""), "Тип события": self._event_data.get("event_type", ""),
} }
archive_url = self._event_data.get("archive_url") archive_path = self._event_data.get("archive_path")
if archive_url: if archive_path:
attrs["Архив видео"] = archive_url attrs["Архив видео"] = archive_path
event = self._event_data.get("event", {}) event = self._event_data.get("event", {})
rfid = event.get("rfid") rfid = event.get("rfid")
+6 -2
View File
@@ -18,13 +18,15 @@
"event_sensor_enabled": "Create event sensor entity", "event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path", "archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)", "archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot 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_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom", "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" "screenshot_copies": "Maximum number of saved screenshots per intercom"
} }
} }
@@ -49,13 +51,15 @@
"event_sensor_enabled": "Create event sensor entity", "event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path", "archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)", "archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot 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_path": "Subfolder name inside HA media folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom", "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" "screenshot_copies": "Maximum number of saved screenshots per intercom"
} }
} }
+6 -2
View File
@@ -18,13 +18,15 @@
"event_sensor_enabled": "Создавать сенсор последнего события", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов", "archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)", "archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_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_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон", "archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон" "screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
} }
} }
@@ -49,13 +51,15 @@
"event_sensor_enabled": "Создавать сенсор последнего события", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов", "archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)", "archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_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_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон", "archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон" "screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
} }
} }