Build your «Smart device» for Google Home

davidnadejdin

David

Posted on August 12, 2022

Build your «Smart device» for Google Home

Creating Google Home project

We will use Cloud-to-cloud integration method. So we need to navigate to Actions on Google console and create a new project:
Image description
Image description
Image description
And we need to name our Smart Home Action:
Image description
Image description

Set up OAuth2 and backend server

Cloud-to-cloud integration required OAuth2 server for linking your smart device service to Google Home application. In this article, we will implement it with Laravel framework and Passport package. You should pass base steps, like Laravel and Passport installation, database setup, and make fake users or you can find example code here. All additional actions will be described next.

Account linking

Move to your Laravel project and run the command to generate OAuth Client Information for Google Home.

$ php artisan passport:client

Which user ID should the client be assigned to?:
> 1

What should we name the client?:
> Google

Where should we redirect the request after authorization?:
> https://oauth-redirect.googleusercontent.com/r/{your project id}

New client created successfully.
Client ID: 9700039b-92b7-4a79-a421-152747b9a257
Client secret: 813PEwdTAq7kf7vRXuyd75dJEaSzAIZ1GDWjIyRM
Enter fullscreen mode Exit fullscreen mode

Pass received data and oauth2 endpoints to Account Linking settings in your project:
Image description

Implement backend

To notify Google Home about our device status, we need to return their data when Google Home requested data by Fulfillment URL. Google Home sends data in 3 types: SYNC, QUERY, and EXECUTE.

Response for sync request will return a list of all devices and their capabilities:

