GVA (Google Voice Assistant)
What is GVA
GVA is the Google Smart Home integration that allows users to control their RainMaker devices via Google Assistant. It implements the Google Smart Home API fulfillment webhook, handling device discovery, state queries, and device control commands.
Why it is needed
To enable voice control and Google Home app integration for RainMaker devices. Users link their RainMaker account with Google via OAuth against the ESP User OIDC identity provider and can then say things like “Hey Google, turn on the light” to control their devices.
Pre-requisites
User is registered and authenticated
GVA configuration
Architecture
Two Lambda functions serve the GVA integration:
gva_action- Handles Smart Home fulfillment (SYNC, QUERY, EXECUTE, DISCONNECT intents)gva_cfg- Handles GVA configuration management (store/get/delete credentials)
Configuration
OIDC VA Client
Account linking runs against the ESP User OIDC identity provider, not Cognito. A confidential OAuth 2.1 client (va-client) is seeded into the ESP User client registry (espuser-oauth-clients) for voice-assistant account linking:
OAuth flow: Authorization Code Grant (
grant_types:authorization_code,refresh_token)Scopes:
openid,email,phone,profileRedirect URIs: registered dynamically by the config API (there are no hardcoded initial values); each POST unions its URIs onto the client’s existing set, so the Google and Alexa redirect URIs coexist on the shared
va-clientrowClient ID:
va-client— the registry client id (also available in SSM/espuser/base/va-client-id)Client Secret: the generated secret for
va-client, retrievable via the superadmin clients API (GET /v1/admin/clients?get_secret=true) or SSM/espuser/base/va-client-secretOIDC endpoints: the
authorization_endpointandtoken_endpointpublished in the discovery document (/.well-known/openid-configuration). Both are served on the ESP User API Gateway base (EspUserApiUrl):<api-url>/oauth2/authorizeand<api-url>/oauth2/token.
Google Console Setup
This must be done before calling the Store Configuration API.
Step 1: Create Google Cloud Project
Go to Google Cloud Console
Create a new project (or use an existing one)
Note the Project ID (e.g.,
your-project-id) — this is theproject_idfor the config API
Step 2: Create Google Home Project
Create a new project, link it to the Google Cloud project from Step 1
Add a Cloud-to-cloud integration
Under Develop > Setup:
OAuth Client ID:
va-client(the OIDC client id; SSM/espuser/base/va-client-id)OAuth Client Secret: the
va-clientsecret from the superadmin clients API (GET /v1/admin/clients?get_secret=true) or SSM/espuser/base/va-client-secretAuthorization URL: the discovery document’s
authorization_endpoint(<api-url>/oauth2/authorize)Token URL: the discovery document’s
token_endpoint(<api-url>/oauth2/token)Fulfillment URL:
https://<api_gateway_url>/v1/integrations/gvawhere api_gateway_url is from rmng-outputs.json (ApiGatewayUrl)Scopes:
openid,email,phone,profile
Step 3: Enable HomeGraph API (for Report State)
Go to https://console.cloud.google.com/apis/library/homegraph.googleapis.com
On the drop-down menu at the top, select your smart home project created in previous steps
Click Enable
Step 4: Create Service Account (for Report State)
A dedicated service account must be created for Report State. Do NOT reuse the Firebase Admin SDK default service account (firebase-adminsdk-*) — it can authenticate but cannot write to the Home Graph.
Go to Google Service Account page: https://console.cloud.google.com/projectselector2/iam-admin/serviceaccounts?supportedpurview=project
Select your smart home project created in the previous step
Click Create Service Account
In the Service Account Name field, enter a suitable name (e.g.,
homegraph-agent)In Description, enter “Service Account for report state token creation”
Click Create and Continue
In the next step, select the role type Service Accounts and select the role Service Account OpenID Connect Identity Token Creator
Click Continue and then click Done
Generate a JSON key:
Click the created service account email
Go to the Keys tab and click Add Key
Select Create new key
Select JSON key type
A file will be downloaded with the required credentials
This JSON is uploaded via the Store Configuration API (see below). The notification Lambda uses it to obtain OAuth2 tokens with scope https://www.googleapis.com/auth/homegraph for calling the Report State API.
GVA config
Store Configuration
The configuration is stored by calling the admin configuration API with the service account JSON key file downloaded from the Google Cloud Console (see Step 4 above) as the request body. The API validates the payload, registers the Google redirect URI on the OIDC va-client registry row (unioning it onto the client’s existing set), and persists the JSON to SSM (/rmng/gva/service_account_json).
API: POST /v1/admin/integrations/gva/configuration
Request: The entire service account JSON is sent as the request body.
{
"type": "service_account",
"project_id": "<your-project-id>",
"private_key_id": "<key-id>",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "<name>@<project-id>.iam.gserviceaccount.com",
"client_id": "<client-id>",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/<encoded-email>",
"universe_domain": "googleapis.com"
}
Validation: project_id, client_email, and private_key must be present.
Process:
Validate required fields (
project_id,client_email,private_key)Calculate redirect URI:
https://oauth-redirect.googleusercontent.com/r/<project_id>Register the redirect URI on the OIDC
va-clientregistry row, unioning it onto its existing set (env var:OIDC_VA_CLIENT_ID, valueva-client)Store the entire service account JSON as a single SSM parameter (
/rmng/gva/service_account_json)
Response:
{
"message": "GVA client configuration stored successfully",
"project_id": "my-project-id",
"redirect_uris": ["https://oauth-redirect.googleusercontent.com/r/my-project-id"]
}
Get Configuration
API: GET /v1/admin/integrations/gva/configuration
Process:
Retrieve the service account JSON from SSM (
/rmng/gva/service_account_json)Calculate redirect URI from
project_idReturn the full service account JSON with
redirect_urisappended
Response:
{
"type": "service_account",
"project_id": "<your-project-id>",
"private_key_id": "<key-id>",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "<name>@<project-id>.iam.gserviceaccount.com",
"client_id": "<client-id>",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/<encoded-email>",
"universe_domain": "googleapis.com",
"redirect_uris": ["https://oauth-redirect.googleusercontent.com/r/my-project-id"]
}
Delete Configuration
API: DELETE /v1/admin/integrations/gva/configuration
Process:
Delete the service account JSON from SSM (
/rmng/gva/service_account_json)
Response:
{
"message": "GVA client configuration deleted successfully"
}
Smart Home Intents
Authentication
Google sends an ESP User access token (issued to the voice-assistant client at account linking) in the Authorization: Bearer <token> header. The Lambda extracts the user ID using this token to identify the user. No API Gateway authorizer is used — the POST /v1/integrations/gva endpoint has authorization_type: NONE since Google sends the token in the request body/headers and the Lambda validates it directly.
Device ID Format
Devices are identified as <node_id>.<device_name> (e.g., ABC123.Light1). This composite ID maps a Google device back to a specific RainMaker node and device within that node.
Custom Data
During SYNC, each device carries customData containing:
groupID— the group the node belongs to (for permission loading)paramMap_<Trait>— maps GVA traits to RainMaker parameter names (e.g.,paramMap_OnOff: "Power")
This custom data is sent back by Google on QUERY and EXECUTE requests, avoiding additional lookups.
All intents are served by a single webhook endpoint.
API: POST /v1/integrations/gva
Authorization: Bearer token in Authorization header (ESP User access token)
The Lambda routes requests based on inputs[0].intent:
Intent |
Handled as |
|---|---|
|
Account unlinking |
|
Device control |
|
State queries |
|
Device discovery |
SYNC (Device Discovery)
Google sends SYNC when the user links their account or requests a device refresh.
Request:
{
"requestId": "uuid",
"inputs": [{ "intent": "action.devices.SYNC", "payload": {} }]
}
Process:
Get user ID from the ESP User access token
List all groups for the user
For each group, iterate over
nodeIDsFor each node, fetch its node configuration
For each device in the node config:
Generate device ID:
<node_id>.<device_name>Map RainMaker device type to GVA device type
Determine traits and attributes from device parameters
Build
customDatawith group ID and parameter mappings
Response:
{
"requestId": "uuid",
"payload": {
"agentUserId": "user-id",
"devices": [
{
"id": "node123.Light1",
"type": "action.devices.types.LIGHT",
"traits": ["action.devices.traits.OnOff", "action.devices.traits.Brightness"],
"name": { "name": "Light1" },
"willReportState": true,
"deviceInfo": {
"manufacturer": "ESP32",
"model": "RainMaker Device",
"swVersion": "1.1.0"
},
"attributes": {},
"customData": {
"groupID": "abc123",
"paramMap_OnOff": "Power",
"paramMap_Brightness": "Brightness"
}
}
]
}
}
Device Type Mapping
RainMaker Type |
GVA Type |
|---|---|
|
|
|
|
|
|
|
|
|
|
Unknown / nil |
|
Trait Mapping
RainMaker Param Type |
GVA Trait |
GVA Command |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Trait Attributes
ColorSetting:
{ "colorModel": "hsv" }FanSpeed: Ordered speed names:
low(slow),medium(mid),high(fast)TemperatureSetting: Modes:
heat,cool,heatcool,auto,off. Unit: Celsius
Sequence Diagram:
sequenceDiagram
title GVA SYNC - Device Discovery
participant Google as Google Assistant
participant Lambda as gva_action Lambda
participant ConfigSvc as ConfigService
participant GroupDB as DynamoDB (groups)
participant UserGroupDB as DynamoDB (rmng-user-group-assoc)
Google->>Lambda: POST /v1/integrations/gva<br/>{intent: SYNC, Bearer token}
activate Lambda
Lambda->>Lambda: Extract user ID from token
Lambda->>UserGroupDB: List groups for user
UserGroupDB->>Lambda: groups with node_ids
loop For each node in each group
Lambda->>ConfigSvc: Get node config (node_id)
ConfigSvc->>Lambda: NodeCfg (devices, params)
Lambda->>Lambda: Map devices to GVA types/traits<br/>Build customData with paramMap
end
Lambda->>Google: {agentUserId, devices[]}
deactivate Lambda
QUERY (State Query)
Google sends QUERY to get current device state.
Request:
{
"requestId": "uuid",
"inputs": [{
"intent": "action.devices.QUERY",
"payload": {
"devices": [
{ "id": "node123.Light1", "customData": { "groupID": "abc123", "paramMap_OnOff": "Power" } }
]
}
}]
}
Process:
Get user ID from the ESP User access token
For each queried device:
Parse device ID into
node_idanddevice_nameLoad node permissions using
groupIDfromcustomDataGet device parameters from the IoT shadow
Convert parameter values to GVA state format using trait parameter mappings from
customData
State Conversion:
Trait |
RainMaker Param |
GVA State Key |
Conversion |
|---|---|---|---|
Brightness |
Brightness (int) |
|
Direct integer (0-100) |
ColorSetting |
Hue, Saturation |
|
Hue direct, saturation/value divided by 100 |
FanSpeed |
Speed (int) |
|
Percentage + named setting (<=33: low, <=66: medium, >66: high) |
OnOff |
Power (bool) |
|
Direct boolean |
TemperatureSetting |
Temperature (float) |
|
Direct float, mode defaults to “heat” |
Response:
{
"requestId": "uuid",
"payload": {
"devices": {
"node123.Light1": {
"status": "SUCCESS",
"online": true,
"on": true,
"brightness": 75
}
}
}
}
Error Response (device not found):
{
"requestId": "uuid",
"payload": {
"devices": {
"node123.Light1": {
"status": "ERROR",
"errorCode": "deviceNotFound"
}
}
}
}
Sequence Diagram:
sequenceDiagram
title GVA QUERY - State Query
participant Google as Google Assistant
participant Lambda as gva_action Lambda
participant IoT as AWS IoT Shadow
Google->>Lambda: POST /v1/integrations/gva<br/>{intent: QUERY, devices[], Bearer token}
activate Lambda
Lambda->>Lambda: Extract user ID from token
loop For each device
Lambda->>Lambda: Parse device ID (node_id.device_name)
Lambda->>Lambda: Load node permissions (groupID from customData)
Lambda->>IoT: Get device params from shadow
IoT->>Lambda: Device parameters
Lambda->>Lambda: Convert params to GVA state<br/>using paramMap from customData
end
Lambda->>Google: {devices: {device_id: {status, state}}}
deactivate Lambda
EXECUTE (Device Control)
Google sends EXECUTE when the user issues a voice command.
Request:
{
"requestId": "uuid",
"inputs": [{
"intent": "action.devices.EXECUTE",
"payload": {
"commands": [{
"devices": [{ "id": "node123.Light1", "customData": { "groupID": "abc123", "paramMap_OnOff": "Power" } }],
"execution": [{ "command": "action.devices.commands.OnOff", "params": { "on": true } }]
}]
}
}]
}
Process:
Extract user ID from access token
For each command, for each device, for each execution:
Parse device ID into
node_idanddevice_nameLoad node permissions using
groupIDfromcustomDataRoute to command handler based on
execution.commandMap GVA params to RainMaker params using
customDataparameter mappingsPublish the mapped values to the device’s desired shadow
Command mapping:
Command |
GVA Params |
RainMaker Publish |
GVA State Response |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Fan speed named values: low = 25%, medium = 50%, high = 100%.
Response:
{
"requestId": "uuid",
"payload": {
"commands": [
{
"ids": ["node123.Light1"],
"status": "SUCCESS",
"states": { "on": true, "online": true }
}
]
}
}
Error Response:
{
"requestId": "uuid",
"payload": {
"commands": [
{
"ids": ["node123.Light1"],
"status": "ERROR",
"errorCode": "unknownError"
}
]
}
}
Sequence Diagram:
sequenceDiagram
title GVA EXECUTE - Device Control
participant Google as Google Assistant
participant Lambda as gva_action Lambda
participant IoT as AWS IoT Shadow/MQTT
Google->>Lambda: POST /v1/integrations/gva<br/>{intent: EXECUTE, commands[], Bearer token}
activate Lambda
Lambda->>Lambda: Extract user ID from token
loop For each command > device > execution
Lambda->>Lambda: Parse device ID (node_id.device_name)
Lambda->>Lambda: Load node permissions (groupID from customData)
Lambda->>Lambda: Map GVA command params<br/>to RainMaker params via customData
Lambda->>IoT: Publish to desired shadow<br/>{device_name: {param: value}}
IoT->>Lambda: Success
end
Lambda->>Google: {commands: [{ids, status, states}]}
deactivate Lambda
DISCONNECT (Account Unlinking)
Google sends DISCONNECT when the user unlinks their RainMaker account from Google. After unlinking, Google stops sending SYNC/QUERY/EXECUTE intents for that user.
Request:
{
"requestId": "uuid",
"inputs": [{ "intent": "action.devices.DISCONNECT", "payload": {} }]
}
Process:
Get user ID from the ESP User access token
Delete the user’s GVA link row from
rmng-user-endpoints(best-effort: a delete failure is logged and the intent still succeeds, since Google has already unlinked on its side)Return an empty acknowledgement
Response: Per the Google Smart Home spec, the webhook returns an empty JSON body.
{}
Proactive Notifications (Report State)
When a device’s state changes (typically a physical interaction or any cloud-side update that the user did not initiate via Google), ESP RainMaker Neo calls Google’s HomeGraph Report State API to keep the Google Home graph in sync. This is what powers “OK Google, is the light on?” returning the right answer after a wall-switch press.
Dispatcher integration
Report State is delivered through the shared notifications dispatcher. Specifically:
Registered service name:
gvaService kind: user-specific. The dispatcher resolves the user list for the originating group/subgroup, and
agentUserId = <userID>is set on each outbound request.Trigger: a shadow update where the dispatch event’s
notifymap contains a"gva"key. Direct notifications (notify/…topics) are not supported by the GVA channel — onlyshadow_updateevents are marshalled.GVA is a bespoke adapter — it owns the URI, the service-account JWT auth, and the HomeGraph body shape, rather than reusing the generic webhook scaffold. See notifications-webhooks.md for context on the two implementation patterns.
Authentication model
Unlike Alexa, GVA does not use per-user OAuth. Report State is authenticated with a single service-account JWT scoped to the HomeGraph API:
Service-account JSON is stored in SSM (see Step 4: Create Service Account).
On each invocation, the SSM parameter is read (with caching), used to mint a JWT, and exchanged for a Google access token scoped to
https://www.googleapis.com/auth/homegraph.One token is reused across all users in a single dispatch — fetched once and applied per-user.
No per-user OAuth token is stored for GVA — the agentUserId field in the
request body is what Google uses to look up the linked HomeGraph account. There
is however a per-user link row in rmng-user-endpoints
(integration_id = "gva"), written on the first SYNC and deleted on
DISCONNECT, because Google exposes no link/unlink callback. Report State and
Request Sync use that row to decide which users to notify, so a user with no row
receives nothing. The row carries a placeholder OAuth bundle only because the
table requires one on non-push rows; it is never used to authenticate.
Process
A shadow update notification is received with the node’s current state and delta.
Get node configuration to determine devices and their capabilities.
Compute the set of changed devices from the delta — only these are reported, matching Google’s expectation that Report State carries deltas, not full state.
For each changed device, build a HomeGraph state map using the device’s traits and current params.
If no devices changed (e.g. the delta only touched a non-device shadow field), the marshal step returns an empty result and the dispatcher logs “No changed device states to report, skipping Report State”.
Mint a HomeGraph access token.
For each target user, set
agentUserId = userIDon the request and POST tohttps://homegraph.googleapis.com/v1/devices:reportStateAndNotificationwithAuthorization: Bearer <homegraph token>.
Report State Structure
{
"requestId": "uuid",
"agentUserId": "<ESP RainMaker Neo user ID>",
"payload": {
"devices": {
"states": {
"node123.Light1": {
"on": true,
"brightness": 75,
"online": true
}
}
}
}
}
The keys of payload.devices.states are <nodeID>.<deviceName> values matching exactly what was returned in the most recent SYNC for the same agentUserId (Google requires Report State device IDs to match the SYNC IDs). State values follow Google’s per-trait schema (see Trait Mapping).
Error semantics
The GVA channel is best-effort:
A failed token mint aborts the entire dispatch (no users get reported), but only that one notification — other channels (
push,alexa, …) still fire and other notifications are unaffected.Per-(user, device) HTTP failures are logged and skipped — the loop continues.
The dispatcher only sees a hard failure if the marshal step itself fails, in which case
gvais dropped for that event.
There is no retry, no DLQ, and no delivery receipt — observability comes from logs and the HomeGraph diagnostics in Google Cloud Console.
Testing
Once the Google Console setup is complete and the service account configuration has been stored via POST /v1/admin/integrations/gva/configuration (see Store Configuration):
Ensure the RainMaker user account has at least one node with supported devices.
On a mobile device, open the Google Home app signed in with the Google account linked to your Google Home project.
Tap + (Add) → Set up device → Works with Google.
Search for and select your integration, then link the RainMaker account through the OAuth/Cognito sign-in flow.
After linking, Google issues a SYNC request and your RainMaker devices appear in the Google Home app. You can then control them and issue voice commands.
For sharing an unpublished integration with specific test accounts, see Beta Sharing below.
Beta Sharing
To share the Google Home action with specific users for testing:
Step 1: Add Testers to Google Cloud Project
Go to Google Cloud Console: https://console.cloud.google.com -> IAM & Admin -> IAM
Click Grant Access
Add the tester’s Google account email address
Assign the Viewer role
Click Save
Step 2: Tester Activates the Test Action
The tester must perform these steps:
Go to Google Home Developer Console: https://console.home.google.com and sign in with the same Google account that was added to the Cloud project
Make sure you can see the same project
Navigate to the Test tab and click Test or Retest — this step is required to make the action visible in the Google Home app
Important: The test action will remain hidden in the Google Home app until the tester completes the above steps. Simply having Cloud IAM access is not enough.
Step 3: Tester Links the Integration
Open the Google Home app on their phone
Tap + (Add) -> Set up device -> Works with Google
Search for your integration — it should appear with a [test] tag in front of the name
Tap it and link their account (sign in via the OAuth/Cognito flow)
Devices should now appear in their Google Home app
Troubleshooting:
If the action doesn’t appear in the Google Home app, make sure the tester has clicked Test/Retest in the Developer Console (Step 2.3 above).
It can take a few minutes for permissions to propagate.
Try force closing and reopening the Google Home app if the integration doesn’t appear.