Files
homeassistant/custom_components/yandex_station/camera.py
Victor Alexandrovich Tsyrenschikov 373ed28445 python
2026-03-30 20:25:42 +05:00

260 lines
9.0 KiB
Python

import asyncio
import logging
import re
from datetime import datetime, timezone
from aiohttp import web
from homeassistant.components.camera import Camera, CameraEntityFeature, async_get_image
from homeassistant.components.media_player import MediaPlayerState, MediaType
from homeassistant.const import CONTENT_TYPE_MULTIPART
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.util import slugify
from .core.const import DOMAIN
from .core.entity import YandexEntity
from .core.image import draw_cover, draw_lyrics, draw_none
from .core.yandex_music import get_lyrics
from .core.yandex_quasar import YandexQuasar
from .core.yandex_station import YandexStation
from .hass import hass_utils
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, entry, async_add_entities):
quasar: YandexQuasar = hass.data[DOMAIN][entry.unique_id]
async_add_entities(
[YandexLyrics(quasar, speaker) for speaker in quasar.speakers], False
)
entities = []
for quasar, device, config in hass_utils.incluce_devices(hass, entry):
if instances := device.get("capabilities"):
for instance in instances:
if instance["type"] == "devices.capabilities.video_stream":
if "hls" in instance["parameters"]["protocols"]:
entities.append(YandexHLSCamera(quasar, device, instance))
elif "snapshot_url" in device.get("parameters", ""):
entities.append(YandexSnapshotCamera(quasar, device, instance))
async_add_entities(entities)
class YandexHLSCamera(Camera, YandexEntity):
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(self, quasar: YandexQuasar, device: dict, config: dict):
Camera.__init__(self)
YandexEntity.__init__(self, quasar, device, config)
def use_stream_for_stills(self) -> bool:
return True
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
image = await async_get_image(self.hass, self.entity_id, width, height)
return image.content
async def stream_source(self) -> str | None:
# This is used by cameras with CameraEntityFeature.STREAM and StreamType.HLS.
devices = await self.quasar.get_device_action(
self.device, "get_stream", {"protocols": ["hls"]}
)
url = devices[0]["capabilities"][0]["state"]["value"]["stream_url"]
return url
class YandexSnapshotCamera(Camera, YandexEntity):
def __init__(self, quasar: YandexQuasar, device: dict, config: dict):
Camera.__init__(self)
YandexEntity.__init__(self, quasar, device, config)
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
try:
url = self.device["parameters"]["snapshot_url"] + "/current.jpg"
r = await self.quasar.session.get(url, timeout=15)
return await r.read()
except Exception as e:
_LOGGER.debug(f"Can't get snapshot: {repr(e)}")
return None
class YandexLyrics(Camera):
_attr_entity_registry_enabled_default = False
stream_clients: int = 0
def __init__(self, quasar: YandexQuasar, device: dict):
super().__init__()
self.quasar = quasar
self.device = device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device["quasar_info"]["device_id"])},
name=self.device["name"],
)
self._attr_name = device["name"] + " Текст"
self._attr_unique_id = device["quasar_info"]["device_id"] + f"_lyrics"
self.entity_id = f"select.yandex_station_{slugify(self._attr_unique_id)}"
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
return await self.get_cover()
async def handle_async_mjpeg_stream(
self, request: web.Request
) -> web.StreamResponse | None:
response = web.StreamResponse()
response.content_type = CONTENT_TYPE_MULTIPART.format("--frameboundary")
await response.prepare(request)
try:
if self.stream_clients == 0:
self._attr_is_streaming = True
self._async_write_ha_state()
self.stream_clients += 1
while True:
if lyrics := await self.get_lyrics():
await self.handle_lyrics(response, lyrics, self.lyrics_content_id)
continue
if cover := await self.get_cover():
await self.handle_cover(response, cover, self.cover_content_id)
continue
await self.handle_cover(response, draw_none(), self.cover_content_id)
finally:
self.stream_clients -= 1
if self.stream_clients == 0:
self._attr_is_streaming = False
self._async_write_ha_state()
return response
async def handle_lyrics(
self, response: web.StreamResponse, lyrics: str, content_id: str
):
entity: YandexStation = self.device.get("entity")
if entity.media_position is None:
return
times: list[float] = []
lines: list[str] = []
for line in RE_LYRICS.findall(lyrics):
ts = int(line[0]) * 60 + int(line[1]) + int(line[2]) / 100
times.append(ts)
lines.append(line[3])
lyric_pos = lyric_pos_next = 0
while entity.media_content_id == content_id:
media_position = entity.media_position
if entity.state == MediaPlayerState.PLAYING:
dt = datetime.now(timezone.utc) - entity.media_position_updated_at
media_position += dt.total_seconds()
delay = min(lyric_pos_next - media_position, 1)
else:
delay = 1
if not (lyric_pos <= media_position < lyric_pos_next):
if media_position < times[0]:
lyric_pos = 0
lyric_pos_next = times[0]
image = draw_lyrics(None, lines[0]) # before first
elif media_position > times[-1]:
lyric_pos = times[-1]
lyric_pos_next = entity.media_duration or 65535
image = draw_lyrics(lines[-1], None) # last
else:
for i, ts in enumerate(times):
if ts >= media_position:
lyric_pos = times[i - 1]
lyric_pos_next = ts
image = draw_lyrics(lines[i - 1], lines[i])
break
else:
image = draw_none()
await write_to_mjpeg_stream(response, image)
await asyncio.sleep(delay)
async def handle_cover(
self, response: web.StreamResponse, image: bytes, content_id: str
):
await write_to_mjpeg_stream(response, image)
entity: YandexStation = self.device.get("entity")
while entity.media_content_id == content_id:
await asyncio.sleep(1)
cover: bytes | None = None
cover_content_id: str = None
async def get_cover(self) -> bytes | None:
entity: YandexStation = self.device.get("entity")
if not entity:
return None
if self.cover_content_id != entity.media_content_id:
if entity.media_image_url:
session = async_get_clientsession(self.hass)
r = await session.get(entity.media_image_url, timeout=15)
image = await r.read()
self.cover = draw_cover(entity.media_title, entity.media_artist, image)
else:
self.cover = None
self.cover_content_id = entity.media_content_id
return self.cover
lyrics: str | None = None
lyrics_content_id: str = None
async def get_lyrics(self) -> str | None:
entity: YandexStation = self.device.get("entity")
if not entity:
return None
if self.lyrics_content_id != entity.media_content_id:
if entity.media_content_type == MediaType.MUSIC:
self.lyrics = await get_lyrics(
self.quasar.session, entity.media_content_id
)
else:
self.lyrics = None
self.lyrics_content_id = entity.media_content_id
return self.lyrics
RE_LYRICS = re.compile(
r"^\[([0-9]{2}):([0-9]{2})\.([0-9]{2})] (.+)$", flags=re.MULTILINE
)
async def write_to_mjpeg_stream(response: web.StreamResponse, image: bytes) -> None:
data = (
b"--frameboundary\r\nContent-Type: image/jpeg\r\nContent-Length: "
+ str(len(image)).encode()
+ b"\r\n\r\n"
+ image
+ b"\r\n"
)
# two times - fix Chrome bug
await response.write(data)
await response.write(data)