OK Google, заведи мне машину

Habrahabr 4

Будущее всё ближе. Лет 10 назад я и не мог подумать, что буду заводить машину с помощью голосовой команды!

Последние годы я с интересом наблюдал за бурным развитием голосовых ассистентов. После выхода Google Home Mini, решил что и мне уже пора попробовать, так как цена стала более-менее адекватной для «игрушки». Первый проект — интеграция голосового помощника с GSM модулем StarLine для автозапуска, контроля координат, напряжения аккумулятора и других параметров, отдаваемых сигнализацией автомобиля. Итак, поехали? Наличие Google Home не обязательно, всё описанное далее будет работать и с приложением Google Assistant на телефоне. У меня установлен GSM/GPS модуль StarLine M31, но должно работать со всеми GSM сигнализациями от StarLine.

Общая схема приложения для Google Assistant

  • Google Home / Google Assistant отвечает за преобразование голоса в текст и обратно + взаимодействие со стандартными гугловскими сервисами. При вызове нашего приложения, Action в терминологии Google, запросы передаются на DialogFlow (API.AI на схеме).
  • DialogFlow — отвечает за определение схемы диалога, обработку текста запросов на естественном языке, выделение сущностей, формирование ответов и взаимодействие с внешним миром с помощью вызова WebHook при необходимости.
  • WebHook — WEB-сервис для взаимодействия с внешним миром. На вход подается ветка диалога (Intent) + параметры извлеченные из запроса (Entities). На выходе — ответ пользователю.

1. DialogFlow.com

Для начала нам надо создать приложение (agent) на dialogflow (бывший API.AI). Регистрируемся с помощью Google аккаунта к которому у нас будет привязан Google Home. К сожалению, русский язык пока не доступен для Google Assistant, выбираем английский.

Далее нам надо создать Intents. Intent в терминологии DialogFlow — одна из веток диалога отвечающая за определенное действие. В нашем случае это будут: GetBattery, GetTemperature, StartEngine, StopEngine. Так же существует Default Intent, срабатывающий в самом начале, обычно это приветствие и краткий рассказ о том, что можно делать с помощью данного приложения. В каждом Intent нам необходимо указать примеры голосовых команд (User says), желательно по 5-10 разных вариантов.

Во всех Intents, кроме дефолтного, нам необходимо отправлять запросы к нашему скрипту (WebHook), поэтому ставим Fulfillment — Use webhook.

2. WebHook для взаимодействия с сервером Starline

Нам нужен скрипт который получает Intent из запроса от DialogFlow и дергает команды Starline. Быстрее всего у меня получилось реализовать это на Python+Flask.

Взаимодействие со StarLine взято отсюда + прочекано на актуальность снифером в браузере. Для запуска на сервере я использовал gunicorn

gunicorn -b :3333 flask.starline:app
+ nginx в качестве реверс прокси. Учтите, HTTPS обязателен!
starline.py
from flask import Flask, request
from flask_restful import reqparse, Resource, Api, abort
import requests
import logging

DEVICE_ID = 1234567 # Use HTTPS sniffer to find your DEVICE_ID in https://starline-online.ru/ traffic
LOGIN = 'YOUR_STARLINE_EMAIL'
PASS = 'YOUR_STARLINE_PASSWORD'

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
header = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:28.0) Gecko/20100101 Firefox/28.0',
    'Accept': 'application/json, text/javascript, */*; q=0.01',
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
    'X-Requested-With': 'XMLHttpRequest'}


def start_engine(): 
    with requests.Session() as session:
        t = session.get('https://starline-online.ru/', headers=header)
        login = session.post('https://starline-online.ru/user/login', {
            'LoginForm[login]': LOGIN,
            'LoginForm[pass]': PASS,
            'LoginForm[rememberMe]': 'off'}, headers=header)
        logging.debug(login.content)
        r0 = session.get('https://starline-online.ru/device', headers=header)
        logging.debug(r0.content)
        r = session.post('https://starline-online.ru/device/{0}/executeCommand'.format(DEVICE_ID), {
            'value': '1',
            'action': 'ign',
            'password': ''}, headers=header, timeout=1)
        logging.debug(r.status_code)
        logging.debug(r.content)
        logout = session.post('https://starline-online.ru/user/logout', {
            '': ''}, )
        return ('Engine started!')


