Build a smart home service for the Actions on Google Client Library v2

Nick Felker
Google Developers
Published in
7 min readJun 21, 2018

The newly released version of the Node.js client library for Actions on Google is designed to be more modular, making it easy to add third-party plugins and support a variety of services. A service in the client library v2 represents a formal contract for a webhook which ingests a JSON payload, processes the request, and responds with a JSON response representing the answer to what was requested.

The extensible design of this library allows developers to build their own services. Currently, the library contains services for Actions SDK, Dialogflow, and smart home.

If you are building a smart home integration with Node.js, you can use the new smart home service. This provides an easier developer surface for developers who want to control their IoT devices through the Google Assistant. You can give commands like “Turn on my lights” as well as more specific commands, such as “Dim the lights a little”. The Google Assistant will communicate with your webhook to send the command or check the device’s current state.

In this post, I’ll explain how to build a new service for the v2 client library. If you’re building your own service which will support Actions on Google, this guide can help you get started and make sense of the new architecture. Building your own service would allow you to handle your own natural language processing system while maintaining a clean API.

Both this service and the client library are written in TypeScript. TypeScript is useful when dealing with a webhook that receives and responds with JSON. Because these objects can be strongly typed, you will get warnings earlier if your responses don’t fit the expected schema. It can be compiled into JavaScript, so developers can use this service in either language.

Basic service architecture

Each service starts with a handful of simple interfaces which extend from common interfaces: AppOptions, ServiceBaseApp, and AppHandler. Below is the simplest implementation of a service:

import { AppOptions, AppHandler, ServiceBaseApp, attach } from ‘../../assistant’
import { JsonObject } from ‘../../common’
// Options which are used across intents, like API keys
export interface SimpleOptions extends AppOptions {}
// A collection of methods and properties
export interface SimpleApp extends ServiceBaseApp {
intents: SimpleIntentHandlers
}
// Maps each intent to a particular response
export interface SimpleIntentHandler {
(body: JsonObject): JsonObject | Promise<JsonObject>
}
export interface SimpleIntentHandlers {
[intent: string]: SimpleIntentHandler
}
export interface Simple {
(options?: SimpleOptions): AppHandler & SimpleApp
}
// Implementation of the SimpleApp interface
export const simpleapp: Simple = (options?) => attach<SimpleApp>({
intents: {},
async handler(
this: SimpleApp,
body: JsonObject,
headers,
) {
const intent: string = body.intent
const handler = this.intents[intent]
return {
// Executes the defined function for the given intent
body: await handler(body),
status: 500,
}
},
})

SimpleOptions can contain options that are valid throughout all usages of your response, such as an API key. The debug key is always available, so you can write debug logs from your server when specified by the developer.

SimpleApp is the interface that will be used to hold all of our methods and properties. Right now, it just contains a map of intents to handlers.

The SimpleIntentHandlers will be used to map each intent we have to a particular response. This response can be a simple JSON object or a Promise containing a JSON object.

At the bottom of the code snippet, we export an implementation of the SimpleApp interface. This is where we implement each property and method defined above. It initializes intents as an empty object and has the handler method.

This handler method is important because it’s used to return a response from the webhook. In this basic example, it gets the intent from the body of the HTTP request. It then finds the defined function to run from the map of intents. Finally, it runs the handler and uses the handler in the body of the response.

Now that we understand how to build a service, let’s implement it for smart home.

Build a smart home service

Smart home integrations can make API calls to request a sync or report the current state. To do this, developers need to get an API key for the home graph API using the Google Cloud Console. This API key can go into the options interface, as shown in the code snippet below:

export interface SmartHomeOptions extends AppOptions {
key?: string
// …
}

This API key is a string, and we can add the question mark to make it optional. An API key is not used for fulfillment, but is required if we want to make a Request Sync API call.

Unlike conversational Actions, smart home integrations only have three possible intents: SYNC, QUERY, and EXECUTE. For each intent, we create a corresponding method with a handler. First, we need to add these to our interface, as shown in the code snippet below:

export interface SmartHomeApp extends ServiceBaseApp {
_intents: SmartHomeHandlers
onSync(handler: SmartHomeHandler<JsonObject, JsonObject>): this
onQuery(handler: SmartHomeHandler<JsonObject, JsonObject>): this
onExecute(handler: SmartHomeHandler<JsonObject, JsonObject>): this
_intent(intent: Api.SmartHomeV1Intents, handler: smartHomeHandler<JsonObject, JsonObject>): this
// …
}

Next, we implement each method, which just stores the function into our map until it is called by the handler. You will notice that the type of intent is not a string anymore. A separate type is used that only accepts three specific strings. TypeScript’s compiler will catch any typos. In the code snippet below, you can see the implementation of the SmartHomeApp interface:

export const smarthome: SmartHome = (options?) => attach<SmartHomeApp>({
_intents: {},
_intent(this: SmartHomeApp, intent, handler) {
this.intents[intent] = handler
return this
},
onSync(this: SmartHomeApp, handler) {
return this.intent(‘actions.devices.SYNC’, handler)
},
onQuery(this: SmartHomeApp, handler) {
return this.intent(‘actions.devices.QUERY’, handler)
},
onExecute(this: SmartHomeApp, handler) {
return this.intent(‘actions.devices.EXECUTE’, handler)
},
async handler(
this: SmartHomeApp,
body: Api.SmartHomeV1Request,
headers,
) {
const { intent }: SmartHomeIntent = body.inputs[0]
const handler = this._intents[intent]

return {
body: await handler(body),
headers: {},
status: 200,
}
},
})