# Request example
{
    "requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
    "inputs": [{
      "intent": "action.devices.SYNC"
    }]
}
Enter fullscreen mode Exit fullscreen mode
# Response example
{
    "requestId": "6894439706274654512",
    "payload": {
        "agentUserId": "user123",
        "devices": [
            {
                "id": 1,
                "type": "action.devices.types.THERMOSTAT",
                "traits": [
                    "action.devices.traits.TemperatureSetting"
                ],
                "name": {
                    "name": "Thermostat"
                },
                "willReportState": true,
                "attributes": {
                    "availableThermostatModes": [
                        "off",
                        "heat",
                        "cool"
                    ],
                    "thermostatTemperatureRange": {
                        "minThresholdCelsius": 18,
                        "maxThresholdCelsius": 30
                    },
                    "thermostatTemperatureUnit": "C"
                },
                "deviceInfo": {
                    "manufacturer": "smart-home-inc"
                }
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Resposne for query request must include a full set of states for each of the traits supported by the requested devices:

# Request example
{
    "requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
    "inputs": [{
        "intent": "action.devices.QUERY",
        "payload": {
            "devices": [{
                "id": "1"
            }]
        }
    }]
}
Enter fullscreen mode Exit fullscreen mode
# Response example
{
    "requestId": "6894439706274654514",
    "payload": {
        "agentUserId": "user123",
        "devices": {
            "1": {
                "status": "SUCCESS",
                "online": true,
                "thermostatMode": "cool",
                "thermostatTemperatureSetpoint": 23,
                "thermostatTemperatureAmbient": 10,
                "thermostatHumidityAmbient": 10
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Execute request contain data as same as Query but can contain commands given to a group of devices(response is same as Query):

# request example
{
    "inputs": [
        {
            "context": {
                "locale_country": "US",
                "locale_language": "en"
            },
            "intent": "action.devices.EXECUTE",
            "payload": {
                "commands": [
                    {
                        "devices": [
                            {
                                "id": "1"
                            }
                        ],
                        "execution": [
                            {
                                "command": "action.devices.commands.ThermostatTemperatureSetpoint",
                                "params": {
                                    "thermostatTemperatureSetpoint": 25.5
                                }
                            }
                        ]
                    }
                ]
            }
        }
    ],
    "requestId": "15039538743185198388"
}
Enter fullscreen mode Exit fullscreen mode

Understanding device object

A device object on Sync request must contain information about a device like its name, device info, including traits, and attributes based on included trains:

# Device object on sync request
{
    "id": 1,
    "type": "action.devices.types.THERMOSTAT",
    "traits": [
        "action.devices.traits.TemperatureSetting"
    ],
    "name": {
        "name": "Thermostat"
    },
    "willReportState": true,
    "attributes": {
        "availableThermostatModes": [
            "off",
            "heat",
            "cool"
        ],
        "thermostatTemperatureRange": {
            "minThresholdCelsius": 18,
            "maxThresholdCelsius": 30
        },
        "thermostatTemperatureUnit": "C"
    },
    "deviceInfo": {
        "manufacturer": "smart-home-inc"
    }
}
Enter fullscreen mode Exit fullscreen mode

And must contain a device state on Quest or Execute:

# Device object on Query or Execute request
{
    "status": "SUCCESS",
    "online": true,
    "thermostatMode": "cool",
    "thermostatTemperatureSetpoint": 24,
    "thermostatTemperatureAmbient": 10,
    "thermostatHumidityAmbient": 10
}
Enter fullscreen mode Exit fullscreen mode

Return and save device state

Move to your Laravel project and create a Thermostat model:

php artisan make:model Thermostat -m
Enter fullscreen mode Exit fullscreen mode
# database/migrations/2022_08_11_154357_create_thermostats_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('thermostats', function (Blueprint $table) {
            $table->id();
            $table->boolean('online')->default(false);
            $table->string('mode');
            $table->unsignedInteger('current_temperature')->default(0);
            $table->unsignedInteger('expected_temperature')->default(15);
            $table->unsignedInteger('humidity')->default(0);
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('thermostats');
    }
};
Enter fullscreen mode Exit fullscreen mode
# app/Models/Thermostate.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Thermostat extends Model
{
    protected $fillable = [
        'online',
        'mode',
        'current_temperature',
        'expected_temperature',
        'humidity',
    ];

    protected $casts = [
        'online' => 'boolean',
    ];
}
Enter fullscreen mode Exit fullscreen mode
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Implement Fulfillment URL route

# routes/api.php

<?php

use App\Http\Controllers\FulfillmentController;
use Illuminate\Support\Facades\Route;

Route::post('/', FulfillmentController::class);
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Http\Controllers;

use App\Models\Thermostat;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;

class FulfillmentController extends Controller
{
    public function __invoke(Request $request)
    {
        $response = null;

        // Extract request type
        switch ($request->input('inputs.0.intent')) {
            case 'action.devices.QUERY':
                $response = $this->queryResponse();
                break;
            case 'action.devices.SYNC':
                $response = $this->syncRequest();
                break;
            case 'action.devices.EXECUTE':
                $response = $this->syncExecute($this->syncExecute($request->input('inputs.0.payload.commands'))); // Extract list of commands
                break;
        }

        return $response;
    }

    private function queryResponse()
    {
        $devices = [];

        // Extract our devices states
        foreach (Thermostat::all() as $thermostat) {
            $devices[$thermostat->id] = [
                'status' => 'SUCCESS',
                'online' => $thermostat->online,
                'thermostatMode' => $thermostat->mode,
                'thermostatTemperatureSetpoint' => $thermostat->expected_temperature,
                'thermostatTemperatureAmbient' => $thermostat->current_temperature,
                'thermostatHumidityAmbient' => $thermostat->humidity,
            ];
        }

        return response([
            'requestId' => "6894439706274654514",
            'payload' => [
                "agentUserId" => "user123",
                'devices' => $devices,
            ],
        ]);
    }

    private function syncRequest()
    {
        $devices = [];

        // Define our devices
        foreach (Thermostat::all() as $thermostat) {
            $devices[] = [
                'id' => $thermostat->id,
                'type' => "action.devices.types.THERMOSTAT",
                'traits' => [
                    "action.devices.traits.TemperatureSetting"
                ],
                'name' => [
                    'name' => 'Thermostat'
                ],
                'willReportState' => true,
                'attributes' => [
                    'availableThermostatModes' => [
                        'off',
                        'heat',
                        'cool',
                    ],
                    'thermostatTemperatureRange' => [
                        'minThresholdCelsius' => 18,
                        'maxThresholdCelsius' => 30,
                    ],
                    'thermostatTemperatureUnit' => 'C'
                ],
                'deviceInfo' => [
                    'manufacturer' => 'smart-home-inc',
                ],
            ];
        }

        return response([
            'requestId' => "6894439706274654512",
            'payload' => [
                "agentUserId" => "user123",
                'devices' => $devices,
            ],
        ]);
    }

    private function syncExecute(array $commands)
    {
        foreach ($commands as $command) {
            // Get devices for execute command
            $thermostats = Thermostat::whereIn('id', Arr::pluck($command['devices'], 'id'))->get();

            foreach ($command['execution'] as $executionItem) {
                switch ($executionItem['command']) {
                    // Handle set point command and save it in our model
                    case 'action.devices.commands.ThermostatTemperatureSetpoint':
                        foreach ($thermostats as $thermostat) {
                            $thermostat->update([
                                'expected_temperature' => $executionItem['params']['thermostatTemperatureSetpoint'],
                            ]);
                        }
                        break;
                    // Handle set set mode command and save it in our model
                    case 'action.devices.commands.ThermostatSetMode':
                        foreach ($thermostats as $thermostat) {
                            $thermostat->update([
                                'mode' => $executionItem['params']['thermostatMode'],
                            ]);
                        }
                        break;
                }
            }
        }

        // It not necessary to return data for command request
        return response([]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Return to Console actions and set your Fulfillment URL in your project settings:
Image description
And create your device in your database:
Image description

Simplify the authentication process:

Since we are not interested in authentication details we can skip the implement login page and force the authenticated user:

# app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        //
    }

    public function boot()
    {
        Auth::setUser(User::first());
    }
}
Enter fullscreen mode Exit fullscreen mode

Link backend with Google Home

After previous actions, you can test your backend with Google Home app which is authenticated as the same account used for creating Actions Console project.
Image description
After linking your device will appear in Google Home:
Image description
Now you can control your "device" and its state will be saved on the database. You can trigger sync(reconnect account), query(swipe update gesture), and execute(try to change mode).

You can find more about available device types and traits Here

💖 💪 🙅 🚩
davidnadejdin
David

Posted on August 12, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related