Я хочу рассказать о том, как сделать интеграцию с Google Home своими руками. В этом проекте я буду интегрировать несколько домашних кондиционеров, которые исходно управляются с пульта по infrared. Интеграция с Google Home означает возможность сказать “Hey G, turn on air conditioner” и получить желаемый результат.

Задача оказалось сложной интеграционно, то есть не сложностью алгоритмов или количеством кода, а количеством вовлеченных компонент и технологий. Маленькая заметочка о пройденном пути переросла в цикл из семи.

В этой первой части я расскажу о том, что такое “устройство” с точки зрения Google Home, и как конфигурировать для него свои устройства.

Полный список заметок этой серии:

Также, как и в лабе Smart Home Washer, я буду использовать Firebase Functions для размещения “серверной” части, что влечет за собой использование TypeScript.

Общая структура устройств в Google Home

Есть большой список возможных типов девайсов. Он нужен, чтобы Assistant понимал, как устройство нам представлять, и как мы будем говорить команды этому устройству. То есть это классификация устройства в человеческом языке. Тип устройства вообще не определяет его возможности!

Возможности устройства определяют traits (перевод как “черты” или “особенности” в данном контексте мне режет слух, англицизм тоже не звучит, поэтому в тексте будет термин безе перевода). Есть большой список traits. Если заметили в списке типов, то для каждого типа устройств есть список рекомендуемых traits.

У каждого trait есть aтрибуты (attributes) и стейты (states). Атрибуты уточняют допустимое поведение этого trait. Атрибуты завитсят от возможностей конкретной модели устройства, и поэтому меняются крайне редко, например, при смене кондиционера на более дорогую модель. При каждодневном использовании атрибуты остаются неизменными, меняются только стейты.

Стейты описывают текущее или желаемое состояние устройства. Стейты являются изменяемыми данными и пеередаются как от нашего сервера в Assistant (текущее состояние), так и из Assistant к нам (требуемое состояние, запрошенное пользователем).

Загляните в списки типов и traits прямо сейчас, дальше потребуется хотя бы легкое знакомство с ними.

Простейший Air Conditioner

Итак, у меня есть банальный кондиционер, управляемый с помощью пульта, для которого я записал команды включиться и установить температуру, и выключиться. Соответственно, он у меня будет Air contitioning unit, и будет реализовывать traits OnOff и TemperatureSetting.

У обоих traits есть атрибут, показывающий, может или не может устройство сообщать о своем состоянии. Устройство, управляемое по IR, разумеется, не может. Поэтому для OnOff будет commandOnlyOnOff: true и для TemperatureSetting будет commandOnlyTemperatureSetting: true.

Разница между true и false для commandOnlyTemperatureSetting в том, будет или не будет Google запрашивать состояние устройства перед отправкой команды. В случае true - не будет, но на запрос пользователя “It’s too hot” пришлет команду TemperatureRelative с относительным изменением температуры, например, “-3 градуса”, и ответственность за вычисление абсолютной температуры будет на нас. В случае false Google сначала запросит текущее состояние устройства, а затем пришлет команду ThermostatTemperatureSetpoint с новым абсолютным значением температуры.

Есть термостаты, которые могут сообщать температуру, но мы не можем на нее повлиять. Это не наш случай, поэтому queryOnlyTemperatureSetting: false.

Тонкость, которую я нашел методом проб и ошибок, а не понял из документации. Если не указать в списке поддерживаемых режимов cool, то при попытки попросить определенную температуру, Assistant скажет “Actually, AC doesn’t support that functionality”. Поэтому обязательно availableThermostatModes: 'cool'.

Описать устройство необходимо в объекте класса SmartHomeV1SyncDevices. Документирован формат в описании ответа на запрос SYNC, нам сейчас интересна часть payload->devices. Что такое SYNC, я расскажу в одной из следующих статей.

Итак, получается:

import {SmartHomeV1SyncDevices} from "actions-on-google";

export const typeAC = 'action.devices.types.AC_UNIT';

export const traitOnOff = 'action.devices.traits.OnOff';
export const traitTemperatureSetting = 'action.devices.traits.TemperatureSetting';

export const acTrivial: SmartHomeV1SyncDevices = {
    id: "unknown",
    type: typeAC,
    traits: [
        traitOnOff,
        traitTemperatureSetting,
    ],
    name: {
        defaultNames: [],
        name: 'Some AC',
        nicknames: [],
    },
    deviceInfo: {
        manufacturer: 'Jolly Droid',
        model: 'ac-trivial',
        hwVersion: '1.0',
        swVersion: '1.0.1',
    },
    attributes: {
        // https://developers.google.com/actions/smarthome/traits/onoff
        commandOnlyOnOff: true,
        // https://developers.google.com/actions/smarthome/traits/temperaturesetting
        availableThermostatModes: 'cool',
        thermostatTemperatureUnit: 'C',
        commandOnlyTemperatureSetting: true,
        queryOnlyTemperatureSetting: false,
    },
    willReportState: false,
};

