From e593117ab6f224c8ecad9c1475610b80f1ecfc64 Mon Sep 17 00:00:00 2001 From: Tod Schmidt Date: Mon, 9 Apr 2018 11:46:27 -0400 Subject: [PATCH] Snips sounds (#13746) * Added feedback sound configuration * Added feedback sound configuration * Cleaned up feedback off * Cleaned up whitespace * Moved feedback pus to helper funx * Async * Used async_mock_service for tests * Lint --- homeassistant/components/snips.py | 74 +++++++-- tests/components/test_snips.py | 263 ++++++++++++++++++++++-------- 2 files changed, 256 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index d085b1279cb..812906e7be9 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -4,13 +4,13 @@ Support for Snips on-device ASR and NLU. For more details about this component, please refer to the documentation at https://home-assistant.io/components/snips/ """ -import asyncio import json import logging from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback from homeassistant.helpers import intent, config_validation as cv import homeassistant.components.mqtt as mqtt @@ -19,11 +19,18 @@ DEPENDENCIES = ['mqtt'] CONF_INTENTS = 'intents' CONF_ACTION = 'action' +CONF_FEEDBACK = 'feedback_sounds' +CONF_PROBABILITY = 'probability_threshold' +CONF_SITE_IDS = 'site_ids' SERVICE_SAY = 'say' SERVICE_SAY_ACTION = 'say_action' +SERVICE_FEEDBACK_ON = 'feedback_on' +SERVICE_FEEDBACK_OFF = 'feedback_off' INTENT_TOPIC = 'hermes/intent/#' +FEEDBACK_ON_TOPIC = 'hermes/feedback/sound/toggleOn' +FEEDBACK_OFF_TOPIC = 'hermes/feedback/sound/toggleOff' ATTR_TEXT = 'text' ATTR_SITE_ID = 'site_id' @@ -34,7 +41,12 @@ ATTR_INTENT_FILTER = 'intent_filter' _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {} + DOMAIN: vol.Schema({ + vol.Optional(CONF_FEEDBACK): cv.boolean, + vol.Optional(CONF_PROBABILITY, default=0): vol.Coerce(float), + vol.Optional(CONF_SITE_IDS, default=['default']): + vol.All(cv.ensure_list, [cv.string]), + }), }, extra=vol.ALLOW_EXTRA) INTENT_SCHEMA = vol.Schema({ @@ -57,7 +69,6 @@ SERVICE_SCHEMA_SAY = vol.Schema({ vol.Optional(ATTR_SITE_ID, default='default'): str, vol.Optional(ATTR_CUSTOM_DATA, default=''): str }) - SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ vol.Required(ATTR_TEXT): str, vol.Optional(ATTR_SITE_ID, default='default'): str, @@ -65,13 +76,31 @@ SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ vol.Optional(ATTR_CAN_BE_ENQUEUED, default=True): cv.boolean, vol.Optional(ATTR_INTENT_FILTER): vol.All(cv.ensure_list), }) +SERVICE_SCHEMA_FEEDBACK = vol.Schema({ + vol.Optional(ATTR_SITE_ID, default='default'): str +}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Activate Snips component.""" - @asyncio.coroutine - def message_received(topic, payload, qos): + @callback + def async_set_feedback(site_ids, state): + """Set Feedback sound state.""" + site_ids = (site_ids if site_ids + else config[DOMAIN].get(CONF_SITE_IDS)) + topic = (FEEDBACK_ON_TOPIC if state + else FEEDBACK_OFF_TOPIC) + for site_id in site_ids: + payload = json.dumps({'siteId': site_id}) + hass.components.mqtt.async_publish( + FEEDBACK_ON_TOPIC, None, qos=0, retain=False) + hass.components.mqtt.async_publish( + topic, payload, qos=int(state), retain=state) + + if CONF_FEEDBACK in config[DOMAIN]: + async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) + + async def message_received(topic, payload, qos): """Handle new messages on MQTT.""" _LOGGER.debug("New intent: %s", payload) @@ -81,6 +110,13 @@ def async_setup(hass, config): _LOGGER.error('Received invalid JSON: %s', payload) return + if (request['intent']['probability'] + < config[DOMAIN].get(CONF_PROBABILITY)): + _LOGGER.warning("Intent below probaility threshold %s < %s", + request['intent']['probability'], + config[DOMAIN].get(CONF_PROBABILITY)) + return + try: request = INTENT_SCHEMA(request) except vol.Invalid as err: @@ -97,7 +133,7 @@ def async_setup(hass, config): slots[slot['slotName']] = {'value': resolve_slot_values(slot)} try: - intent_response = yield from intent.async_handle( + intent_response = await intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) if 'plain' in intent_response.speech: snips_response = intent_response.speech['plain']['speech'] @@ -115,11 +151,10 @@ def async_setup(hass, config): mqtt.async_publish(hass, 'hermes/dialogueManager/endSession', json.dumps(notification)) - yield from hass.components.mqtt.async_subscribe( + await hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) - @asyncio.coroutine - def snips_say(call): + async def snips_say(call): """Send a Snips notification message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -129,8 +164,7 @@ def async_setup(hass, config): json.dumps(notification)) return - @asyncio.coroutine - def snips_say_action(call): + async def snips_say_action(call): """Send a Snips action message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -144,12 +178,26 @@ def async_setup(hass, config): json.dumps(notification)) return + async def feedback_on(call): + """Turn feedback sounds on.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), True) + + async def feedback_off(call): + """Turn feedback sounds off.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), False) + hass.services.async_register( DOMAIN, SERVICE_SAY, snips_say, schema=SERVICE_SCHEMA_SAY) hass.services.async_register( DOMAIN, SERVICE_SAY_ACTION, snips_say_action, schema=SERVICE_SCHEMA_SAY_ACTION) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_ON, feedback_on, + schema=SERVICE_SCHEMA_FEEDBACK) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_OFF, feedback_off, + schema=SERVICE_SCHEMA_FEEDBACK) return True diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index f37beef7960..2342e897708 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -1,20 +1,92 @@ """Test the Snips component.""" -import asyncio import json import logging -from homeassistant.core import callback from homeassistant.bootstrap import async_setup_component +from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA +import homeassistant.components.snips as snips from tests.common import (async_fire_mqtt_message, async_mock_intent, async_mock_service) -from homeassistant.components.snips import (SERVICE_SCHEMA_SAY, - SERVICE_SCHEMA_SAY_ACTION) -@asyncio.coroutine -def test_snips_intent(hass, mqtt_mock): +async def test_snips_config(hass, mqtt_mock): + """Test Snips Config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True, + "probability_threshold": .5, + "site_ids": ["default", "remote"] + }, + }) + assert result + + +async def test_snips_bad_config(hass, mqtt_mock): + """Test Snips bad config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": "on", + "probability": "none", + "site_ids": "default" + }, + }) + assert not result + + +async def test_snips_config_feedback_on(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + assert calls[1].data['qos'] == 1 + assert calls[1].data['retain'] + + +async def test_snips_config_feedback_off(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": False + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOff' + assert calls[1].data['qos'] == 0 + assert not calls[1].data['retain'] + + +async def test_snips_config_no_feedback(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'snips', 'say') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_snips_intent(hass, mqtt_mock): """Test intent via Snips.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -41,7 +113,7 @@ def test_snips_intent(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' @@ -50,10 +122,9 @@ def test_snips_intent(hass, mqtt_mock): assert intent.text_input == 'turn the lights green' -@asyncio.coroutine -def test_snips_intent_with_duration(hass, mqtt_mock): +async def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -61,7 +132,8 @@ def test_snips_intent_with_duration(hass, mqtt_mock): { "input": "set a timer of five minutes", "intent": { - "intentName": "SetTimer" + "intentName": "SetTimer", + "probability": 1 }, "slots": [ { @@ -92,7 +164,7 @@ def test_snips_intent_with_duration(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/SetTimer', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' @@ -100,22 +172,14 @@ def test_snips_intent_with_duration(hass, mqtt_mock): assert intent.slots == {'timer_duration': {'value': 300}} -@asyncio.coroutine -def test_intent_speech_response(hass, mqtt_mock): +async def test_intent_speech_response(hass, mqtt_mock): """Test intent speech response via Snips.""" - event = 'call_service' - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - result = yield from async_setup_component(hass, "snips", { + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result - result = yield from async_setup_component(hass, "intent_script", { + result = await async_setup_component(hass, "intent_script", { "intent_script": { "spokenIntent": { "speech": { @@ -131,31 +195,28 @@ def test_intent_speech_response(hass, mqtt_mock): "input": "speak to me", "sessionId": "abcdef0123456789", "intent": { - "intentName": "spokenIntent" + "intentName": "spokenIntent", + "probability": 1 }, "slots": [] } """ - hass.bus.async_listen(event, record_event) async_fire_mqtt_message(hass, 'hermes/intent/spokenIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() - assert len(events) == 1 - assert events[0].data['domain'] == 'mqtt' - assert events[0].data['service'] == 'publish' - payload = json.loads(events[0].data['service_data']['payload']) - topic = events[0].data['service_data']['topic'] + assert len(calls) == 1 + payload = json.loads(calls[0].data['payload']) + topic = calls[0].data['topic'] assert payload['sessionId'] == 'abcdef0123456789' assert payload['text'] == 'I am speaking to you' assert topic == 'hermes/dialogueManager/endSession' -@asyncio.coroutine -def test_unknown_intent(hass, mqtt_mock, caplog): +async def test_unknown_intent(hass, mqtt_mock, caplog): """Test unknown intent.""" caplog.set_level(logging.WARNING) - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -164,21 +225,21 @@ def test_unknown_intent(hass, mqtt_mock, caplog): "input": "I don't know what I am supposed to do", "sessionId": "abcdef1234567890", "intent": { - "intentName": "unknownIntent" + "intentName": "unknownIntent", + "probability": 1 }, "slots": [] } """ async_fire_mqtt_message(hass, 'hermes/intent/unknownIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert 'Received unknown intent unknownIntent' in caplog.text -@asyncio.coroutine -def test_snips_intent_user(hass, mqtt_mock): +async def test_snips_intent_user(hass, mqtt_mock): """Test intentName format user_XXX__intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -186,7 +247,8 @@ def test_snips_intent_user(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "user_ABCDEF123__Lights" + "intentName": "user_ABCDEF123__Lights", + "probability": 1 }, "slots": [] } @@ -194,7 +256,7 @@ def test_snips_intent_user(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/user_ABCDEF123__Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -202,10 +264,9 @@ def test_snips_intent_user(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_intent_username(hass, mqtt_mock): +async def test_snips_intent_username(hass, mqtt_mock): """Test intentName format username:intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -213,7 +274,8 @@ def test_snips_intent_username(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "username:Lights" + "intentName": "username:Lights", + "probability": 1 }, "slots": [] } @@ -221,7 +283,7 @@ def test_snips_intent_username(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/username:Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -229,15 +291,41 @@ def test_snips_intent_username(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_say(hass, caplog): +async def test_snips_low_probability(hass, mqtt_mock, caplog): + """Test intent via Snips.""" + caplog.set_level(logging.WARNING) + result = await async_setup_component(hass, "snips", { + "snips": { + "probability_threshold": 0.5 + }, + }) + assert result + payload = """ + { + "input": "I am not sure what to say", + "intent": { + "intentName": "LightsMaybe", + "probability": 0.49 + }, + "slots": [] + } + """ + + async_mock_intent(hass, 'LightsMaybe') + async_fire_mqtt_message(hass, 'hermes/intent/LightsMaybe', + payload) + await hass.async_block_till_done() + assert 'Intent below probaility threshold 0.49 < 0.5' in caplog.text + + +async def test_snips_say(hass, caplog): """Test snips say with invalid config.""" calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) + snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -245,15 +333,14 @@ def test_snips_say(hass, caplog): assert calls[0].data['text'] == 'Hello' -@asyncio.coroutine -def test_snips_say_action(hass, caplog): +async def test_snips_say_action(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'intent_filter': ['myIntent']} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -262,31 +349,71 @@ def test_snips_say_action(hass, caplog): assert calls[0].data['intent_filter'] == ['myIntent'] -@asyncio.coroutine -def test_snips_say_invalid_config(hass, caplog): +async def test_snips_say_invalid_config(hass, caplog): """Test snips say with invalid config.""" calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) + snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello', 'badKey': 'boo'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text -@asyncio.coroutine -def test_snips_say_action_invalid_config(hass, caplog): +async def test_snips_say_action_invalid(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'can_be_enqueued': 'notabool'} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text + + +async def test_snips_feedback_on(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_on' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_off(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_off', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_off', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_off' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_config(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote', 'test': 'test'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 0