We can also add a method for Request Sync, which will take the key that is defined in our options and make the API call to trigger a SYNC request:

async requestSync(this: SmartHomeApp, agentUserId) {
if (!options || !this.key) {
throw new Error(`An API key was not specified. ` +
`Please visit https://console.cloud.google.com/apis/api/homegraph.googleapis.com/overview`)
}

return await makeApiCall(`/v1/devices:requestSync?key=${encodeURIComponent(this.key)}`, {
agent_user_id: agentUserId,
})
}

At this point, our service is ready. In just a few short steps, we wrapped the smart home APIs into an easy-to-use API for developers.

Account linking process between the Google Assistant and partner cloud service

When the user first links their account between the Assistant and this smart home fulfillment, a SYNC intent will be sent to the webhook, which calls the app.onSync method with a sync request payload. When the user says “dim the light a little”, the Assistant first needs to send a QUERY intent to get the current light’s brightness. The app.onQuery method will be called with a query request payload. Then, the Assistant will reduce the brightness by a few points and send an EXECUTE intent to change the light’s brightness. The app.onExecute method will be called with an execute request payload.

Here’s a short snippet of how it would work:

const app = smarthome({
key: '12F…',
debug: true,
})
app.onSync(body => {
return {
"requestId": "ff36a3cc-ec34–11e6-b1a0–64510650abcf",
"payload": {
"agentUserId": "1836.15267389",
"devices": [{
"id": "123",
"type": "action.devices.types.OUTLET",
"traits": [
"action.devices.traits.OnOff"
],
"name": {
"defaultNames": ["My Outlet 1234"],
"name": "Night light",
"nicknames": ["wall plug"]
},
"willReportState": false,
"roomHint": "kitchen",
"deviceInfo": {
"manufacturer": "lights-out-inc",
"model": "hs1234",
"hwVersion": "3.2",
"swVersion": "11.4"
},
"customData": {
"fooValue": 74,
"barValue": true,
"bazValue": "foo"
}
}]
}
}
})
app.onQuery(body => {
return {
"requestId": "ff36a3cc-ec34–11e6-b1a0–64510650abcf",
"payload": {
"devices": {
"123": {
"on": true,
"online": true
},
}
}
}
})
app.onExecute(body => {
return {
"requestId": "ff36a3cc-ec34–11e6-b1a0–64510650abcf",
"payload": {
"commands": [{
"ids": ["123"],
"status": "SUCCESS",
"states": {
"on": true,
"online": true
}
}]
}
}
})
exports.smarthome = app

Types

At this stage, the requests and responses are not strongly typed. This means that your response can be malformed, and that may not be clear until you deploy your integration and try testing it. For example, you may accidentally omit a required field. In the example below, we will be alerted to a malformed response because we are using a number instead of a string for the id field, and we are missing the required device type field:

app.onSync(body => {
return {
"requestId": "ff36a3cc-ec34–11e6-b1a0–64510650abcf",
"payload": {
"agentUserId": "1836.15267389",
"devices": [{
"id": 123,
"traits": [
"action.devices.traits.OnOff"
],
"name": {
"defaultNames": ["My Outlet 1234"],
"name": "Night light",
"nicknames": ["wall plug"]
},
"willReportState": false,
"roomHint": "kitchen",
"deviceInfo": {
"manufacturer": "lights-out-inc",
"model": "hs1234",
"hwVersion": "3.2",
"swVersion": "11.4"
},
"customData": {
"fooValue": 74,
"barValue": true,
"bazValue": "foo"
}
}]
}
}
})

Errors will state something like “Types of property ‘id’ are incompatible. Type ‘number’ is not assignable to type ‘string’” and “Property ‘type’ is missing in type”. These checks give us greater confidence that our webhook response is valid.

We can take advantage of TypeScript’s explicit typing in order to check our response earlier, as shown in the following code:

export interface SmartHomeIntentHandler {
(body: Api.SmartHomeV1Request): Api.SmartHomeV1Response | Promise<Api.SmartHomeV1Response>
}

Instead of taking in a JsonObject and returning a JsonObject, the handler takes in a SmartHomeV1Request type and responds with a SmartHomeV1Response type. This response type is a union of the response for each intent, one of the three types in the set:

export type SmartHomeV1Response = SmartHomeV1SyncResponse | SmartHomeV1QueryResponse | SmartHomeV1ExecuteResponse

The creation of all of the types and subtypes takes a bit more work, but, at the end, we have the following well-defined schema:

export interface SmartHomeV1SyncName {
defaultNames: string[],
name: string,
nicknames: string[]
}
export interface SmartHomeV1SyncDeviceInfo {
manufacturer: string,
model: string,
hwVersion: string,
swVersion: string
}
export interface SmartHomeV1SyncDevices {
id: string,
type: string,
traits: string[],
name: SmartHomeV1SyncName,
willReportState: boolean,
deviceInfo?: SmartHomeV1SyncDeviceInfo,
attributes?: ApiClientObjectMap<any>,
customData?: ApiClientObjectMap<any>
}
export interface SmartHomeV1SyncPayload {
agentUserId: string,
errorCode?: string,
debugString?: string,
devices: SmartHomeV1SyncDevices[]
}
export interface SmartHomeV1SyncResponse {
requestId: string,
payload: SmartHomeV1SyncPayload
}
// …

You can view the full source code for this service on our GitHub repository. If you want to use this smart home service, you can get started by installing the Actions on Google module and importing the smart home service.

--

--

Nick Felker
Google Developers

Social Media Expert -- Rowan University 2017 -- IoT & Assistant @ Google