Домашние устройства

На базе шаблона для простейшего кондиционера я могу создать список реально существующих устройств.

Каждое устройство из этого списка появится в Google Home App, и может управляться независимо.

import {acTrivial} from "./devices";
import {SmartHomeV1SyncDevices} from "actions-on-google";

const libAcId = 'libAc';
const libAc: SmartHomeV1SyncDevices = {
    ...acTrivial,
    id: libAcId,
    name: {
        name: 'Library AC',
        defaultNames: ['Library AC'],
        nicknames: []
    }
};

const masterBedroomAcId = 'masterBedroomAc';
const masterBedroomAc: SmartHomeV1SyncDevices = {
    ...acTrivial,
    id: masterBedroomAcId,
    name: {
        name: 'Master Bedroom AC',
        defaultNames: ['Master Bedroom AC'],
        nicknames: []
    }
};

export const listOfHomeDevices = [
    libAc,
    masterBedroomAc
]

Обработка команд

В описании каждого trait есть список команд, которые ему могут быть отправлены. Некоторые команды возможны только при определенном наботе атрибутов, поэтому не нужно реализовывать сразу все.

Trait OnOff имеет одноименную команду OnOff, а для trait-а TemperatureSetting мы реализуем команды ThermostatTemperatureSetpoint и TemperatureRelative.

Вместе с каждой командой приходит список параметров, который уточняет, как именно команду нужно выполнить. Приходят команды в виде структур SmartHomeV1ExecuteRequestExecution, например:

{
  "command": "action.devices.commands.ThermostatTemperatureSetpoint",
  "params": {
    "thermostatTemperatureSetpoint": 25
  }
}

Для каждого устройства может прийти несколько разных команд.

В примере ниже каждая функция, обрабатывающая команду, возвращает значения стейтов, за которые она отвечает.
processCommands() обрабатывает команды одну за одной, собирает общий результат и возвращает значения стейтов.

Обратите внимание, что все стейты имеют уникальные имена и передаются всегда вместе, без деления на traits.

Список стейтов, полученный в результате выполнения команды, будет передан в Google Home, сохранен в нашей базе данных, и, как следствие, передан конечным исполнителям, непосредственно взаимодействующим с нашими устройствами. Как это происходит, я расскажу в следующих статьях.

import {SmartHomeV1ExecuteRequestExecution} from "actions-on-google/src/service/smarthome/api/v1";
import {ApiClientObjectMap} from "actions-on-google/src/common";

function commandOnOff(params: ApiClientObjectMap<any>): ApiClientObjectMap<any> {
    return {
        on: params.on
    };
}

function commandThermostatTemperatureSetpoint(params: ApiClientObjectMap<any>): ApiClientObjectMap<any> {
    // где-то здесь нужно проверить, что температура поддерживается устройством
    return {
        thermostatTemperatureSetpoint: params.thermostatTemperatureSetpoint,
        on: true
    };
}

function commandTemperatureRelative(params: ApiClientObjectMap<any>,
                                    state: ApiClientObjectMap<any>): ApiClientObjectMap<any> {

    let newTemperature = state["thermostatTemperatureSetpoint"];

    if (params.hasOwnProperty('thermostatTemperatureRelativeWeight')) {
        // 1 unit of weight will be equal to 1 degree
        newTemperature += params['thermostatTemperatureRelativeWeight'];
    } else if (params.hasOwnProperty('thermostatTemperatureRelativeDegree')) {
        newTemperature += params['thermostatTemperatureRelativeDegree'];
    } else {
        console.log(`Incorrect parameters for TemperatureRelative`);
    }

    return {
        thermostatTemperatureSetpoint: newTemperature,
        on: true
    };
}

export function processCommands(executions: SmartHomeV1ExecuteRequestExecution[],
                                state: ApiClientObjectMap<any>): ApiClientObjectMap<any> {
    let newState: ApiClientObjectMap<any> = state;
    let summary: ApiClientObjectMap<any> = {};

    for (const execution of executions) {
        const execCommand = execution.command;
        const params = execution.params;

        let update: ApiClientObjectMap<any> = {};

        switch (execution.command) {
            case 'action.devices.commands.OnOff':
                console.log(`OnOff to ${params.on}`);

                update = commandOnOff(params);
                break;

            case 'action.devices.commands.ThermostatTemperatureSetpoint':
                console.log(`ThermostatTemperatureSetpoint to ${params.thermostatTemperatureSetpoint}`);

                update = commandThermostatTemperatureSetpoint(params);
                break;

            case 'action.devices.commands.TemperatureRelative':
                console.log(`TemperatureRelative`);

                update = commandTemperatureRelative(params, newState);
                break;

            default:
                console.log(`Unknown execution ${execCommand}`);
        }

        newState = {...newState, ...update};
        summary = {...newState, ...update};
    }

    return summary;
}

Продолжение: Часть 2 - хранение состояний