Загрузить файлы в «custom_components/rtkey»

This commit is contained in:
2026-06-28 13:41:15 +03:00
parent f749579e96
commit 9eca755ce4
5 changed files with 1090 additions and 0 deletions
+687
View File
@@ -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)
+93
View File
@@ -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": "Домофон",
}
+236
View File
@@ -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": "Домофон",
}
+74
View File
@@ -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