1145 lines
41 KiB
Python
1145 lines
41 KiB
Python
import base64
|
|
import binascii
|
|
import json
|
|
import re
|
|
import time
|
|
import uuid
|
|
from datetime import timedelta
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
from homeassistant.components import shopping_list
|
|
from homeassistant.components.media_player import *
|
|
from homeassistant.components.media_player.const import (
|
|
MEDIA_TYPE_TVSHOW, MEDIA_TYPE_CHANNEL, MEDIA_CLASS_APP
|
|
)
|
|
from homeassistant.components.media_source.models import BrowseMediaSource
|
|
from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE
|
|
from homeassistant.core import callback
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.helpers.device_registry import DeviceRegistry
|
|
from homeassistant.helpers.entity import DeviceInfo
|
|
from homeassistant.helpers.template import Template
|
|
from homeassistant.util import dt
|
|
|
|
from . import DOMAIN, DATA_CONFIG, CONF_INCLUDE, CONF_INTENTS
|
|
from .core import utils
|
|
from .core.yandex_glagol import YandexGlagol
|
|
from .core.yandex_music import get_mp3
|
|
from .core.yandex_quasar import YandexQuasar
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# update speaker online state once per 5 minutes
|
|
SCAN_INTERVAL = timedelta(minutes=5)
|
|
|
|
RE_EXTRA = re.compile(br'{".+?}\n')
|
|
RE_MUSIC_ID = re.compile(r'^\d+(:\d+)?$')
|
|
RE_SHOPPING = re.compile(r'^\d+\) (.+)\.$', re.MULTILINE)
|
|
|
|
BASE_FEATURES = (
|
|
SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP |
|
|
SUPPORT_VOLUME_MUTE | SUPPORT_PLAY_MEDIA | SUPPORT_TURN_ON |
|
|
SUPPORT_SELECT_SOUND_MODE | SUPPORT_BROWSE_MEDIA
|
|
)
|
|
|
|
CLOUD_FEATURES = (
|
|
BASE_FEATURES | SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK |
|
|
SUPPORT_NEXT_TRACK
|
|
)
|
|
LOCAL_FEATURES = (
|
|
BASE_FEATURES | SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_SELECT_SOURCE
|
|
)
|
|
|
|
SOUND_MODE1 = "Произнеси текст"
|
|
SOUND_MODE2 = "Выполни команду"
|
|
|
|
MEDIA_DEFAULT = [{
|
|
"title": "Произнеси текст", "media_content_type": "text",
|
|
"thumbnail": "https://brands.home-assistant.io/_/tts/icon.png",
|
|
}, {
|
|
"title": "Выполни команду", "media_content_type": "command",
|
|
"thumbnail": "https://brands.home-assistant.io/_/automation/icon.png",
|
|
}]
|
|
|
|
SOURCE_STATION = 'Станция'
|
|
SOURCE_HDMI = 'HDMI'
|
|
|
|
# Thanks to: https://github.com/iswitch/ha-yandex-icons
|
|
CUSTOM = {
|
|
'yandexstation': ['yandex:station', "Яндекс", "Станция"],
|
|
'yandexstation_2': ['yandex:station-max', "Яндекс", "Станция Макс"],
|
|
'yandexmidi': ['yandex:station-2', "Яндекс", "Станция 2"],
|
|
'yandexmini': ['yandex:station-mini', "Яндекс", "Станция Мини"],
|
|
'yandexmini_2': ['yandex:station-mini-2', "Яндекс", "Станция Мини 2"],
|
|
'yandexmicro': ['yandex:station-lite', "Яндекс", "Станция Лайт"],
|
|
'yandexmodule': ['yandex:module', "Яндекс", "Модуль"],
|
|
'yandexmodule_2': ['yandex:module-2', "Яндекс", "Модуль 2"],
|
|
'lightcomm': ['yandex:dexp-smartbox', "DEXP", "Smartbox"],
|
|
'elari_a98': ['yandex:elari-smartbeat', "Elari", "SmartBeat"],
|
|
'linkplay_a98': ['yandex:irbis-a', "IRBIS", "A"],
|
|
'wk7y': ['yandex:lg-xboom-wk7y', "LG", "XBOOM AI ThinQ WK7Y"],
|
|
'prestigio_smart_mate': [
|
|
'yandex:prestigio-smartmate', "Prestigio", "Smartmate"
|
|
],
|
|
'jbl_link_music': ['yandex:jbl-link-music', "JBL", "Link Music"],
|
|
'jbl_link_portable': ['yandex:jbl-link-portable', "JBL", "Link Portable"],
|
|
}
|
|
|
|
DEVICES = ['devices.types.media_device.tv']
|
|
|
|
|
|
async def async_setup_entry(hass, entry, async_add_entities):
|
|
quasar: YandexQuasar = hass.data[DOMAIN][entry.unique_id]
|
|
|
|
# add Yandex stations
|
|
entities = []
|
|
for speaker in await quasar.load_speakers():
|
|
speaker['entity'] = entity = YandexStation(quasar, speaker)
|
|
entities.append(entity)
|
|
for module in quasar.modules:
|
|
module['entity'] = entity = YandexModule(quasar, module)
|
|
entities.append(entity)
|
|
async_add_entities(entities, True)
|
|
|
|
# add TVs
|
|
if CONF_INCLUDE not in hass.data[DOMAIN][DATA_CONFIG]:
|
|
return
|
|
|
|
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
|
|
entities = [
|
|
YandexTV(quasar, device)
|
|
for device in quasar.devices
|
|
if device['name'] in include and device['type'] in DEVICES
|
|
]
|
|
async_add_entities(entities, True)
|
|
|
|
|
|
# noinspection PyUnusedLocal
|
|
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
# only intents setup via setup platform
|
|
intents = discovery_info[CONF_INTENTS]
|
|
add_entities([YandexIntents(intents)])
|
|
|
|
|
|
# noinspection PyAbstractClass
|
|
class YandexSource(BrowseMediaSource):
|
|
def __init__(self, **kwargs):
|
|
query = {}
|
|
if kwargs.get("media_content_id"):
|
|
query["message"] = kwargs.pop("media_content_id")
|
|
kwargs.setdefault("can_expand", False)
|
|
if kwargs.get("template"):
|
|
query["template"] = template = kwargs.pop("template")
|
|
kwargs.setdefault("can_expand", "message" in template)
|
|
if kwargs.get("extra"):
|
|
extra = kwargs.pop("extra")
|
|
query["volume_level"] = extra["volume_level"]
|
|
if query:
|
|
kwargs["identifier"] = utils.encode_media_source(query)
|
|
|
|
kwargs = {
|
|
"domain": "tts", # will show message/say dialog
|
|
"identifier": DOMAIN, # may be any but not empty
|
|
"media_class": MEDIA_CLASS_APP, # needs for icon
|
|
"can_play": False, # show play button in
|
|
"can_expand": True, # true - show say dialog, false - run command
|
|
**kwargs # override all default values
|
|
}
|
|
super().__init__(**kwargs)
|
|
|
|
|
|
# noinspection PyAbstractClass
|
|
class MediaBrowser(MediaPlayerEntity):
|
|
media_cache: list = None
|
|
|
|
async def async_browse_media(
|
|
self, media_content_type: str = None, media_content_id: str = None,
|
|
) -> BrowseMedia:
|
|
if not MediaBrowser.media_cache:
|
|
conf = self.hass.data[DOMAIN][DATA_CONFIG]
|
|
conf = conf.get("media_source") or MEDIA_DEFAULT
|
|
MediaBrowser.media_cache = [YandexSource(**item) for item in conf]
|
|
|
|
for media in MediaBrowser.media_cache:
|
|
if (media.media_content_id == media_content_id and
|
|
media.media_content_type == media_content_type):
|
|
return media
|
|
|
|
return BrowseMediaSource(
|
|
title=self.name, children=MediaBrowser.media_cache,
|
|
domain=None, identifier=None, media_class=None,
|
|
media_content_type=None, can_play=False, can_expand=True,
|
|
)
|
|
|
|
|
|
# noinspection PyAbstractClass
|
|
class YandexStation(MediaBrowser):
|
|
_attr_extra_state_attributes: dict = None
|
|
|
|
local_state: Optional[dict] = None
|
|
# для управления громкостью Алисы
|
|
alice_volume: Optional[dict] = None
|
|
|
|
# true of false if device has HDMI
|
|
hdmi_audio: Optional[bool] = None
|
|
|
|
# song_id to know when sond changes
|
|
sync_id: Optional[str] = None
|
|
# for disabling mute when speak with Alice
|
|
sync_mute: Optional[bool] = None
|
|
# {name: entity_id} pairs
|
|
sync_sources: dict = None
|
|
# if sync mode enabled
|
|
sync_state: Optional[bool] = None
|
|
sync_volume: Optional[float] = None
|
|
|
|
glagol: YandexGlagol = None
|
|
|
|
def __init__(self, quasar: YandexQuasar, device: dict):
|
|
self.quasar = quasar
|
|
self.device = device
|
|
self.requests = {}
|
|
|
|
self._attr_assumed_state = True
|
|
self._attr_extra_state_attributes = {}
|
|
self._attr_is_volume_muted = False
|
|
self._attr_media_image_remotely_accessible = True
|
|
self._attr_name = device['name']
|
|
self._attr_should_poll = True
|
|
self._attr_state = STATE_IDLE
|
|
self._attr_sound_mode_list = [SOUND_MODE1, SOUND_MODE2]
|
|
self._attr_sound_mode = SOUND_MODE1
|
|
self._attr_supported_features = CLOUD_FEATURES
|
|
self._attr_volume_level = 0.5
|
|
self._attr_unique_id = device['quasar_info']['device_id']
|
|
|
|
self._attr_device_info = info = DeviceInfo(
|
|
identifiers={(DOMAIN, self.unique_id)},
|
|
name=self.device['name'],
|
|
)
|
|
if self.device_platform in CUSTOM:
|
|
info["manufacturer"] = CUSTOM[self.device_platform][1]
|
|
info["model"] = CUSTOM[self.device_platform][2]
|
|
|
|
# backward compatibility
|
|
self.entity_id = "media_player.yandex_station_" + \
|
|
self._attr_unique_id.lower()
|
|
|
|
# ADDITIONAL CLASS FUNCTION
|
|
|
|
@property
|
|
def device_platform(self):
|
|
return self.device['quasar_info']['platform']
|
|
|
|
def debug(self, text: str):
|
|
_LOGGER.debug(f"{self.name} | {text}")
|
|
|
|
async def init_local_mode(self):
|
|
# self.debug(f"Init local mode (hass: {self.hass is not None})")
|
|
if not self.glagol:
|
|
self.glagol = YandexGlagol(self.quasar.session, self.device)
|
|
self.glagol.update_handler = self.async_set_state
|
|
|
|
await self.glagol.start_or_restart()
|
|
|
|
# init sources only once
|
|
if self.sync_sources is not None:
|
|
return
|
|
|
|
self.sync_sources = {
|
|
src["name"]: src for src in utils.get_media_players(self.hass)
|
|
}
|
|
|
|
# for HomeKit source list support
|
|
self._attr_device_class = DEVICE_CLASS_TV
|
|
self._attr_source_list = \
|
|
[SOURCE_STATION] + list(self.sync_sources.keys())
|
|
self._attr_source = SOURCE_STATION
|
|
|
|
await self.init_hdmi_audio()
|
|
|
|
async def init_hdmi_audio(self):
|
|
if self.device_platform not in ('yandexstation', 'yandexstation_2'):
|
|
return
|
|
|
|
# load state if unknown
|
|
if self.hdmi_audio is None:
|
|
try:
|
|
conf = await self.quasar.get_device_config(self.device)
|
|
self.hdmi_audio = conf.get('hdmiAudio', False)
|
|
except:
|
|
_LOGGER.warning("Не получается получить настройки HDMI")
|
|
return
|
|
|
|
if self.hdmi_audio:
|
|
self._attr_source = SOURCE_HDMI
|
|
self._attr_source_list.insert(1, SOURCE_HDMI)
|
|
|
|
async def sync_hdmi_audio(self):
|
|
# if HDMI supported and state loaded
|
|
if self.hdmi_audio is None:
|
|
return
|
|
|
|
if self._attr_source == SOURCE_STATION:
|
|
enabled = False
|
|
elif self._attr_source == SOURCE_HDMI:
|
|
enabled = True
|
|
else:
|
|
return
|
|
|
|
# check if something changed
|
|
if self.hdmi_audio == enabled:
|
|
return
|
|
|
|
try:
|
|
device_config = await self.quasar.get_device_config(self.device)
|
|
if enabled:
|
|
device_config['hdmiAudio'] = True
|
|
else:
|
|
device_config.pop('hdmiAudio', None)
|
|
await self.quasar.set_device_config(self.device, device_config)
|
|
except:
|
|
_LOGGER.warning("Не получается изменить настройки HDMI")
|
|
return
|
|
|
|
self.hdmi_audio = enabled
|
|
|
|
async def response(self, card: dict, request_id: str):
|
|
if not card:
|
|
self.debug(f"Empty response on request: {request_id}")
|
|
return
|
|
|
|
self.debug(f"{card['text']} | {request_id}")
|
|
|
|
if card['type'] == 'simple_text':
|
|
text = card['text']
|
|
|
|
elif card['type'] == 'text_with_button':
|
|
text = card['text']
|
|
|
|
for button in card['buttons']:
|
|
assert button['type'] == 'action'
|
|
for directive in button['directives']:
|
|
if directive['name'] == 'open_uri':
|
|
title = button['title']
|
|
uri = directive['payload']['uri']
|
|
text += f"\n[{title}]({uri})"
|
|
|
|
else:
|
|
_LOGGER.error(f"Неизвестный тип ответа: {card['type']}")
|
|
return
|
|
|
|
self.hass.bus.async_fire(f"{DOMAIN}_response", {
|
|
'entity_id': self.entity_id,
|
|
'name': self.name,
|
|
'text': text,
|
|
'request_id': request_id
|
|
})
|
|
|
|
async def _set_brightness(self, value: str):
|
|
if self.device_platform not in ('yandexstation_2', 'yandexmini_2'):
|
|
_LOGGER.warning("Поддерживаются только станции с экраном")
|
|
return
|
|
|
|
device_config = await self.quasar.get_device_config(self.device)
|
|
if not device_config:
|
|
_LOGGER.warning("Не получается получить настройки станции")
|
|
return
|
|
|
|
try:
|
|
value = float(value)
|
|
except:
|
|
_LOGGER.exception(f"Недопустимое значение яркости: {value}")
|
|
return
|
|
|
|
if 'led' not in device_config:
|
|
device_config['led'] = {
|
|
'brightness': {'auto': True, 'value': 0.5}
|
|
}
|
|
|
|
if 0 <= value <= 1:
|
|
device_config['led']['brightness']['auto'] = False
|
|
device_config['led']['brightness']['value'] = value
|
|
else:
|
|
device_config['led']['brightness']['auto'] = True
|
|
|
|
await self.quasar.set_device_config(self.device, device_config)
|
|
|
|
async def _set_beta(self, value: str):
|
|
device_config = await self.quasar.get_device_config(self.device)
|
|
|
|
if value == 'True':
|
|
value = True
|
|
elif value == 'False':
|
|
value = False
|
|
else:
|
|
value = None
|
|
|
|
if value is not None:
|
|
device_config['beta'] = value
|
|
await self.quasar.set_device_config(self.device, device_config)
|
|
|
|
self.hass.components.persistent_notification.async_create(
|
|
f"{self.name} бета-тест: {device_config['beta']}"
|
|
)
|
|
|
|
async def _set_settings(self, value: str):
|
|
data = yaml.safe_load(value)
|
|
for k, v in data.items():
|
|
await self.quasar.set_account_config(k, v)
|
|
|
|
async def _shopping_list(self):
|
|
if shopping_list.DOMAIN not in self.hass.data:
|
|
return
|
|
|
|
data: shopping_list.ShoppingData = self.hass.data[shopping_list.DOMAIN]
|
|
|
|
card = await self.glagol.send({'command': 'sendText',
|
|
'text': "Что в списке покупок"})
|
|
alice_list = RE_SHOPPING.findall(card['text'])
|
|
self.debug(f"Список покупок: {alice_list}")
|
|
|
|
remove_from = [
|
|
alice_list.index(item['name'])
|
|
for item in data.items
|
|
if item['complete'] and item['name'] in alice_list
|
|
]
|
|
if remove_from:
|
|
# не может удалить больше 6 штук за раз
|
|
remove_from = sorted(remove_from, reverse=True)
|
|
for i in range(0, len(remove_from), 6):
|
|
items = [str(p + 1) for p in remove_from[i:i + 6]]
|
|
text = "Удали из списка покупок: " + ', '.join(items)
|
|
await self.glagol.send({'command': 'sendText', 'text': text})
|
|
|
|
add_to = [
|
|
item['name'] for item in data.items
|
|
if not item['complete'] and item['name'] not in alice_list and
|
|
not item['id'].startswith('alice')
|
|
]
|
|
for name in add_to:
|
|
# плохо работает, если добавлять всё сразу через запятую
|
|
text = "Добавь в список покупок " + name
|
|
await self.glagol.send({'command': 'sendText', 'text': text})
|
|
|
|
if add_to or remove_from:
|
|
card = await self.glagol.send({'command': 'sendText',
|
|
'text': "Что в списке покупок"})
|
|
alice_list = RE_SHOPPING.findall(card['text'])
|
|
self.debug(f"Новый список покупок: {alice_list}")
|
|
|
|
data.items = [
|
|
{'name': name, 'id': 'alice' + uuid.uuid4().hex, 'complete': False}
|
|
for name in alice_list
|
|
]
|
|
await self.hass.async_add_executor_job(data.save)
|
|
|
|
async def _sync_play_media(self, player_state: dict):
|
|
self.debug(f"Sync state: play_media")
|
|
|
|
url = await get_mp3(self.quasar.session, player_state)
|
|
if not url:
|
|
return
|
|
|
|
await self.async_media_seek(0)
|
|
|
|
source = self.sync_sources[self._attr_source]
|
|
data = {
|
|
"media_content_id": utils.StreamingView.get_url(
|
|
self.hass, self._attr_unique_id, url
|
|
),
|
|
"media_content_type": source.get("media_content_type", "music"),
|
|
"entity_id": source["entity_id"],
|
|
}
|
|
|
|
await self.hass.services.async_call(
|
|
"media_player", "play_media", data
|
|
)
|
|
|
|
def _check_set_alice_volume(self, volume: int):
|
|
# если уже есть активная громкость, или громкость голоса равна текущей
|
|
# громкости колонки - ничего не делаем
|
|
if self.alice_volume or volume == self.volume_level:
|
|
return
|
|
|
|
self.alice_volume = {
|
|
'prev_volume': self.volume_level,
|
|
'wait_state': 'SPEAKING',
|
|
'wait_ts': time.time() + 30
|
|
}
|
|
|
|
self.hass.create_task(self.async_set_volume_level(volume))
|
|
|
|
def _process_alice_volume(self, alice_state: str):
|
|
volume = None
|
|
|
|
# если что-то пошло не так, через 30 секунд возвращаем громкость
|
|
if time.time() > self.alice_volume['wait_ts']:
|
|
if 'prev_volume' in self.alice_volume:
|
|
volume = self.alice_volume['prev_volume']
|
|
self.alice_volume = None
|
|
|
|
elif self.alice_volume['wait_state'] == alice_state:
|
|
if alice_state == 'SPEAKING':
|
|
self.alice_volume['wait_state'] = 'IDLE'
|
|
|
|
elif alice_state == 'IDLE':
|
|
volume = self.alice_volume['prev_volume']
|
|
self.alice_volume = None
|
|
|
|
if volume:
|
|
self.hass.create_task(self.async_set_volume_level(volume))
|
|
|
|
@callback
|
|
def yandex_dialog(self, media_type: str, media_id: str):
|
|
"""Passes TTS data to YandexDialogs component and return text command to
|
|
start dialog with data CRC-hash as ID.
|
|
"""
|
|
if media_type.startswith("dialog"):
|
|
_, name, tag = media_type.split(":")
|
|
payload = {
|
|
"tts": media_id,
|
|
"session": {"dialog": tag},
|
|
"end_session": False
|
|
}
|
|
else:
|
|
_, name = media_type.split(":")
|
|
payload = {"tts": media_id}
|
|
|
|
crc = str(binascii.crc32(media_id.encode()))
|
|
try:
|
|
dialog = self.hass.data["yandex_dialogs"]
|
|
dialog.dialogs[crc] = payload
|
|
except:
|
|
_LOGGER.warning("Компонент Яндекс Диалогов не подключен")
|
|
|
|
return f"СКАЖИ НАВЫКУ {name} {crc}"
|
|
|
|
@callback
|
|
def async_sync_state(self, service: str, **kwargs):
|
|
self.debug(f"Sync state: {service}")
|
|
|
|
source = self.sync_sources[self._attr_source]
|
|
if source.get("sync_volume") is False and service == "volume_set":
|
|
return
|
|
|
|
if service == "play_media":
|
|
self.hass.async_create_task(self.async_media_seek(0))
|
|
kwargs["media_content_id"] = utils.StreamingView.get_url(
|
|
self.hass, self._attr_unique_id, kwargs.pop("url")
|
|
)
|
|
kwargs["media_content_type"] = source.get(
|
|
"media_content_type", "music"
|
|
)
|
|
|
|
kwargs["entity_id"] = source["entity_id"]
|
|
|
|
self.hass.async_create_task(self.hass.services.async_call(
|
|
"media_player", service, kwargs
|
|
))
|
|
|
|
@callback
|
|
def update_device_info(self, sw_version: str):
|
|
if not self.hass:
|
|
return
|
|
registry: DeviceRegistry = self.hass.data['device_registry']
|
|
device = registry.async_get_device(
|
|
{(DOMAIN, self._attr_unique_id)}, None
|
|
)
|
|
registry.async_update_device(device.id, sw_version=sw_version)
|
|
|
|
@callback
|
|
def async_set_state(self, data: dict):
|
|
if data is None:
|
|
self.debug("Возврат в облачный режим")
|
|
self.local_state = None
|
|
|
|
self._attr_assumed_state = True
|
|
self._attr_extra_state_attributes.pop("alice_state", None)
|
|
self._attr_media_artist = None
|
|
self._attr_media_content_type = None
|
|
self._attr_media_duration = None
|
|
self._attr_media_image_url = None
|
|
self._attr_media_position = None
|
|
self._attr_media_position_updated_at = None
|
|
self._attr_media_title = None
|
|
self._attr_supported_features = CLOUD_FEATURES
|
|
self._attr_should_poll = True
|
|
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
is_send_by_speaker = 'requestId' not in data
|
|
|
|
state = data['state']
|
|
state['local_push'] = is_send_by_speaker
|
|
state.pop('timeSinceLastVoiceActivity', None)
|
|
|
|
# skip same state
|
|
if self.local_state == state:
|
|
return
|
|
|
|
if "softwareVersion" in data:
|
|
self.update_device_info(data["softwareVersion"])
|
|
|
|
self.local_state = state
|
|
|
|
# возвращаем из состояния mute, если нужно
|
|
# if self.prev_volume and state['volume']:
|
|
# self.prev_volume = None
|
|
|
|
if self.alice_volume:
|
|
self._process_alice_volume(state['aliceState'])
|
|
|
|
extra_item = extra_stream = None
|
|
|
|
try:
|
|
astate = data['extra']['appState'].encode('ascii')
|
|
astate = base64.b64decode(astate)
|
|
for m in RE_EXTRA.findall(astate):
|
|
m = json.loads(m)
|
|
if "item" in m:
|
|
extra_item = m["item"]
|
|
if "stream" in m:
|
|
extra_stream = m["stream"]
|
|
except:
|
|
pass
|
|
|
|
mctp = miur = mpos = mart = mdur = mtit = None
|
|
stat = STATE_IDLE
|
|
spft = LOCAL_FEATURES
|
|
|
|
# в прошивке Яндекс.Станции Мини есть косяк - звук всегда (int) 0
|
|
vlvl = state['volume'] if isinstance(state['volume'], float) else None
|
|
|
|
pstate = state.get("playerState")
|
|
if pstate:
|
|
try:
|
|
if pstate.get('liveStreamText') == "Прямой эфир":
|
|
mctp = MEDIA_TYPE_CHANNEL # radio
|
|
elif pstate["extra"]:
|
|
# music, podcast also shows as music
|
|
mctp = pstate['extra']['stateType']
|
|
elif extra_item:
|
|
extra_type = extra_item['type']
|
|
mctp = MEDIA_TYPE_TVSHOW \
|
|
if extra_type == 'tv_show_episode' \
|
|
else extra_type
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
if pstate['extra'].get('stateType') in ('music', 'radio'):
|
|
url = pstate['extra']['coverURI']
|
|
if url:
|
|
miur = 'https://' + url.replace('%%', '400x400')
|
|
elif extra_item:
|
|
miur = extra_item['thumbnail_url_16x9']
|
|
except:
|
|
pass
|
|
|
|
mdur = pstate["duration"]
|
|
mpos = pstate["progress"]
|
|
mart = pstate["subtitle"]
|
|
mtit = pstate["title"]
|
|
|
|
stat = STATE_PLAYING if state["playing"] else STATE_PAUSED
|
|
if pstate["hasPrev"]:
|
|
spft |= SUPPORT_PREVIOUS_TRACK
|
|
if pstate["hasNext"]:
|
|
spft |= SUPPORT_NEXT_TRACK
|
|
if pstate["duration"]:
|
|
spft |= SUPPORT_SEEK
|
|
|
|
if self.sync_state:
|
|
# синхронизируем статус, если выбран такой режим
|
|
if self.sync_state != stat:
|
|
# синхронизируем статус, если он не совпадает
|
|
if stat == STATE_PLAYING:
|
|
if self.sync_id != pstate["id"]:
|
|
# запускаем новую песню, если ID изменился
|
|
self.hass.create_task(
|
|
self._sync_play_media(pstate)
|
|
)
|
|
self.sync_id = pstate["id"]
|
|
else:
|
|
# продолжаем играть, если ID не изменился
|
|
self.async_sync_state("media_play")
|
|
|
|
else:
|
|
# останавливаем, если ничего не играет
|
|
self.async_sync_state("media_pause")
|
|
|
|
self.sync_state = stat
|
|
|
|
if self.sync_state == STATE_PLAYING:
|
|
if vlvl and self.sync_volume != vlvl:
|
|
self.sync_mute = None
|
|
self.sync_volume = vlvl
|
|
self.async_sync_state("volume_set", volume_level=vlvl)
|
|
|
|
# если музыка играет - глушим колонку Яндекса
|
|
if self.sync_mute is True:
|
|
# включаем громкость колонки, когда с ней разговариваем
|
|
if state["aliceState"] != "IDLE":
|
|
self.sync_mute = False
|
|
self.hass.create_task(
|
|
self.async_mute_volume(False)
|
|
)
|
|
else:
|
|
# выключаем громкость колонки, когда с ней не
|
|
# разговариваем
|
|
if state["aliceState"] == "IDLE":
|
|
self.sync_mute = True
|
|
self.hass.create_task(self.async_mute_volume(True))
|
|
|
|
self._attr_assumed_state = False
|
|
self._attr_available = True
|
|
self._attr_media_artist = mart
|
|
self._attr_media_content_type = mctp
|
|
self._attr_media_duration = mdur
|
|
self._attr_media_image_url = miur
|
|
self._attr_media_position = mpos
|
|
self._attr_media_position_updated_at = dt.utcnow() # TODO: check this
|
|
self._attr_media_title = mtit
|
|
self._attr_state = stat
|
|
self._attr_supported_features = spft
|
|
self._attr_should_poll = False
|
|
|
|
if vlvl is not None:
|
|
if vlvl > 0:
|
|
self._attr_is_volume_muted = False
|
|
self._attr_volume_level = vlvl
|
|
else:
|
|
self._attr_is_volume_muted = True
|
|
|
|
self._attr_extra_state_attributes["alice_state"] = state['aliceState']
|
|
|
|
if self.hass:
|
|
self.async_write_ha_state()
|
|
|
|
# BASE MEDIA PLAYER FUNCTIONS
|
|
|
|
async def async_added_to_hass(self):
|
|
if (await utils.has_custom_icons(self.hass) and
|
|
self.device_platform in CUSTOM):
|
|
self._attr_icon = CUSTOM[self.device_platform][0]
|
|
self.debug(f"Установка кастомной иконки: {self._attr_icon}")
|
|
|
|
if 'host' in self.device:
|
|
await self.init_local_mode()
|
|
|
|
async def async_will_remove_from_hass(self):
|
|
if self.glagol:
|
|
await self.glagol.stop()
|
|
|
|
async def async_select_sound_mode(self, sound_mode: str):
|
|
self._attr_sound_mode = sound_mode
|
|
self.async_write_ha_state()
|
|
|
|
async def async_select_source(self, source):
|
|
self.debug(f"Change source to {source}")
|
|
if self.sync_mute is True:
|
|
# включаем звук колонке, если выключали его
|
|
self.hass.create_task(self.async_mute_volume(False))
|
|
|
|
if self.sync_state:
|
|
# сбрасываем синхронизацию
|
|
self.sync_state = self.sync_id = self.sync_volume = \
|
|
self.sync_mute = None
|
|
# останавливаем внешний медиаплеер
|
|
self.async_sync_state("media_pause")
|
|
|
|
self.sync_state = self.sync_sources and source in self.sync_sources
|
|
|
|
self._attr_source = source
|
|
self.async_write_ha_state()
|
|
|
|
await self.sync_hdmi_audio()
|
|
|
|
async def async_mute_volume(self, mute: bool):
|
|
volume = 0 if mute else self._attr_volume_level
|
|
await self.async_set_volume_level(volume)
|
|
|
|
async def async_set_volume_level(self, volume: float):
|
|
if self.local_state:
|
|
# у станции округление громкости до десятых
|
|
await self.glagol.send({
|
|
'command': 'setVolume',
|
|
'volume': round(volume, 1)
|
|
})
|
|
|
|
else:
|
|
await self.quasar.send(
|
|
self.device, f"громкость на {round(10 * volume)}"
|
|
)
|
|
if volume > 0:
|
|
self._attr_is_volume_muted = False
|
|
self._attr_volume_level = round(volume, 2)
|
|
else:
|
|
# don't change volume_level so can back to previous value
|
|
self._attr_is_volume_muted = True
|
|
self.async_write_ha_state()
|
|
|
|
async def async_media_seek(self, position):
|
|
if self.local_state:
|
|
await self.glagol.send({
|
|
'command': 'rewind', 'position': position})
|
|
|
|
async def async_media_play(self):
|
|
if self.local_state:
|
|
await self.glagol.send({'command': 'play'})
|
|
|
|
else:
|
|
await self.quasar.send(self.device, "продолжить")
|
|
self._attr_state = STATE_PLAYING
|
|
self.async_write_ha_state()
|
|
|
|
async def async_media_pause(self):
|
|
if self.local_state:
|
|
await self.glagol.send({'command': 'stop'})
|
|
|
|
else:
|
|
await self.quasar.send(self.device, "пауза")
|
|
self._attr_state = STATE_PAUSED
|
|
self.async_write_ha_state()
|
|
|
|
async def async_media_stop(self):
|
|
await self.async_media_pause()
|
|
|
|
async def async_media_previous_track(self):
|
|
if self.local_state:
|
|
await self.glagol.send({'command': 'prev'})
|
|
else:
|
|
await self.quasar.send(self.device, "прошлый трек")
|
|
|
|
async def async_media_next_track(self):
|
|
if self.local_state:
|
|
await self.glagol.send({'command': 'next'})
|
|
else:
|
|
await self.quasar.send(self.device, "следующий трек")
|
|
|
|
async def async_turn_on(self):
|
|
if self.local_state:
|
|
await self.glagol.send(utils.update_form(
|
|
'personal_assistant.scenarios.player_continue'
|
|
))
|
|
else:
|
|
await self.async_media_play()
|
|
|
|
async def async_turn_off(self):
|
|
if self.local_state:
|
|
await self.glagol.send(utils.update_form(
|
|
'personal_assistant.scenarios.quasar.go_home'
|
|
))
|
|
else:
|
|
await self.async_media_pause()
|
|
|
|
async def async_update(self):
|
|
# update online only while cloud connected
|
|
if self.local_state:
|
|
return
|
|
await self.quasar.update_online_stats()
|
|
self._attr_available = self.device.get('online', False)
|
|
|
|
async def async_play_media(
|
|
self, media_type: str, media_id: str, extra: dict = None, **kwargs
|
|
):
|
|
# backward support Hass lower than v2022.3
|
|
if '/api/tts_proxy/' in media_id:
|
|
session = async_get_clientsession(self.hass)
|
|
media_id = await utils.get_tts_message(session, media_id)
|
|
media_type = 'tts'
|
|
|
|
if media_id.startswith("media-source://tts/"):
|
|
query = utils.decode_media_source(media_id)
|
|
if query.get("template"):
|
|
template = Template(query.pop("template"), self.hass)
|
|
media_id = template.async_render(query)
|
|
else:
|
|
media_id = query["message"]
|
|
if query.get("volume_level"):
|
|
extra.setdefault("volume_level", float(query["volume_level"]))
|
|
# provider, music - from 3rd party TTS (ex google)
|
|
if media_type in ("provider", "music"):
|
|
media_type = "text"
|
|
|
|
if not media_id:
|
|
_LOGGER.warning(f"Получено пустое media_id")
|
|
return
|
|
|
|
# tts for backward compatibility and mini-media-player support
|
|
if media_type == "tts":
|
|
if self._attr_sound_mode == SOUND_MODE1:
|
|
media_type = "text"
|
|
else:
|
|
media_type = "command"
|
|
elif media_type == 'brightness':
|
|
await self._set_brightness(media_id)
|
|
return
|
|
elif media_type == 'beta':
|
|
await self._set_beta(media_id)
|
|
return
|
|
elif media_type == 'settings':
|
|
await self._set_settings(media_id)
|
|
return
|
|
|
|
if self.local_state:
|
|
if 'https://' in media_id or 'http://' in media_id:
|
|
session = async_get_clientsession(self.hass)
|
|
payload = await utils.get_media_payload(media_id, session)
|
|
if not payload:
|
|
_LOGGER.warning(f"Unsupported url: {media_id}")
|
|
return
|
|
|
|
elif media_type.startswith(("text:", "dialog:")):
|
|
payload = {
|
|
'command': 'sendText',
|
|
'text': self.yandex_dialog(media_type, media_id)
|
|
}
|
|
|
|
elif media_type == 'text':
|
|
# даже в локальном режиме делам TTS через облако, чтоб колонка
|
|
# не продолжала слушать
|
|
force_local: bool = extra and extra.get("force_local")
|
|
if self.quasar.session.x_token and not force_local:
|
|
media_id = utils.fix_cloud_text(media_id)
|
|
if extra and extra.get("volume_level") is not None:
|
|
self._check_set_alice_volume(extra["volume_level"])
|
|
await self.quasar.send(self.device, media_id, is_tts=True)
|
|
return
|
|
|
|
else:
|
|
payload = {'command': 'sendText',
|
|
'text': f"Повтори за мной '{media_id}'"}
|
|
|
|
elif media_type == 'command':
|
|
payload = {'command': 'sendText', 'text': media_id}
|
|
|
|
elif media_type == 'dialog':
|
|
if extra and extra.get("volume_level") is not None:
|
|
self._check_set_alice_volume(extra["volume_level"])
|
|
payload = utils.update_form(
|
|
'personal_assistant.scenarios.repeat_after_me',
|
|
request=media_id)
|
|
|
|
elif media_type == 'json':
|
|
payload = json.loads(media_id)
|
|
|
|
elif RE_MUSIC_ID.match(media_id):
|
|
payload = {'command': 'playMusic', 'id': media_id,
|
|
'type': media_type}
|
|
|
|
elif media_type == 'shopping_list':
|
|
await self._shopping_list()
|
|
return
|
|
|
|
elif media_type.startswith('question'):
|
|
request_id = (media_type.split(':', 1)[1]
|
|
if ':' in media_type else None)
|
|
card = await self.glagol.send({'command': 'sendText',
|
|
'text': media_id})
|
|
await self.response(card, request_id)
|
|
return
|
|
|
|
else:
|
|
_LOGGER.warning(f"Unsupported local media: {media_id}")
|
|
return
|
|
|
|
await self.glagol.send(payload)
|
|
|
|
else:
|
|
if media_type.startswith(("text:", "dialog:")):
|
|
media_id = self.yandex_dialog(media_type, media_id)
|
|
await self.quasar.send(self.device, media_id)
|
|
|
|
elif media_type == 'text':
|
|
media_id = utils.fix_cloud_text(media_id)
|
|
await self.quasar.send(self.device, media_id, is_tts=True)
|
|
|
|
elif media_type == 'command':
|
|
media_id = utils.fix_cloud_text(media_id)
|
|
await self.quasar.send(self.device, media_id)
|
|
|
|
elif media_type == 'brightness':
|
|
await self._set_brightness(media_id)
|
|
return
|
|
|
|
else:
|
|
_LOGGER.warning(f"Unsupported cloud media: {media_type}")
|
|
return
|
|
|
|
|
|
# noinspection PyAbstractClass
|
|
class YandexModule(YandexStation):
|
|
"""YandexModule support only local control."""
|
|
|
|
def __init__(self, quasar: YandexQuasar, device: dict):
|
|
super().__init__(quasar, device)
|
|
|
|
self._attr_available = False
|
|
self._attr_should_poll = False
|
|
|
|
# both yandex moduls don't support music sync
|
|
self.sync_sources = {}
|
|
|
|
self.entity_id = self.entity_id.replace("_station_", "_module_")
|
|
|
|
def async_set_state(self, data: dict):
|
|
super().async_set_state(data)
|
|
|
|
if self._attr_available and self.local_state is None:
|
|
self._attr_available = False
|
|
|
|
async def async_set_volume_level(self, volume: float):
|
|
# yandex module 2 bug
|
|
if self.device_platform == "yandexmodule_2":
|
|
volume *= 10
|
|
await super().async_set_volume_level(volume)
|
|
|
|
async def async_update(self):
|
|
pass
|
|
|
|
async def async_media_play(self):
|
|
# yandex module 2 bug
|
|
if self.device_platform == "yandexmodule_2":
|
|
await self.glagol.send({
|
|
"command": "sendText", "text": "продолжить"
|
|
})
|
|
return
|
|
|
|
await super().async_media_play()
|
|
|
|
async def async_play_media(self, media_type: str, media_id: str, **kwargs):
|
|
kwargs["extra"].setdefault("force_local", True)
|
|
await super().async_play_media(media_type, media_id, **kwargs)
|
|
|
|
|
|
# noinspection PyAbstractClass
|
|
class YandexIntents(MediaPlayerEntity):
|
|
def __init__(self, intents: list):
|
|
self.intents = intents
|
|
|
|
@property
|
|
def name(self):
|
|
return "Yandex Intents"
|
|
|
|
@property
|
|
def supported_features(self):
|
|
return (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET |
|
|
SUPPORT_VOLUME_STEP)
|
|
|
|
async def async_volume_up(self):
|
|
pass
|
|
|
|
async def async_volume_down(self):
|
|
pass
|
|
|
|
async def async_set_volume_level(self, volume):
|
|
index = int(volume * 100) - 1
|
|
if index < len(self.intents):
|
|
text = self.intents[index]
|
|
_LOGGER.debug(f"Получена команда: {text}")
|
|
self.hass.bus.async_fire('yandex_intent', {'text': text})
|
|
|
|
async def async_turn_on(self):
|
|
pass
|
|
|
|
async def async_turn_off(self):
|
|
pass
|
|
|
|
|
|
# noinspection PyAbstractClass
|
|
class YandexTV(MediaPlayerEntity):
|
|
_sources = None
|
|
_supported_features = 0
|
|
|
|
def __init__(self, quasar: YandexQuasar, device: dict):
|
|
self.quasar = quasar
|
|
self.device = device
|
|
|
|
@property
|
|
def unique_id(self):
|
|
return self.device['id'].replace('-', '')
|
|
|
|
@property
|
|
def name(self):
|
|
return self.device['name']
|
|
|
|
@property
|
|
def should_poll(self):
|
|
return False
|
|
|
|
@property
|
|
def device_class(self):
|
|
return DEVICE_CLASS_TV
|
|
|
|
@property
|
|
def icon(self):
|
|
return 'mdi:television-classic'
|
|
|
|
@property
|
|
def state(self):
|
|
return STATE_PLAYING
|
|
|
|
@property
|
|
def supported_features(self):
|
|
return self._supported_features
|
|
|
|
@property
|
|
def source_list(self):
|
|
return list(self._sources.keys())
|
|
|
|
async def async_turn_on(self):
|
|
await self.quasar.device_action(self.device['id'], on=True)
|
|
|
|
async def async_turn_off(self):
|
|
await self.quasar.device_action(self.device['id'], on=False)
|
|
|
|
async def async_volume_up(self):
|
|
await self.quasar.device_action(self.device['id'], volume=1)
|
|
|
|
async def async_volume_down(self):
|
|
await self.quasar.device_action(self.device['id'], volume=-1)
|
|
|
|
async def async_mute_volume(self, mute):
|
|
await self.quasar.device_action(self.device['id'], mute=mute)
|
|
|
|
async def async_media_next_track(self):
|
|
await self.quasar.device_action(self.device['id'], channel=1)
|
|
|
|
async def async_media_previous_track(self):
|
|
await self.quasar.device_action(self.device['id'], channel=-1)
|
|
|
|
async def async_media_pause(self):
|
|
await self.quasar.device_action(self.device['id'], pause=True)
|
|
|
|
async def async_select_source(self, source):
|
|
source = self._sources[source]
|
|
await self.quasar.device_action(self.device['id'], input_source=source)
|
|
|
|
async def async_added_to_hass(self):
|
|
data = await self.quasar.get_device(self.device['id'])
|
|
for capability in data['capabilities']:
|
|
instance = capability['parameters'].get('instance')
|
|
if capability['type'] == 'devices.capabilities.on_off':
|
|
self._supported_features |= \
|
|
SUPPORT_TURN_ON | SUPPORT_TURN_OFF
|
|
elif instance == 'volume':
|
|
self._supported_features |= SUPPORT_VOLUME_STEP
|
|
elif instance == 'channel':
|
|
self._supported_features |= \
|
|
SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK
|
|
elif instance == 'input_source':
|
|
self._sources = {
|
|
p['name']: p['value']
|
|
for p in capability['parameters']['modes']
|
|
}
|
|
self._supported_features |= SUPPORT_SELECT_SOURCE
|
|
elif instance == 'mute':
|
|
self._supported_features |= SUPPORT_VOLUME_MUTE
|
|
elif instance == 'pause':
|
|
# without play, pause from the interface does not work
|
|
self._supported_features |= SUPPORT_PAUSE | SUPPORT_PLAY
|