Загрузить файлы в «custom_components/rtkey»
This commit is contained in:
@@ -0,0 +1,687 @@
|
|||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
|
DOMAIN = "rtkey"
|
||||||
|
PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# === Константы настроек ===
|
||||||
|
CONF_NAME = "name"
|
||||||
|
CONF_TOKEN = "token"
|
||||||
|
CONF_CAMERA_IMAGE_REFRESH_INTERVAL = "camera_image_refresh_interval"
|
||||||
|
CONF_SCREENSHOT_QUALITY = "screenshot_quality"
|
||||||
|
CONF_LOG_LEVEL = "log_level"
|
||||||
|
|
||||||
|
# Настройки событий
|
||||||
|
CONF_EVENTS_ENABLED = "events_enabled"
|
||||||
|
CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval"
|
||||||
|
CONF_EVENT_TIME_LAG = "event_time_lag"
|
||||||
|
CONF_PRE_EVENT_SECONDS = "pre_event_seconds"
|
||||||
|
CONF_EVENT_SCREENSHOT_ENABLED = "event_screenshot_enabled"
|
||||||
|
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"
|
||||||
|
|
||||||
|
TOKEN_REFRESH_BUFFER = 300
|
||||||
|
|
||||||
|
DATA_SCHEMA = {
|
||||||
|
vol.Required(CONF_NAME): str,
|
||||||
|
vol.Required(CONF_TOKEN): str,
|
||||||
|
}
|
||||||
|
|
||||||
|
OPTIONS_SCHEMA = {
|
||||||
|
vol.Optional(CONF_NAME): str,
|
||||||
|
vol.Optional(CONF_TOKEN): str,
|
||||||
|
|
||||||
|
vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector(
|
||||||
|
selector.NumberSelectorConfig(
|
||||||
|
min=0, max=600, step=30,
|
||||||
|
mode=selector.NumberSelectorMode.SLIDER,
|
||||||
|
unit_of_measurement="сек",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(
|
||||||
|
options=[
|
||||||
|
selector.SelectOptionDict(value="small", label="Маленькое (быстрее)"),
|
||||||
|
selector.SelectOptionDict(value="medium", label="Среднее"),
|
||||||
|
selector.SelectOptionDict(value="large", label="Большое"),
|
||||||
|
selector.SelectOptionDict(value="precise", label="Точное (медленнее)"),
|
||||||
|
],
|
||||||
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(
|
||||||
|
options=[
|
||||||
|
selector.SelectOptionDict(value="DEBUG", label="DEBUG (подробно)"),
|
||||||
|
selector.SelectOptionDict(value="INFO", label="INFO (обычный)"),
|
||||||
|
selector.SelectOptionDict(value="WARNING", label="WARNING (только предупреждения)"),
|
||||||
|
selector.SelectOptionDict(value="ERROR", label="ERROR (только ошибки)"),
|
||||||
|
],
|
||||||
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_EVENTS_ENABLED, default=True): bool,
|
||||||
|
|
||||||
|
vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector(
|
||||||
|
selector.NumberSelectorConfig(
|
||||||
|
min=5, max=60, step=5,
|
||||||
|
mode=selector.NumberSelectorMode.SLIDER,
|
||||||
|
unit_of_measurement="сек",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector(
|
||||||
|
selector.NumberSelectorConfig(
|
||||||
|
min=0, max=60, step=5,
|
||||||
|
mode=selector.NumberSelectorMode.SLIDER,
|
||||||
|
unit_of_measurement="сек",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector(
|
||||||
|
selector.NumberSelectorConfig(
|
||||||
|
min=0, max=30, step=5,
|
||||||
|
mode=selector.NumberSelectorMode.SLIDER,
|
||||||
|
unit_of_measurement="сек",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_EVENT_SCREENSHOT_ENABLED, default=True): bool,
|
||||||
|
|
||||||
|
vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(
|
||||||
|
options=[
|
||||||
|
selector.SelectOptionDict(value="small", label="Маленькое"),
|
||||||
|
selector.SelectOptionDict(value="medium", label="Среднее"),
|
||||||
|
selector.SelectOptionDict(value="large", label="Большое"),
|
||||||
|
selector.SelectOptionDict(value="precise", label="Точное"),
|
||||||
|
],
|
||||||
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_EVENT_ARCHIVE_ENABLED, default=True): bool,
|
||||||
|
|
||||||
|
vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector(
|
||||||
|
selector.NumberSelectorConfig(
|
||||||
|
min=10, max=120, step=10,
|
||||||
|
mode=selector.NumberSelectorMode.SLIDER,
|
||||||
|
unit_of_measurement="сек",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
vol.Optional(CONF_EVENT_SENSOR_ENABLED, default=True): bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RTKeyCamerasApi:
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
self.hass = hass
|
||||||
|
self.entry = entry
|
||||||
|
self.token = entry.options.get(CONF_TOKEN) or entry.data.get(CONF_TOKEN, "")
|
||||||
|
self.screenshot_quality = entry.options.get(CONF_SCREENSHOT_QUALITY, "medium")
|
||||||
|
|
||||||
|
# Настройки событий
|
||||||
|
self.events_enabled = entry.options.get(CONF_EVENTS_ENABLED, True)
|
||||||
|
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.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_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)
|
||||||
|
|
||||||
|
log_level = entry.options.get(CONF_LOG_LEVEL, "INFO")
|
||||||
|
_LOGGER.setLevel(getattr(logging, log_level, logging.INFO))
|
||||||
|
_LOGGER.info(f"📊 Уровень логирования: {log_level}")
|
||||||
|
|
||||||
|
self.user_token = None
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Информация о камерах
|
||||||
|
self.camera_tokens = {}
|
||||||
|
self._cameras_cache = None
|
||||||
|
self._cameras_cache_ts = 0
|
||||||
|
|
||||||
|
# Кэш картинок
|
||||||
|
self._image_cache = {}
|
||||||
|
self._image_cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ✅ НОВОЕ: Отслеживание недоступных камер (404)
|
||||||
|
self._unavailable_cameras: set[str] = set()
|
||||||
|
|
||||||
|
# ✅ НОВОЕ: Флаг протухших токенов (403)
|
||||||
|
self._tokens_invalid = False
|
||||||
|
self._tokens_refresh_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Данные событий
|
||||||
|
self.last_events = {}
|
||||||
|
self._last_request_ids = {}
|
||||||
|
self.rfid_names = {}
|
||||||
|
self.intercom_camera_map = {}
|
||||||
|
self.intercom_ids = []
|
||||||
|
self._events_polling_task: asyncio.Task | None = None
|
||||||
|
self._event_listeners = []
|
||||||
|
|
||||||
|
self._local_tz = ZoneInfo(hass.config.time_zone)
|
||||||
|
_LOGGER.info(f"🕐 Временная зона HA: {hass.config.time_zone}")
|
||||||
|
|
||||||
|
def build_device_name(self, name: str) -> str:
|
||||||
|
return name.strip() if name else "RT Key Device"
|
||||||
|
|
||||||
|
def get_camera_name(self, camera_id: str) -> str:
|
||||||
|
"""Получаем название камеры по её ID."""
|
||||||
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
|
if token_info:
|
||||||
|
return token_info.get("title", "Неизвестная камера")
|
||||||
|
return "Неизвестная камера"
|
||||||
|
|
||||||
|
def is_camera_available(self, camera_id: str) -> bool:
|
||||||
|
"""Проверяем, доступна ли камера (не в списке недоступных)."""
|
||||||
|
return camera_id not in self._unavailable_cameras
|
||||||
|
|
||||||
|
def register_event_listener(self, callback):
|
||||||
|
if callback not in self._event_listeners:
|
||||||
|
self._event_listeners.append(callback)
|
||||||
|
|
||||||
|
def unregister_event_listener(self, callback):
|
||||||
|
if callback in self._event_listeners:
|
||||||
|
self._event_listeners.remove(callback)
|
||||||
|
|
||||||
|
def _notify_event_listeners(self, intercom_id: str):
|
||||||
|
for callback in self._event_listeners:
|
||||||
|
try:
|
||||||
|
callback(intercom_id)
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка в event listener: {e}")
|
||||||
|
|
||||||
|
async def _fetch_cameras(self):
|
||||||
|
"""Получаем информацию о камерах (токены для стрима и скриншотов)."""
|
||||||
|
if self._cameras_cache and (time.time() - self._cameras_cache_ts < 60):
|
||||||
|
return self._cameras_cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Запрос camera_video_data/list...")
|
||||||
|
r = await self.hass.async_add_executor_job(
|
||||||
|
functools.partial(
|
||||||
|
requests.get,
|
||||||
|
"https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list?paging.limit=100&paging.offset=0",
|
||||||
|
headers={"Authorization": f"Bearer {self.token}"},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
devices = data.get("data", [])
|
||||||
|
|
||||||
|
if devices:
|
||||||
|
self.user_token = devices[0].get("userToken")
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
uid = device.get("uid")
|
||||||
|
if uid:
|
||||||
|
self.camera_tokens[uid] = {
|
||||||
|
"title": device.get("title", "Устройство"),
|
||||||
|
"category_type": device.get("category", {}).get("type", "unknown"),
|
||||||
|
"category_title": device.get("category", {}).get("title", ""),
|
||||||
|
"model": device.get("model", ""),
|
||||||
|
"vendor": device.get("vendor", ""),
|
||||||
|
"serial_number": device.get("serialNumber", ""),
|
||||||
|
"mac": device.get("mac", ""),
|
||||||
|
"ip": device.get("ip", ""),
|
||||||
|
"status_type": device.get("status", {}).get("type", "unknown"),
|
||||||
|
"status_title": device.get("status", {}).get("title", ""),
|
||||||
|
"streamer_token": device.get("streamerToken"),
|
||||||
|
"screenshot_token": device.get("screenshotToken"),
|
||||||
|
"user_token": device.get("userToken"),
|
||||||
|
}
|
||||||
|
|
||||||
|
self._cameras_cache = devices
|
||||||
|
self._cameras_cache_ts = time.time()
|
||||||
|
_LOGGER.info(f"✅ Загружено {len(self.camera_tokens)} устройств")
|
||||||
|
return devices
|
||||||
|
else:
|
||||||
|
_LOGGER.error(f"camera_video_data/list вернул {r.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка получения камер: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def refresh_tokens(self):
|
||||||
|
"""🔄 Принудительно обновляем все токены (сбрасываем кэш)."""
|
||||||
|
async with self._tokens_refresh_lock:
|
||||||
|
_LOGGER.info("🔄 Принудительное обновление токенов...")
|
||||||
|
self._cameras_cache = None
|
||||||
|
self._cameras_cache_ts = 0
|
||||||
|
await self._fetch_cameras()
|
||||||
|
self._tokens_invalid = False
|
||||||
|
_LOGGER.info(f"✅ Токены обновлены для {len(self.camera_tokens)} устройств")
|
||||||
|
|
||||||
|
async def _fetch_intercoms(self):
|
||||||
|
"""Получаем информацию о домофонах из отдельного API."""
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Запрос домофонов из household.key.rt.ru...")
|
||||||
|
r = await self.hass.async_add_executor_job(
|
||||||
|
functools.partial(
|
||||||
|
requests.get,
|
||||||
|
"https://household.key.rt.ru/api/v2/app/devices/intercom",
|
||||||
|
headers={"Authorization": f"Bearer {self.token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
self._build_intercom_mappings(data)
|
||||||
|
|
||||||
|
devices = data.get("data", {}).get("devices", [])
|
||||||
|
self.intercom_ids = [str(d.get("id")) for d in devices if d.get("id")]
|
||||||
|
|
||||||
|
_LOGGER.info(f"✅ Загружено {len(self.intercom_ids)} домофонов: {self.intercom_ids}")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
_LOGGER.error(f"intercom вернул {r.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка получения домофонов: {e}")
|
||||||
|
return {"data": {"devices": []}}
|
||||||
|
|
||||||
|
def _build_intercom_mappings(self, data: dict):
|
||||||
|
devices = data.get("data", {}).get("devices", [])
|
||||||
|
for device in devices:
|
||||||
|
intercom_id = device.get("id")
|
||||||
|
camera_id = device.get("camera_id")
|
||||||
|
if intercom_id and camera_id:
|
||||||
|
self.intercom_camera_map[str(intercom_id)] = camera_id
|
||||||
|
|
||||||
|
for code in device.get("inter_codes", []):
|
||||||
|
rfid_id = code.get("id")
|
||||||
|
name = code.get("name_by_user", "")
|
||||||
|
if rfid_id and name:
|
||||||
|
self.rfid_names[rfid_id] = name
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
f"Построены маппинги: {len(self.intercom_camera_map)} камер домофонов, "
|
||||||
|
f"{len(self.rfid_names)} ключей"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_cameras_info(self):
|
||||||
|
await self._fetch_cameras()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for uid, info in self.camera_tokens.items():
|
||||||
|
items.append({
|
||||||
|
"id": uid,
|
||||||
|
"title": info["title"],
|
||||||
|
"category_type": info["category_type"],
|
||||||
|
"status_title": info["status_title"],
|
||||||
|
"model": info["model"],
|
||||||
|
"vendor": info["vendor"],
|
||||||
|
"serial_number": info["serial_number"],
|
||||||
|
"mac": info["mac"],
|
||||||
|
"ip": info["ip"],
|
||||||
|
# ✅ Добавляем флаг доступности
|
||||||
|
"available": self.is_camera_available(uid),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"data": {"items": items}}
|
||||||
|
|
||||||
|
async def get_intercoms_info(self):
|
||||||
|
if not self.intercom_ids:
|
||||||
|
return await self._fetch_intercoms()
|
||||||
|
return await self._fetch_intercoms()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None:
|
||||||
|
"""Получаем скриншот камеры с обработкой ошибок 403 и 404."""
|
||||||
|
async with self._image_cache_lock:
|
||||||
|
if camera_id in self._image_cache:
|
||||||
|
img, ts = self._image_cache[camera_id]
|
||||||
|
if time.time() - ts < 4:
|
||||||
|
return img
|
||||||
|
|
||||||
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
|
screenshot_token = token_info.get("screenshot_token") if token_info else None
|
||||||
|
user_token = (token_info.get("user_token") or self.user_token) if token_info else None
|
||||||
|
|
||||||
|
# ✅ Получаем название камеры для логов
|
||||||
|
camera_name = self.get_camera_name(camera_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"https://key.rt.ru/screenshot/image/{self.screenshot_quality}/{camera_id}/last.jpg"
|
||||||
|
if screenshot_token:
|
||||||
|
url += f"?token={screenshot_token}&_={int(time.time() * 1000)}"
|
||||||
|
|
||||||
|
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}")
|
||||||
|
r = await self.hass.async_add_executor_job(
|
||||||
|
functools.partial(
|
||||||
|
requests.get,
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
"Referer": "https://key.rt.ru/",
|
||||||
|
"Origin": "https://key.rt.ru",
|
||||||
|
"X-UTOKEN": user_token or self.token,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ Обработка 403 — токен протух
|
||||||
|
if r.status_code == 403 and not retry:
|
||||||
|
_LOGGER.warning(
|
||||||
|
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
|
||||||
|
)
|
||||||
|
self._tokens_invalid = True
|
||||||
|
await self.refresh_tokens()
|
||||||
|
return await self.get_camera_image(camera_id, retry=True)
|
||||||
|
|
||||||
|
if r.status_code == 403 and retry:
|
||||||
|
_LOGGER.error(
|
||||||
|
f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ✅ Обработка 404 — камера недоступна
|
||||||
|
if r.status_code == 404:
|
||||||
|
if camera_id not in self._unavailable_cameras:
|
||||||
|
_LOGGER.warning(
|
||||||
|
f"📷 Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline"
|
||||||
|
)
|
||||||
|
self._unavailable_cameras.add(camera_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if r.status_code == 200 and len(r.content) > 2000:
|
||||||
|
image_data = r.content
|
||||||
|
async with self._image_cache_lock:
|
||||||
|
self._image_cache[camera_id] = (image_data, time.time())
|
||||||
|
|
||||||
|
# ✅ Если камера была недоступна — помечаем как восстановленную
|
||||||
|
if camera_id in self._unavailable_cameras:
|
||||||
|
self._unavailable_cameras.discard(camera_id)
|
||||||
|
_LOGGER.info(f"✅ Камера \"{camera_name}\" ({camera_id}) снова доступна")
|
||||||
|
|
||||||
|
_LOGGER.info(f"✅ Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт")
|
||||||
|
return image_data
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
f"Скриншот для {camera_name} ({camera_id}): статус {r.status_code}, размер {len(r.content)}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None:
|
||||||
|
"""Получаем скриншот на момент события (с учётом лага)."""
|
||||||
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
|
screenshot_token = token_info.get("screenshot_token") if token_info else None
|
||||||
|
user_token = (token_info.get("user_token") or self.user_token) if token_info else None
|
||||||
|
|
||||||
|
adjusted_timestamp = event_timestamp - self.event_time_lag
|
||||||
|
|
||||||
|
url = f"https://key.rt.ru/screenshot/image/{self.event_screenshot_quality}/{camera_id}/{adjusted_timestamp}.jpg"
|
||||||
|
if screenshot_token:
|
||||||
|
url += f"?token={screenshot_token}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_LOGGER.debug(f"Запрос скриншота события для {camera_id}: {url}")
|
||||||
|
r = await self.hass.async_add_executor_job(
|
||||||
|
functools.partial(
|
||||||
|
requests.get,
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
"Referer": "https://key.rt.ru/",
|
||||||
|
"Origin": "https://key.rt.ru",
|
||||||
|
"X-UTOKEN": user_token or self.token,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code == 200 and len(r.content) > 1000:
|
||||||
|
_LOGGER.info(f"✅ Скриншот события получен {camera_id} ({len(r.content)} байт)")
|
||||||
|
return r.content
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка скриншота события {camera_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_event_archive_url(self, camera_id: str, event_timestamp: int) -> str | None:
|
||||||
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
|
streamer_token = token_info.get("streamer_token") if token_info else None
|
||||||
|
|
||||||
|
adjusted_timestamp = event_timestamp - self.event_time_lag - self.pre_event_seconds
|
||||||
|
|
||||||
|
base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/{adjusted_timestamp}.mp4"
|
||||||
|
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1"
|
||||||
|
|
||||||
|
if streamer_token:
|
||||||
|
return f"{base}?{params}&token={streamer_token}"
|
||||||
|
return f"{base}?{params}"
|
||||||
|
|
||||||
|
async def get_camera_stream_url(self, camera_id: str) -> str | None:
|
||||||
|
token_info = self.camera_tokens.get(camera_id)
|
||||||
|
streamer_token = token_info.get("streamer_token") if token_info else None
|
||||||
|
|
||||||
|
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"
|
||||||
|
if streamer_token:
|
||||||
|
return f"{base}?{params}&token={streamer_token}"
|
||||||
|
return f"{base}?{params}"
|
||||||
|
|
||||||
|
async def open_intercom(self, intercom_id: str):
|
||||||
|
try:
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
functools.partial(
|
||||||
|
requests.post,
|
||||||
|
f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open",
|
||||||
|
headers={"Authorization": f"Bearer {self.token}"},
|
||||||
|
json={},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_LOGGER.info(f"✅ Открыт домофон {intercom_id}")
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка открытия {intercom_id}: {e}")
|
||||||
|
|
||||||
|
def _parse_iso_to_timestamp(self, iso_str: str) -> int:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
||||||
|
return int(dt.timestamp())
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка парсинга времени {iso_str}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _format_local_time(self, iso_str: str) -> str:
|
||||||
|
try:
|
||||||
|
dt_utc = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
||||||
|
dt_local = dt_utc.astimezone(self._local_tz)
|
||||||
|
return dt_local.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка форматирования времени {iso_str}: {e}")
|
||||||
|
return iso_str
|
||||||
|
|
||||||
|
def _get_event_description(self, event: dict) -> str:
|
||||||
|
event_type = event.get("event_type", "")
|
||||||
|
rfid_id = event.get("rfid_id")
|
||||||
|
|
||||||
|
if rfid_id and rfid_id in self.rfid_names:
|
||||||
|
return self.rfid_names[rfid_id]
|
||||||
|
|
||||||
|
descriptions = {
|
||||||
|
"api_open_remote": "Открытие через приложение",
|
||||||
|
"face_open_remote": "Открытие по лицу",
|
||||||
|
"pin_code_open_remote": "Открытие по пин-коду",
|
||||||
|
"code_open_local": "Открытие по коду",
|
||||||
|
"rfid_open_local": "Открытие по ключу",
|
||||||
|
"dtmf_open_local": "Открытие через телефон",
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.get("rfid") and event_type == "rfid_open_local":
|
||||||
|
return f"Ключ {event.get('rfid')}"
|
||||||
|
|
||||||
|
return descriptions.get(event_type, event_type)
|
||||||
|
|
||||||
|
async def get_last_event(self, intercom_id: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
url = (
|
||||||
|
f"https://events.key.rt.ru/api/v2/events/list?"
|
||||||
|
f"begin_raised_at=2018-01-01T00:00:00Z&"
|
||||||
|
f"end_raised_at={now_iso}&"
|
||||||
|
f"device_ids={intercom_id}&"
|
||||||
|
f"limit=1&"
|
||||||
|
f"sort_by=raised_at&"
|
||||||
|
f"offset=0"
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await self.hass.async_add_executor_job(
|
||||||
|
functools.partial(
|
||||||
|
requests.get,
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
items = data.get("data", {}).get("items", [])
|
||||||
|
if items:
|
||||||
|
return items[0]
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(f"Events API вернул {r.status_code} для {intercom_id}")
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_last_event(self, intercom_id: str, event: dict):
|
||||||
|
camera_id = self.intercom_camera_map.get(str(intercom_id))
|
||||||
|
if not camera_id:
|
||||||
|
_LOGGER.warning(f"Не найден camera_id для домофона {intercom_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
event_timestamp = self._parse_iso_to_timestamp(event.get("raised_at", ""))
|
||||||
|
if not event_timestamp:
|
||||||
|
return
|
||||||
|
|
||||||
|
screenshot_data = None
|
||||||
|
if self.event_screenshot_enabled:
|
||||||
|
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
|
||||||
|
|
||||||
|
archive_url = None
|
||||||
|
if self.event_archive_enabled:
|
||||||
|
archive_url = self.get_event_archive_url(camera_id, event_timestamp)
|
||||||
|
|
||||||
|
self.last_events[intercom_id] = {
|
||||||
|
"event": event,
|
||||||
|
"camera_id": camera_id,
|
||||||
|
"timestamp": event_timestamp,
|
||||||
|
"local_time": self._format_local_time(event.get("raised_at", "")),
|
||||||
|
"description": self._get_event_description(event),
|
||||||
|
"event_type": event.get("event_type", ""),
|
||||||
|
"screenshot_data": screenshot_data,
|
||||||
|
"archive_url": archive_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
f"🔔 Новое событие для домофона {intercom_id}: "
|
||||||
|
f"{self.last_events[intercom_id]['description']} "
|
||||||
|
f"в {self.last_events[intercom_id]['local_time']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._notify_event_listeners(intercom_id)
|
||||||
|
|
||||||
|
async def _events_polling_loop(self):
|
||||||
|
_LOGGER.info(f"🔄 Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
for intercom_id in self.intercom_ids:
|
||||||
|
last_event = await self.get_last_event(intercom_id)
|
||||||
|
if not last_event:
|
||||||
|
continue
|
||||||
|
|
||||||
|
request_id = last_event.get("request_id")
|
||||||
|
last_request_id = self._last_request_ids.get(intercom_id)
|
||||||
|
|
||||||
|
if request_id and request_id != last_request_id:
|
||||||
|
self._last_request_ids[intercom_id] = request_id
|
||||||
|
await self.update_last_event(intercom_id, last_event)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Ошибка в цикле опроса событий: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(self.events_refresh_interval)
|
||||||
|
|
||||||
|
async def start_events_polling(self):
|
||||||
|
if not self.events_enabled:
|
||||||
|
_LOGGER.info("Отслеживание событий отключено")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._events_polling_task and not self._events_polling_task.done():
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._fetch_intercoms()
|
||||||
|
|
||||||
|
self._events_polling_task = asyncio.create_task(self._events_polling_loop())
|
||||||
|
|
||||||
|
async def stop_events_polling(self):
|
||||||
|
if self._events_polling_task and not self._events_polling_task.done():
|
||||||
|
self._events_polling_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._events_polling_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._events_polling_task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
cameras_api = RTKeyCamerasApi(hass, entry)
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
||||||
|
"cameras_api": cameras_api
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.add_update_listener(update_listener)
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
await cameras_api.start_events_polling()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"]
|
||||||
|
await cameras_api.stop_events_polling()
|
||||||
|
|
||||||
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
|
||||||
|
from . import DOMAIN, RTKeyCamerasApi, _LOGGER
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: Any,
|
||||||
|
) -> None:
|
||||||
|
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
|
||||||
|
intercoms_info = await cameras_api.get_intercoms_info()
|
||||||
|
|
||||||
|
entities = [
|
||||||
|
RTKeyOpenButton(hass, config_entry, cameras_api, intercom_info)
|
||||||
|
for intercom_info in intercoms_info.get("data", {}).get("devices", [])
|
||||||
|
]
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class RTKeyOpenButton(ButtonEntity):
|
||||||
|
"""Кнопка открытия домофона."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
cameras_api: RTKeyCamerasApi,
|
||||||
|
intercom_info: dict,
|
||||||
|
) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self.cameras_api = cameras_api
|
||||||
|
self.intercom_id = str(intercom_info["id"])
|
||||||
|
self.camera_id = intercom_info.get("camera_id")
|
||||||
|
|
||||||
|
self.device_name = cameras_api.build_device_name(
|
||||||
|
intercom_info.get("name_by_user") or
|
||||||
|
intercom_info.get("description") or
|
||||||
|
intercom_info.get("name_by_company", "Домофон")
|
||||||
|
)
|
||||||
|
|
||||||
|
self._attr_unique_id = f"open_button_{self.intercom_id}"
|
||||||
|
self._attr_name = "Открыть"
|
||||||
|
self._attr_icon = "mdi:door-open"
|
||||||
|
|
||||||
|
# Сохраняем информацию из API домофонов
|
||||||
|
self._device_serial = intercom_info.get("serial_number", "")
|
||||||
|
self._device_group = ", ".join(intercom_info.get("device_group", []))
|
||||||
|
self._is_active = intercom_info.get("is_active", False)
|
||||||
|
self._capabilities = [
|
||||||
|
cap.get("name") for cap in intercom_info.get("capabilities", []) if cap.get("setup")
|
||||||
|
]
|
||||||
|
self._inter_codes_count = len(intercom_info.get("inter_codes", []))
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Вызывается при нажатии на кнопку."""
|
||||||
|
_LOGGER.info(f"🔘 Нажата кнопка открытия для {self.device_name}")
|
||||||
|
await self.cameras_api.open_intercom(self.intercom_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
|
"""Атрибуты, которые будут видны в карточке сущности в HA."""
|
||||||
|
attrs = {
|
||||||
|
"Статус": "Активен" if self._is_active else "Неактивен",
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._device_serial:
|
||||||
|
attrs["Серийный номер"] = self._device_serial
|
||||||
|
if self._device_group:
|
||||||
|
attrs["Группа"] = self._device_group
|
||||||
|
if self._inter_codes_count > 0:
|
||||||
|
attrs["Ключей доступа"] = self._inter_codes_count
|
||||||
|
if self._capabilities:
|
||||||
|
attrs["Возможности"] = ", ".join(self._capabilities[:5])
|
||||||
|
if len(self._capabilities) > 5:
|
||||||
|
attrs["Возможности"] += f" (и еще {len(self._capabilities) - 5})"
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
device_id = self.camera_id or self.intercom_id
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, device_id)},
|
||||||
|
"name": self.device_name,
|
||||||
|
"manufacturer": "RT Key",
|
||||||
|
"model": "Домофон",
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import datetime
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: Any,
|
||||||
|
) -> None:
|
||||||
|
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
|
||||||
|
cameras_info = await cameras_api.get_cameras_info()
|
||||||
|
|
||||||
|
entities = [
|
||||||
|
RTKeyCamera(hass, config_entry, cameras_api, camera_info)
|
||||||
|
for camera_info in cameras_info["data"]["items"]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Добавляем архивные камеры событий для домофонов
|
||||||
|
if cameras_api.event_archive_enabled:
|
||||||
|
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("Архивные видео событий отключены")
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class RTKeyCamera(Camera):
|
||||||
|
"""Live-камера (для всех устройств — и камер, и домофонов)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
cameras_api: RTKeyCamerasApi,
|
||||||
|
camera_info: dict,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.hass = hass
|
||||||
|
self.cameras_api = cameras_api
|
||||||
|
self.camera_id = camera_info["id"]
|
||||||
|
|
||||||
|
self.device_name = cameras_api.build_device_name(camera_info.get("title", "Устройство"))
|
||||||
|
|
||||||
|
self._device_model = camera_info.get("model", "")
|
||||||
|
self._device_vendor = camera_info.get("vendor", "")
|
||||||
|
self._device_serial = camera_info.get("serial_number", "")
|
||||||
|
self._device_mac = camera_info.get("mac", "")
|
||||||
|
self._device_ip = camera_info.get("ip", "")
|
||||||
|
self._device_status_title = camera_info.get("status_title", "Неизвестно")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def async_camera_image(
|
||||||
|
self, width: int | None = None, height: int | None = None
|
||||||
|
) -> bytes | None:
|
||||||
|
return await self.cameras_api.get_camera_image(self.camera_id)
|
||||||
|
|
||||||
|
@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 = "❌ Нет связи"
|
||||||
|
|
||||||
|
attrs = {"Статус": status}
|
||||||
|
if self._device_model:
|
||||||
|
attrs["Модель"] = self._device_model
|
||||||
|
if self._device_serial:
|
||||||
|
attrs["Серийный номер"] = self._device_serial
|
||||||
|
if self._device_mac and self._device_mac != "00:00:00:00:00:00":
|
||||||
|
attrs["MAC"] = self._device_mac
|
||||||
|
if self._device_ip and self._device_ip != "0.0.0.0":
|
||||||
|
attrs["IP"] = self._device_ip
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
info = {
|
||||||
|
"identifiers": {(DOMAIN, self.camera_id)},
|
||||||
|
"name": self.device_name,
|
||||||
|
"manufacturer": self._device_vendor or "RT Key",
|
||||||
|
}
|
||||||
|
if self._device_model:
|
||||||
|
info["model"] = self._device_model
|
||||||
|
if self._device_serial:
|
||||||
|
info["serial_number"] = self._device_serial
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
class RTKeyEventCamera(Camera):
|
||||||
|
"""Камера для просмотра архивного видео на момент последнего события."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
cameras_api: RTKeyCamerasApi,
|
||||||
|
intercom_info: dict,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.hass = hass
|
||||||
|
self.cameras_api = cameras_api
|
||||||
|
self.intercom_id = str(intercom_info["id"])
|
||||||
|
self.camera_id = intercom_info.get("camera_id")
|
||||||
|
|
||||||
|
self.device_name = cameras_api.build_device_name(
|
||||||
|
intercom_info.get("name_by_user")
|
||||||
|
or intercom_info.get("description")
|
||||||
|
or intercom_info.get("name_by_company", "Домофон")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
self.cameras_api.register_event_listener(self._handle_event_update)
|
||||||
|
|
||||||
|
if self.intercom_id in self.cameras_api.last_events:
|
||||||
|
self._update_from_event()
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
self.cameras_api.unregister_event_listener(self._handle_event_update)
|
||||||
|
await super().async_will_remove_from_hass()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_event_update(self, intercom_id: str):
|
||||||
|
if intercom_id == self.intercom_id:
|
||||||
|
self._update_from_event()
|
||||||
|
|
||||||
|
def _update_from_event(self):
|
||||||
|
event_info = self.cameras_api.last_events.get(self.intercom_id)
|
||||||
|
if not event_info:
|
||||||
|
return
|
||||||
|
|
||||||
|
archive_url = event_info.get("archive_url")
|
||||||
|
if archive_url:
|
||||||
|
self._archive_url = archive_url
|
||||||
|
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()
|
||||||
|
|
||||||
|
async def stream_source(self) -> str | None:
|
||||||
|
return self._archive_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
|
attrs = {}
|
||||||
|
if self._event_description:
|
||||||
|
attrs["Событие"] = self._event_description
|
||||||
|
if self._event_time:
|
||||||
|
attrs["Время"] = self._event_time
|
||||||
|
if self._archive_url:
|
||||||
|
attrs["URL архива"] = self._archive_url
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
device_id = self.camera_id or self.intercom_id
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, device_id)},
|
||||||
|
"name": self.device_name,
|
||||||
|
"manufacturer": "RT Key",
|
||||||
|
"model": "Домофон",
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.config_entries import (
|
||||||
|
ConfigEntry,
|
||||||
|
ConfigFlow,
|
||||||
|
OptionsFlowWithConfigEntry,
|
||||||
|
)
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_TOKEN,
|
||||||
|
CONF_CAMERA_IMAGE_REFRESH_INTERVAL,
|
||||||
|
CONF_SCREENSHOT_QUALITY,
|
||||||
|
CONF_LOG_LEVEL,
|
||||||
|
CONF_EVENTS_ENABLED,
|
||||||
|
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,
|
||||||
|
DATA_SCHEMA,
|
||||||
|
OPTIONS_SCHEMA,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RTKeyOptionsFlow(OptionsFlowWithConfigEntry):
|
||||||
|
async def async_step_init(self, user_input=None):
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self.config_entry.data.get(CONF_NAME, "RT Key"),
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_options = dict(self.options)
|
||||||
|
if CONF_TOKEN not in current_options and CONF_TOKEN in self.config_entry.data:
|
||||||
|
current_options[CONF_TOKEN] = self.config_entry.data[CONF_TOKEN]
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
vol.Schema(OPTIONS_SCHEMA), current_options
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
VERSION = 1
|
||||||
|
MINOR_VERSION = 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry: ConfigEntry) -> RTKeyOptionsFlow:
|
||||||
|
return RTKeyOptionsFlow(config_entry)
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=user_input.get(CONF_NAME, "RT Key"),
|
||||||
|
data=user_input,
|
||||||
|
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),
|
||||||
|
{}, # Пустые значения по умолчанию для первой настройки
|
||||||
|
),
|
||||||
|
)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Reference in New Issue
Block a user