def stop_engine(): 
    with requests.Session() as session:
        t = session.get('https://starline-online.ru/', headers=header)
        login = session.post('https://starline-online.ru/user/login', {
            'LoginForm[login]': LOGIN,
            'LoginForm[pass]': PASS,
            'LoginForm[rememberMe]': 'off'}, headers=header)
        logging.debug(login.content)
        r0 = session.get('https://starline-online.ru/device', headers=header)
        logging.debug(r0.content)
        r = session.post('https://starline-online.ru/device/{0}/executeCommand'.format(DEVICE_ID), {
            'value': '0',
            'action': 'ign',
            'password': ''}, headers=header)
        logging.debug(r.status_code)
        logging.debug(r.content)
        logout = session.post('https://starline-online.ru/user/logout', {
            '': ''}, )
        return ('Engine stopped!')


def get_params():
    with requests.Session() as session:
        t = session.get('https://starline-online.ru/', headers=header)
        login = session.post('https://starline-online.ru/user/login', {
            'LoginForm[login]': LOGIN,
            'LoginForm[pass]': PASS,
            'LoginForm[rememberMe]': 'off'}, headers=header)
        logging.debug(login.content)
        r0 = session.get('https://starline-online.ru/device', headers=header)
        logging.debug(r0.content)
        res_dict = r0.json()['answer']['devices'][0]

        logout = session.post('https://starline-online.ru/user/logout', {
            '': ''}, )
        return {'battery': res_dict['battery'], 'temperature': res_dict['ctemp']}


def get_battery_text():
    return ("Battery voltage {0} volts.".format(get_params()['battery']))


def get_temperature_text():
    return ("Temperature: {0} degrees.".format(get_params()['temperature']))


app = Flask(__name__)
app.config['BUNDLE_ERRORS'] = True
api = Api(app)


class ProccessGoogleRequest(Resource):
    def get(self):
        return {"status": "OK"}

    def post(self):
        req = request.get_json()
        logging.debug(request.get_json())
        response = ''
        if req['result']['metadata']['intentName'] == 'GetBattery':
            response = get_battery_text()
        if req['result']['metadata']['intentName'] == 'GetTemperature':
            response = get_temperature_text()
        if req['result']['metadata']['intentName'] == 'StartEngine':
            response = start_engine()
        if req['result']['metadata']['intentName'] == 'StopEngine':
            response = stop_engine()
        if response == '':
            abort(400, message='Intent not detected')
        return {"speech": response, "displayText": response}


api.add_resource(ProccessGoogleRequest, '/starline/')

if __name__ == '__main__':
    app.run(debug=False)

Да, пользуясь случаем, хочу обратиться к команде StarLine — ребята, почему бы не сделать нормальный API с документацией? Глядишь и интеграций со сторонними продуктами стало бы в разы больше?

3. Тестируем в симуляторе и на реальном усройстве

Для тестирования в DialogFlow заходим в Integrations -> Google Assistant -> INTEGRATION SETTINGS -> Test и попадаем в симулятор Actions on Google

А вот и результат тестирования в реальном мире

Единственный косяк, в данной версии он отвечает «Engine started» до реального запуска двигателя так как не успевает дождаться ответа от Starline.

Идеи:

1. Запрос местоположения у Google Assistant, озвучивание расстояния до машины (Starline умеет отдавать координаты). Пока непонятно как для WebHook на Python запросить местоположение Google Home.

2. Упростить интеграцию Google <-> Starline, тогда отпадёт необходимость хардкодить пароль. Без участия со стороны Starline, как я понимаю, это не возможно.

Известные проблемы:

1. Google Assistant не успевает дождаться от сервера Starline ответа о статусе запуска двигателя

2. Пока при тестировании можно использовать только дефолтное имя приложения(Invocation) — Hey Google, talk to my test app.

Полезные ссылки:

1. Видео от Google

2. Пример с использованием Entities