From ab8330be318b32b6b2c2de584c71d705ffa24650 Mon Sep 17 00:00:00 2001 From: Alexey Khit Date: Sun, 1 Mar 2020 14:32:38 +0300 Subject: [PATCH] Add service sequence support --- CHANGELOG.md | 6 + README.md | 179 +++++++++++++----- custom_components/yandex_station/__init__.py | 18 +- .../yandex_station/media_player.py | 119 ++++++------ custom_components/yandex_station/utils.py | 36 +++- 5 files changed, 242 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9962002..6975fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.12 - 2020-03-01 + +### Added + +- Поддержка последовательного выполнения команд (очередей) + ## 0.1.11 - 2020-02-22 ### Changed diff --git a/README.md b/README.md index ffd2d1e..4cc1627 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - [CHANGELOG](https://github.com/AlexxIT/YandexStation/blob/master/CHANGELOG.md) -На середину февраля 2020 поддерживается: +На начало марта 2020 поддерживается: - Яндекс.Станция (большая) - Яндекс.Модуль (у меня нет, но по отзывам работает) @@ -51,25 +51,98 @@ yandex_station: token: abcdefghijklmnopqrstuvwxyz ``` -## Пример использования +## Примеры использования + +Если у вас в конфиге есть другие TTS - читайте раздел "*Несколько TTS в конфиге*". + +Для шаблонов не забывайте указывать `data_template`, для остальных команд хватит просто `data`. + +Поддерживаются команды на несколько станций одновременно (как TTS, так и media_player). + +### Обычный способ вызвать TTS + +Зависит от настройки "Режим звука" (из окна медиа-плеера). Будет или произносить текст или выполнять команду. Он же вызывается из окна медиа-плеера. ```yaml script: # TTS зависит от настройки "Режим звука"! (произнести или выполнить команду) - yandex_tts: - alias: TTS на Яндекс.Станции + yandex_tts1: + alias: TTS зависит от настройки "Режим звука"! sequence: - service: tts.yandex_station_say + entity_id: media_player.yandex_station data_template: - entity_id: media_player.yandex_station_12345678901234567890 message: Температура в комнате {{ states("sensor.temperature_hall")|round }} градуса. +``` + +### Второй способ вызвать TTS + +Не зависит от настройки "Режим звука". + +```yaml +script: + # TTS не зависит от настройки "Режим звука"! и всегда будет произносить фразу + yandex_tts2: + alias: TTS не зависит от настройки "Режим звука" + sequence: + - service: media_player.play_media + entity_id: media_player.yandex_station + data: + media_content_id: Повторяю вашу фразу + media_content_type: text +``` + +### Продвинутый TTS + +Не зависит от настройки "Режим звука", но продолжает слушать после произнесения текста! + +В этом режиме поддерживаются эффекты, библиотека звуков и настройка речи: + +- [Настройка генерацию речи](https://yandex.ru/dev/dialogs/alice/doc/speech-tuning-docpage/) + ```yaml + media_content_id: смелость sil <[500]> город+а берёт + ``` +- [Наложение эффектов на голос](https://yandex.ru/dev/dialogs/alice/doc/speech-effects-docpage/) + ```yaml + media_content_id: Ехал Грека через реку видит Грека в реке рак + ``` +- [Библиотека звуков](https://yandex.ru/dev/dialogs/alice/doc/sounds-docpage/) + ```yaml + media_content_id: У вас получилось! + ``` + +```yaml +script: + yandex_tts3: + alias: TTS c эффектами + sequence: + # Отправляем TTS с эффектами (media_content_type: dialog) + - service: media_player.play_media + entity_id: media_player.yandex_station + data: + media_content_id: Объявление погоды на сегодня... + media_content_type: dialog + # Ожидаем окончания фразы (после dialog нужно дожидаться LISTENING) + - wait_template: "{{ is_state_attr('media_player.yandex_station', 'alice_state', 'LISTENING') }}" + + # Останавливаем режим LISTENING + - service: yandex_station.send_command + entity_id: media_player.yandex_station + data: + command: cancelVoiceDialog +``` + +### Примеры управления станцией + +```yaml +script: yandex_play_album: alias: Включить Би-2 на Станции sequence: - service: media_player.play_media + entity_id: media_player.yandex_station data: - entity_id: media_player.yandex_station_12345678901234567890 media_content_id: 60062 # ID альбома в Яндекс.Музыка media_content_type: album # album, track or playlist @@ -87,58 +160,72 @@ script: alias: Звук Станции на HDMI sequence: - service: media_player.select_source + entity_id: media_player.yandex_station_12345678901234567890 data: - entity_id: media_player.yandex_station_12345678901234567890 source: HDMI ``` -Для шаблонов не забывайте указывать `data_template`, для остальных команд -хватит просто `data`. +## Очередь команд -Поддерживаются команды на несколько станций одновременно (как TTS, так и -media_player). +Команды можно выполнять последовательно, дожидаясь ответа от станции. -## Продвинутое использование TTS +**Внимание!** При ожидании окончания "продвинутого" TTS (`dialog`) необходимо дожидаться статуса `LISTENING` и желательно после выполнять команду `cancelVoiceDialog` (пример есть выше). При работе с обычным TTS (`text` или `tts.yandex_station_say`) необходимо дожидаться статуса `IDLE`, как в примере ниже. ```yaml script: - # TTS не зависит от настройки "Режим звука"! и всегда будет произносить фразу - yandex_tts2: - alias: TTS на Яндекс.Станции + yandex_queue: + alias: Очередь команд на станции sequence: - - service: media_player.play_media - data: - entity_id: media_player.yandex_station_12345678901234567890 - media_content_id: Повторяю вашу фразу - media_content_type: text - - # TTS не зависит от настройки "Режим звука"! и будет продолжать слушать после - # произнесения фразы - yandex_tts3: - alias: TTS на Яндекс.Станции - sequence: - - service: media_player.play_media - data: - entity_id: media_player.yandex_station_12345678901234567890 - media_content_id: Мне следует пропылесосить? - media_content_type: dialog + # Устанавливаем громкость станции + - service: media_player.volume_set + entity_id: media_player.yandex_station + data: + volume_level: 0.3 + + # Узнаём у Яндекса погоду (это выполнение команды, а не TTS!) + - service: media_player.play_media + entity_id: media_player.yandex_station + data: + media_content_id: Какая погода сегодня в Москве? + media_content_type: command + + # Ожидаем окончания фразы (после command нужно дожидаться IDLE) + - wait_template: "{{ is_state_attr('media_player.yandex_station', 'alice_state', 'IDLE') }}" + + # Узнаём у Яндекса пробки (это выполнение команды, а не TTS!) + - service: media_player.play_media + entity_id: media_player.yandex_station + data: + media_content_id: Какие пробки сегодня в Москве? + media_content_type: command + + # Ожидаем окончания фразы (после command нужно дожидаться IDLE) + - wait_template: "{{ is_state_attr('media_player.yandex_station', 'alice_state', 'IDLE') }}" + + # Запускаем обычный TTS + - service: media_player.play_media + entity_id: media_player.yandex_station + data: + media_content_id: Хорошего вам дня. А теперь послушайте музыку, которую любите... + media_content_type: text + + # Ожидаем окончания фразы (после command нужно дожидаться IDLE) + - wait_template: "{{ is_state_attr('media_player.yandex_station', 'alice_state', 'IDLE') }}" + + # Устанавливаем громкость станции + - service: media_player.volume_set + entity_id: media_player.yandex_station + data: + volume_level: 0.2 + + # Включаем любимую музыку на станции (это выполнение команды, а не TTS!) + - service: media_player.play_media + entity_id: media_player.yandex_station + data: + media_content_id: Включи мою любимую музыку + media_content_type: command ``` -В режиме `media_content_type: dialog` поддерживаются: - -- [Настройка генерацию речи](https://yandex.ru/dev/dialogs/alice/doc/speech-tuning-docpage/) - ```yaml - media_content_id: смелость sil <[500]> город+а берёт - ``` -- [Наложение эффектов на голос](https://yandex.ru/dev/dialogs/alice/doc/speech-effects-docpage/) - ```yaml - media_content_id: Ехал Грека через реку видит Грека в реке рак - ``` -- [Библиотека звуков](https://yandex.ru/dev/dialogs/alice/doc/sounds-docpage/) - ```yaml - media_content_id: У вас получилось! - ``` - ## Продвинутое использование команд Компонент создаёт сервис `yandex_station.send_command`, которому необходимо передать команду. diff --git a/custom_components/yandex_station/__init__.py b/custom_components/yandex_station/__init__.py index c6c7c86..ca42fce 100644 --- a/custom_components/yandex_station/__init__.py +++ b/custom_components/yandex_station/__init__.py @@ -68,14 +68,17 @@ async def send_command(call: ServiceCall): return data = { - ATTR_MEDIA_CONTENT_ID: json.dumps(data), - ATTR_MEDIA_CONTENT_TYPE: 'command', ATTR_ENTITY_ID: entity_ids, + ATTR_MEDIA_CONTENT_ID: data.get('text'), + ATTR_MEDIA_CONTENT_TYPE: 'dialog', + } if data.get('command') == 'dialog' else { + ATTR_ENTITY_ID: entity_ids, + ATTR_MEDIA_CONTENT_ID: json.dumps(data), + ATTR_MEDIA_CONTENT_TYPE: 'json', } - await hass.services.async_call( - DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True - ) + await hass.services.async_call(DOMAIN_MP, SERVICE_PLAY_MEDIA, data, + blocking=True) async def yandex_station_say(call: ServiceCall): entity_ids = call.data.get(ATTR_ENTITY_ID) or utils.find_station(hass) @@ -94,9 +97,8 @@ async def yandex_station_say(call: ServiceCall): ATTR_ENTITY_ID: entity_ids, } - await hass.services.async_call( - DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True - ) + await hass.services.async_call(DOMAIN_MP, SERVICE_PLAY_MEDIA, data, + blocking=True) def add_device(info: dict): info['yandex_token'] = yandex_token diff --git a/custom_components/yandex_station/media_player.py b/custom_components/yandex_station/media_player.py index b66d370..986429e 100644 --- a/custom_components/yandex_station/media_player.py +++ b/custom_components/yandex_station/media_player.py @@ -48,47 +48,19 @@ def __init__(self, config: dict): super().__init__() self._config = config - self._name = None - self._state = None - self._extra = None - self._updated_at = None + self._name: Optional[str] = None + self._state: Optional[dict] = None + self._extra: Optional[dict] = None + self._updated_at: Optional[dt] = None self._prev_volume = 0.1 self._sound_mode = SOUND_MODE1 async def async_added_to_hass(self) -> None: self._name = self._config['name'] - # TODO: fix create_task - session = async_get_clientsession(self.hass) - asyncio.create_task(self.run_forever(session)) - - async def update(self, data: dict = None): - data['state'].pop('timeSinceLastVoiceActivity', None) - - # skip same state - if self._state == data['state']: - # _LOGGER.debug("Update with same state") - return - # else: - # _LOGGER.debug("Update with new state") - - self._state = data['state'] - - # if 'vinsResponse' in data: - # _LOGGER.debug(json.dumps(data['vinsResponse'], ensure_ascii=False)) - try: - data = data['extra']['appState'].encode('ascii') - data = base64.b64decode(data) - m = RE_EXTRA.search(data) - self._extra = json.loads(m[0]) if m else None - except: - self._extra = None - - self._updated_at = dt.utcnow() - - # _LOGGER.debug(f"Update state {self._config['id']}") - - self.schedule_update_ha_state() + session = async_get_clientsession(self.hass) + coro = self.run_forever(session) + asyncio.create_task(coro) @property def should_poll(self) -> bool: @@ -195,6 +167,13 @@ def sound_mode(self): def sound_mode_list(self): return [SOUND_MODE1, SOUND_MODE2] + @property + def state_attributes(self): + attrs = super().state_attributes + if attrs and self._state: + attrs['alice_state'] = self._state['aliceState'] + return attrs + async def async_select_sound_mode(self, sound_mode): self._sound_mode = sound_mode @@ -232,42 +211,68 @@ async def async_media_previous_track(self): async def async_media_next_track(self): await self.send_to_station({'command': 'next'}) + async def async_turn_on(self): + await self.send_to_station(utils.update_form( + 'personal_assistant.scenarios.player_continue')) + + async def async_turn_off(self): + await self.send_to_station(utils.update_form( + 'personal_assistant.scenarios.quasar.go_home')) + + async def update(self, data: dict = None): + data['state'].pop('timeSinceLastVoiceActivity', None) + + # _LOGGER.debug(data['state']['aliceState']) + + # skip same state + if self._state == data['state']: + return + + self._state = data['state'] + + try: + data = data['extra']['appState'].encode('ascii') + data = base64.b64decode(data) + m = RE_EXTRA.search(data) + self._extra = json.loads(m[0]) if m else None + except: + self._extra = None + + self._updated_at = dt.utcnow() + + # _LOGGER.debug(f"Update state {self._config['id']}") + + self.schedule_update_ha_state() + async def async_play_media(self, media_type: str, media_id: str, **kwargs): if media_type == 'tts': - message = f"Повтори за мной '{media_id}'" \ - if self.sound_mode == SOUND_MODE1 else media_id + media_type = 'text' if self.sound_mode == SOUND_MODE1 \ + else 'command' - await self.send_to_station( - {'command': 'sendText', 'text': message}) + if media_type == 'text': + payload = {'command': 'sendText', + 'text': f"Повтори за мной '{media_id}'"} - elif media_type == 'text': - await self.send_to_station({ - 'command': 'sendText', - 'text': f"Повтори за мной '{media_id}'" - }) + elif media_type == 'command': + payload = {'command': 'sendText', 'text': media_id} elif media_type == 'dialog': - await self.send_to_station(utils.update_form( + payload = utils.update_form( 'personal_assistant.scenarios.repeat_after_me', - request=media_id)) + request=media_id) - elif media_type == 'command': - await self.send_to_station(json.loads(media_id)) + elif media_type == 'json': + payload = json.loads(media_id) elif RE_MUSIC_ID.match(media_id): - await self.send_to_station({ - 'command': 'playMusic', 'id': media_id, 'type': media_type}) + payload = {'command': 'playMusic', 'id': media_id, + 'type': media_type} else: _LOGGER.warning(f"Unsupported media: {media_id}") + return - async def async_turn_on(self): - await self.send_to_station(utils.update_form( - 'personal_assistant.scenarios.player_continue')) - - async def async_turn_off(self): - await self.send_to_station(utils.update_form( - 'personal_assistant.scenarios.quasar.go_home')) + await self.send_to_station(payload) SOURCE_STATION = 'Станция' diff --git a/custom_components/yandex_station/utils.py b/custom_components/yandex_station/utils.py index e4985f9..5e09cee 100644 --- a/custom_components/yandex_station/utils.py +++ b/custom_components/yandex_station/utils.py @@ -66,7 +66,9 @@ class Glagol: def __init__(self): self._config = None self.device_token = None - self.ws = None + self.ws: Optional[websockets.connect] = None + self.new_state: Optional[asyncio.Event] = None + self.wait_response = False async def refresh_device_token(self, session: ClientSession): _LOGGER.debug(f"Refresh device token {self._config['id']}") @@ -84,6 +86,8 @@ async def refresh_device_token(self, session: ClientSession): self.device_token = resp['token'] async def run_forever(self, session: ClientSession): + self.new_state = asyncio.Event() + while True: _LOGGER.debug(f"Restart status loop {self._config['id']}") @@ -93,14 +97,27 @@ async def run_forever(self, session: ClientSession): uri = f"wss://{self._config['host']}:{self._config['port']}" try: self.ws = await websockets.connect(uri, ssl=SSLContext()) + # врядли это API работает, но пусть будет await self.ws.send(json.dumps({ 'conversationToken': self.device_token, - 'payload': {'command': 'subscribeStatus', 'interval': 1} + 'payload': {'command': 'subscribeStatus', 'interval': 5} })) + # сбросим на всяк пожарный + self.wait_response = False + while True: res = await self.ws.recv() - await self.update(json.loads(res)) + data = json.loads(res) + + if self.wait_response: + if 'vinsResponse' in data: + self.wait_response = False + continue + + self.new_state.set() + + await self.update(data) except ConnectionClosed as e: if e.code == 4000: @@ -117,12 +134,21 @@ async def run_forever(self, session: ClientSession): await asyncio.sleep(30) - async def send_to_station(self, message: dict): + async def send_to_station(self, payload: dict): + # _LOGGER.debug(f"Send: {payload}") + + if payload.get('command') in ('sendText', 'serverAction'): + self.wait_response = True + await self.ws.send(json.dumps({ 'conversationToken': self.device_token, - 'payload': message + 'payload': payload })) + # block until new state receive + self.new_state.clear() + await self.new_state.wait() + async def update(self, data: dict): pass