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

В этой части я расскажу, как осуществляется хранение состояния (стейтов) управляемых устройств в Firebase Realtime Database, и как управлять устройствами на основе этих данных.

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

Firebase Realtime Database как persistant storage и как средство связи

Мне нужен persistent storage для хранения состояния устройств. Я использую Firebase Realtime Database. В базе данных я буду хранить текущее (оно же желаемое) состояние для каждого устройства, и менять его по камандам от Google Home.

Также я использую Firebase Realtime Database как интерфейс между “облачной” частью моего дома и конечными исполнителями команд. Исполнители, которые должны отправлять команды устройствам (помним, что наши устройства - это управляемые по IR кодниционеры, телевизоры и другие домашние гаджеты?), будут подписываться на обновление этой базы данных, и при изменении отправлять устройству соответствующую команду.

О том, как подписываться на обновлениями, я писал в предыдущей статье этого цикла, здесь мы этот код расширим.

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

Access to DB

Поскольку Firebase Realtime Database находится в том же Firebase проекте, что и Functions, никакие дополнительные ключи или токены нам не нужны.

import * as admin from 'firebase-admin'

admin.initializeApp();
const firebaseRef = admin.database().ref('/');

TypeScript для меня новый язык, иногда он удивляет и радует. В данном случае, сменив импорт с JavaScript-style const admin = require('firebase-admin'); на TypeScript-style, который выше, я получил много подсказок о неправильной обработке Promise.

Структура

Поскольку единственное, что сейчас нужно хранить, это список стейтов, и стейты одного девайса могут храниться вместе, я так и сделаю. В лабе предлагается хранить стейты каждого trait отдельно, но я в этом никакой пользы не вижу.

Дальше код расскажет все сам.

import {ApiClientObjectMap} from "actions-on-google/src/common";

export async function getDeviceState(deviceId: string): Promise<ApiClientObjectMap<any>> {
    return firebaseRef
        .child(deviceId)
        .once('value')
        .then((state) => {
            return state.val();
        });
}

export async function saveDeviceState(deviceId: string,
                               newState: ApiClientObjectMap<any>) {
    await firebaseRef
        .child(deviceId)
        .update({
            ...newState,
            ...{lastChange: new Date().getTime()}
        });
}

export async function updateDeviceState(deviceId: string,
                                        update: ApiClientObjectMap<any>) {
    await saveDeviceState(deviceId,
        {
            ...await getDeviceState(deviceId),
            ...update
        });
}

Подписка на изменения

Обратите внимание на поле lastChange, которое изменяется в функции saveDeviceState(). Наш сервер будет записывать новое состояние стейтов каждый раз при получении команды из Google Home. И одновременно с этим всегда будет обновляться поле lastChange.

Это нужно для того, чтобы исполнитель команд мог увидеть необходимость что-то сделать, даже если желаемый статус устройства не изменился. Например, по какой-то внешней причине кондиционер оказался включенным, хотя по состоянию в базе данных он выключен. Пользователь может несколько раз подряд просить Google Assistant выключить кондиционер, и наш сервер несколько раз подряд получит одну и ту же команду. Но, поскольку в базе данных уже указано, что кондиционер выключен, никакого заметного обновления данных не произойдет. И если исполнитель будет ориентироваться только на желаемое состояние устройства в базе, он не увидит необходимость повторно выключить кондиционер. Чтобы избежать это, исполнитель будет ориентироваться на смену lastChange, и при изменении пошлет команду, соотсутствующую желаемому состоянию еще раз.

Как исполнитель может подписаться на измененения в Firebase Realtime Database, я рассказывал в предыдущей статье. Теперь, когда формат данных известен, эту историю можно продолжить:

def action(old, new):
    if new['lastChange'] > old['lastChange']:
        if not new['on']:
            acTurnOff()
        else:
            acTemperature(new['thermostatTemperatureSetpoint'])

Еще раз обратите внимание, что константы-ключи on и thermostatTemperatureSetpoint - это имена стейтов соответствующих trait-ов.

Отправка команд по infrared тоже была мной описана в одной из предыдущих статей, теперь можно добавить и ее:

def acTurnOff():
    print('turn off')
    subprocess.call(["irsend", "send_once", "DOWN_AC_RAW", "OFF"])
    time.sleep(2)
    subprocess.call(["irsend", "send_once", "DOWN_AC_RAW", "OFF"])


def acTemperature(temp):
    print('temperature: {}'.format(temp))
    if temp < 18:
        temp = 18
    if temp > 30:
        temp = 30
    subprocess.call(["irsend", "send_once", "DOWN_AC_RAW", "SET_{}".format(temp)])
    time.sleep(2)
    subprocess.call(["irsend", "send_once", "DOWN_AC_RAW", "SET_{}".format(temp)])

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

Продолжение: Часть 3 - создание проекта в Google Assistant