Google Nest
About
This plugin allows Butler to interact with Google Nest devices, particularry Google Nest Thermostat.
To use third-party integrations Google requires a one-time non-refundable US$5 fee as of writing this instruction.
This plugin needs extensive multi-step configuration, creating Google Cloud Console project, setting up needed APIs and getting authorization codes
Installation guide
I. Setting up Google Cloud Platform (GCP) project
1. Create project
-
Go to the Google Cloud Console and login there with your Google account
-
Create a New Project
- In the top navigation bar, click on the project dropdown menu it might show "Select a project"
- Click New Project button
- In the Project name field, enter a name for your project, can be anything
- Click Create button
- Wait until project creation is finished
-
Select newly created project
-
Copy Project ID from the project dashboard, you will need it later
2. Enable needed APIs
-
Go to the API Library (Menu > APIs & Services > Library).
-
Enable SDM API
- In the search field in the top of the page type:
Smart Device Management
and click on the search result
- Click Enable button
- In the search field in the top of the page type:
-
Enable Pub/Sub API
- In the search field in the top of the page type:
Pub/Sub API
and click on the search result
- Click Enable button
- In the search field in the top of the page type:
II. Set up OAuth 2.0 Client ID
1. Configure Authorization
- Select search bar at the top on GCP Console page, type Google Auth Platform there and click on the search result
- Click Get Started button
- On the App Information page enter app name of your choice, select your email in User support email dropdown and click Next button
- On the Audience page select External and click Next button
- On the Contact Information page enter your email address and click Next button
- On the Finish page agree to Google API Services: User Data Policy and click on Continue button
- Click Create button
Be aware that this process can fail due to number of reasons i.e. unsuitable name, the reason will be stated in the error message in such case
2. Configure consent screen
- Click on Branding menu entry
- Scroll the page to Authorized domains section
- Press + Add Domain button and enter
c-home.ua
into Authorized domain 1 field - Scroll to the bottom of the page and click Save button
3. Create OAuth Client ID
You will need to wait 5-10 minutes before you can continue
- Select Clients menu and click + Create Client button there
- Select Web application in Application type
- Enter any name into Name field i.e.
c-home
- Scroll page to Authorized redirect URIs and press Add URI button
- Enter
https://c-home.ua/code-confirmation.html
into URIs 1 field and press Create button - Press Download button near created client (looks like a down arrow)
- Copy Client ID and Client secret and save them somewhere for later steps
III. Configure device access
1. Register for device access
- Go to Device Access Console
- Read and accept the Google APIs Terms of Service and Sandbox Terms of Services
- Press Continue to payment button and pay registration fee
2. Create device access project
- Press + Create Project button
- Enter project name, e.g.
c-home
and press Next button - On the next screen enter Client ID that you saved earlier and press Next button
- Select Enable to enable real-time events and click Create Project
- After this project will be created and project information will be opened
- Copy and save Project ID somewhere for later steps
IV. Acquiring authorization codes
- Paste Client ID from Google Auth Platform into the form below, and open generated link
- Select your Google account and press Allow button
- Click Advanced link
- Click Go to c-home.ua
- Click Select all checkbox so that all needed permissions are selected and press Continue button
- Copy authorization code from the page and save it for later steps
V. Plugin installation
Follow generic installation instructions until you paste, plugin code in text area. Do not press Save button yet.
You need to replace the following constants in the plugin code:
- G_CLIENT_ID - with Client ID from Google Auth Platform
- G_CLIENT_SECRET - with Client Secret from Google Auth Platform
- G_PROJECT_ID_DEVICE_ACCESS - with Project ID from Device Access Console
- G_PROJECT_ID_PUBSUB - with Project ID from Google Cloud Console
- G_AUTH_CODE - with user authorization code
local G_CLIENT_ID = "123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com"
local G_CLIENT_SECRET = "GOCABC-1234567890abcdefghijklmnopqr"
local G_PROJECT_ID_DEVICE_ACCESS = "12345678-90ab-cdef-1234-567890abcdef"
local G_PROJECT_ID_PUBSUB = "c-home-450112"
local G_AUTH_CODE = "4/efaI8ofhbo91ajf4bJdhfi8q1rio8da73210Dhw8VQuiedHIyyrww123yUJ1289eq18yo8f"
Source Code
Plugin source code
local json = require("json")
local timer = require("timer")
local http = require("http")
local https = require("https")
local querystring = require("querystring")
local plugin = {}
--[[
Configuration constants
User should acquire the following constants from Google Cloud Console and
get authorization code for the SDM and Pub/Sub APIs
[!NOTE] After successfull authorization G_AUTH_CODE, G_PROJECT_ID_DEVICE_ACCESS,
G_PROJECT_ID_PUBSUB, G_CLIENT_ID, G_CLIENT_SECRET can (or even should) be set
to `nil` to prevent accidental disclosure of the values in the logs.
For more information, see https://{DOCS URL HERE}
]]
--
local G_CLIENT_ID = ""
local G_CLIENT_SECRET = ""
local G_PROJECT_ID_DEVICE_ACCESS = ""
local G_PROJECT_ID_PUBSUB = ""
local G_AUTH_CODE = ""
local G_PUBSUB_TOPIC = "projects/sdm-prod/topics/enterprise-" .. G_PROJECT_ID_DEVICE_ACCESS
local G_PUBSUB_SUBSCRIPTION = "nest-events-sub"
--[[============================================]]
--[[ ]]
--[[ NO NEED TO CHANGE ANYTHING BELOW THIS LINE ]]
--[[ ]]
--[[============================================]]
local G_BASE_URL_OAUTH = "https://oauth2.googleapis.com/token"
local G_BASE_URL_PUBSUB = "https://pubsub.googleapis.com/v1/"
local G_BASE_URL_SDM = "https://smartdevicemanagement.googleapis.com/v1/"
local G_AUTH_REDIRECT_URI = "https://c-home.ua/code-confirmation.html"
-- SDM API response codes
-- Invalid command arguments
local G_SDM_STATUS_INVALID_ARGUMENT = 400
-- Access token expired
local G_SDM_STATUS_UNAUTHENTICATED = 401
-- Permission denied
local G_SDM_STATUS_PERMISSION_DENIED = 403
-- Command, device, enterprise, room or structure not found
local G_SDM_STATUS_NOT_FOUND = 404
-- Rate limit exceeded
local G_SDM_STATUS_RESOURCE_EXHAUSTED = 429
-- Object already exists
local G_SDM_STATUS_ALREADY_EXISTS = 409
-- @brief Helper function to pretty-print any variable
-- @param var Variable to print
-- @param ident Identation string (optional)
-- @param depth Initial identation depth (optional)
local function print_variable(var, ident, depth)
ident = ident or " "
depth = depth or 0
local ident_str = ""
for _ = 1, depth do
ident_str = ident_str .. ident
end
if type(var) == "table" then
print(ident_str .. "{")
for k, v in pairs(var) do
if type(v) == "table" then
print(ident_str .. ident .. k .. " = ")
print_variable(v, ident, depth + 1)
else
print(ident_str .. ident .. k .. " = " .. tostring(v))
end
end
print(ident_str .. "}")
else
print(ident_str .. tostring(var))
end
end
-- @brief Base64 decode function
-- @param data Base64-encoded string
-- @return Decoded string
local function base64_decode(data)
local b = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
data = string.gsub(data, "[^" .. b .. "=]", "")
return (data:gsub(".", function(x)
if (x == "=") then
return ""
end
local r, f = "", (b:find(x) - 1)
for i = 6, 1, -1 do
r = r .. (f % 2 ^ i - f % 2 ^ (i - 1) > 0 and "1" or "0")
end
return r
end):gsub("%d%d%d?%d?%d?%d?%d?%d?", function(x)
if (#x ~= 8) then
return ""
end
local c = 0
for i = 1, 8 do
c = c + (x:sub(i, i) == "1" and 2 ^ (8 - i) or 0)
end
return string.char(c)
end))
end
-- @brief Helper function to round value, to 1 decimal place
-- @param value Value to round
-- @return Rounded value
local function round_sensor_value(value)
local res = tonumber(string.format("%.1f", value))
return res
end
-- @brief Helper function to round value to nearest quantum
-- @param exact Exact value
-- @param quantum Quantum
-- @return Rounded value
local function round_setpoint_value(exact, quantum)
quantum = quantum or 0.5
local quant,frac = math.modf(exact/quantum)
return quantum * (quant + (frac > 0.5 and 1 or 0))
end
-- @brief Nest thermostat mode to CH thermostat mode
-- @param mode Nest thermostat mode(like "HEAT" or "COOL")
-- @return CH thermostat mode (like "Heat" or "Cool")
local function convert_thermostat_mode_nest_to_ch(mode)
if mode == "HEATCOOL" then
return "Auto"
end
return (string.lower(mode):gsub("^%l", string.upper))
end
-- @brief CH thermostat mode to Nest thermostat mode
-- @param mode CH thermostat mode (like "Heat" or "Cool")
-- @return Nest thermostat mode(like "HEAT" or "COOL")
local function convert_thermostat_mode_ch_to_nest(mode)
if mode == "Auto" then
return "HEATCOOL"
end
return string.upper(mode)
end
-- @brief Nest thermostat state to CH thermostat state
-- @param state Nest thermostat state(like "HEATING" or "COOLING")
-- @return CH thermostat state (like "Heating" or "Cooling")
local function convert_thermostat_state_nest_to_ch(state)
if state == "OFF" then
return "Idle"
end
return (string.lower(state):gsub("^%l", string.upper))
end
-- Plugin definition
plugin.name = "Google Nest"
plugin.version = { 0, 0, 2 }
plugin.provided_interfaces = {
NestDevice = {
actions = {
pollNow = {
description = "Poll device now",
arguments = {},
handler = function(self, args)
return true
end
}
},
parameters = {
identity = {
type = "string",
value = "<Device ID>"
},
version = {
type = "integer",
value = "1"
},
manufacturer = {
type = "string",
value = "Google Nest"
},
model = {
type = "string",
value = "Nest Thermostat"
},
}
}
}
plugin.overridden_interfaces = {
ThermostatMode = {
actions = {
setMode = {}
}
},
ThermostatSetpoint = {
actions = {
setSetpoint = {}
}
},
}
plugin.devices = {
Thermostat = {
type = "DevThermostat",
role = "ClimateThermostat",
icon = "ch-socket",
interfaces = { "NestDevice", "ThermostatMode", "ThermostatSetpoint", "ThermostatOperatingState", "SensorMultilevel" },
parameters = {
identity = {
type = "string",
value = "",
role = "edit",
locale = {
en = "Device ID",
ru = "ID устройства",
ua = "Ідентифікатор пристрою"
}
},
version = {
type = "integer",
value = 1
},
manufacturer = {
type = "string",
value = "Google Nest",
role = "show",
locale = {
en = "Manufacturer",
ru = "Производитель",
ua = "Виробник"
}
},
--[[ Thermostat Mode interface parameters]] --
availableModes = {
type = "json",
value = { "Off", "Heat", "Cool", "Auto" }
},
currentMode = {
type = "string",
value = "Off"
},
autoModeIsActive = {
type = "boolean",
value = false
},
--[[ Thermostat Setpoint interface parameters]] --
setpointCapabilities = {
type = "json",
value = {
Cool = {
minimum = 10,
maximum = 30,
precision = 1,
scale = 0,
size = 4
},
Heat = {
minimum = 10,
maximum = 30,
precision = 1,
scale = 0,
size = 4
},
Auto = {
minimum = 10,
maximum = 30,
precision = 1,
scale = 0,
size = 4
},
}
},
currentSetpoints = {
type = "json",
value = {
Cool = 20,
Heat = 20,
Auto = 20,
}
},
step = {
type = "double",
value = 0.5
},
--[[ Thermostat Operating State interface parameters]] --
currentOperatingState = {
type = "string",
value = "Idle"
},
--[[ Sensor Multilevel interface parameters]] --
Value = {
type = "double",
value = 0
},
sensorType = {
type = "string",
value = "Temperature"
},
valueUnit = {
type = "string",
value = "°C"
},
valueScale = {
type = "string",
value = "Celcius",
}
}
},
HumiditySensor = {
type = "DevHygrometry",
role = "MultilevelSensor",
icon = "ch-humidity",
interfaces = { "NestDevice", "SensorMultilevel" },
parameters = {
identity = {
type = "string",
value = "",
role = "edit",
locale = {
en = "Device ID",
ru = "ID устройства",
ua = "Ідентифікатор пристрою"
}
},
version = {
type = "integer",
value = 1
},
manufacturer = {
type = "string",
value = "Google Nest",
role = "show",
locale = {
en = "Manufacturer",
ru = "Производитель",
ua = "Виробник"
}
},
Value = {
type = "double",
value = 0
},
sensorType = {
type = "string",
value = "Humidity"
},
valueUnit = {
type = "string",
value = "%"
},
valueScale = {
type = "string",
value = "Percent",
}
}
},
TemperatureSensor = {
type = "DevTemperature",
role = "MultilevelSensor",
icon = "ch-temperature",
interfaces = { "NestDevice", "SensorMultilevel" },
parameters = {
identity = {
type = "string",
value = "",
role = "edit",
locale = {
en = "Device ID",
ru = "ID устройства",
ua = "Ідентифікатор пристрою"
}
},
version = {
type = "integer",
value = 1
},
manufacturer = {
type = "string",
value = "Google Nest",
role = "show",
locale = {
en = "Manufacturer",
ru = "Производитель",
ua = "Виробник"
}
},
Value = {
type = "double",
value = 0
},
sensorType = {
type = "string",
value = "Temperature"
},
valueUnit = {
type = "string",
value = "°C"
},
valueScale = {
type = "string",
value = "Celcius",
}
}
},
}
-- Saved device discovery result
plugin.previous_discovery_result = {}
-- List of commands that'll be sent after throttling timer expires
plugin.deferred_commands = {}
-- Registered devices list, for fast access (k = Google's device ID, v = local ID)
plugin.registered_devices_list = {}
--[[ Custom db table functions ]]
-- @brief Check if table exists
--
-- @param tableName Table name
-- @return true if table exists, false otherwise
function plugin:custom_db_has_table(tableName)
print("Checking table " .. tableName)
local query = string.format("SELECT name FROM sqlite_master WHERE type='table' AND name='%s';", tableName)
local result = self.db:rowexec(query);
if result then
return true
end
return false
end
-- @brief Create `tbl_access_data` table
function plugin:custom_db_create_tables()
local query = [[
CREATE TABLE IF NOT EXISTS tbl_access_data (
key TEXT PRIMARY KEY,
value TEXT
);
]]
self.db:exec(query)
end
-- @brief Initialize `tbl_access_data` table
function plugin:custom_db_initialize()
local initialValues = {
project_id_device_access = '',
project_id_pubsub = '',
client_id = '',
client_secret = '',
used_auth_code = '',
access_token = '',
refresh_token = '',
expiry_time = ''
}
for key, value in pairs(initialValues) do
self:update_record(key, value)
end
end
-- @brief Load access data from `tbl_access_data` table
function plugin:custom_db_load_access_data()
local query = "SELECT key,value FROM tbl_access_data;"
local result = {}
local stmt = self.db:prepare(query)
local row = {};
while stmt:step(row) do
result[row[1]] = row[2]
end
return result
end
-- @brief Update one record in `tbl_access_data` table
-- @param key Record name
-- @param value Record value
function plugin:update_record(key, value)
local query = string.format("INSERT OR REPLACE INTO tbl_access_data (key, value) VALUES ('%s', '%s');", key, value)
self.db:exec(query)
end
-- @brief Batch update access data records
-- @param updates Table containing updates (k = key, v = value),
-- where both key and value ar strings
function plugin:update_records(updates)
local query = "BEGIN TRANSACTION;"
for key, value in pairs(updates) do
query = query ..
string.format("INSERT OR REPLACE INTO tbl_access_data (key, value) VALUES ('%s', '%s');", key, value)
end
query = query .. "COMMIT;"
self.db:exec(query)
end
--[[ Throttling functions ]]
-- @brief Start throttling timer
-- While throttled, no new commands will be sent to Google API's (does not include PubSub)
-- After timer expires, it's callback will send all deferred commands
function plugin:engage_rate_limit()
print("Google Nest: Rate limit exceeded")
self:notify("Rate limit exceeded")
self.rate_limit_timer = timer.setTimeout(60000, function() -- after 1 minute
print("Google Nest: Rate limit timer expired")
self.rate_limit_timer = nil
print("Google Nest: Processing deferred commands")
for device_id, commands in pairs(self.deferred_commands) do
for command, params in pairs(commands) do
if command == "set_mode" then
self:set_mode(device_id, params.mode)
elseif command == "set_setpoint" then
self:set_setpoint(device_id, params.new_setpoint, params.mode)
end
end
end
self.deferred_commands = {}
end)
end
--[[ REST API wrappers, helpers and other functions ]]
-- @brief Generic request sender for SDM API
-- @param url URL to send request to
-- @param method HTTP method (GET, POST, PUT, etc.)
-- @param has_body If true, request will have body
-- @param body Request body (optional)
-- @param content_type Content type header value
-- @param with_auth If true, request will have Authorization header
-- @param callback Callback function to be called after request is completed
function plugin:sdm_generic_request(url, method, has_body, body, content_type, with_auth, callback)
if self.rate_limit_timer then
print("Google Nest: Rate limit timer is active")
if callback then
callback(json.stringify({
error = {
status = "RESOURCE EXAUSTED",
code = G_SDM_STATUS_RESOURCE_EXHAUSTED,
message = "Rate limit exceeded"
}
}))
end
return false
end
local opts = http.parseUrl(url)
opts.method = method
if has_body then
opts.headers = {
["Content-Type"] = content_type,
["Content-Length"] = #body
}
else
opts.headers = {}
end
if with_auth then
opts.headers["Authorization"] = "Bearer " .. self.access_data.access_token
end
local req = https.request(opts, function(res)
local data = ""
res:on('data', function(chunk)
data = data .. chunk
end)
res:on("end", function()
local json_data = json.parse(data)
if json_data and json_data.error and with_auth then
if json_data.error.code == G_SDM_STATUS_UNAUTHENTICATED then
print("Access token expired")
self:refresh_token(function()
self:sdm_generic_request(url, method, has_body, body, content_type, with_auth, callback)
end)
return -- do not call callback, it will be called after token refresh
elseif json_data.error.code == G_SDM_STATUS_RESOURCE_EXHAUSTED then
self:engage_rate_limit()
end
end
if (callback) then
callback(data)
end
end)
end)
if has_body then
req:write(body)
end
req:done()
return true
end
-- @brief Send POST request to SDM API
-- @see plugin:sdm_generic_request
function plugin:post_request(url, body, content_type, with_auth, callback)
return self:sdm_generic_request(url, "POST", true, body, content_type, with_auth, callback)
end
-- @brief Send GET request to SDM API
-- @see plugin:sdm_generic_request
function plugin:get_request(url, with_auth, callback)
return self:sdm_generic_request(url, "GET", false, nil, nil, with_auth, callback)
end
-- @brief Generic request sender for PubSub API
-- @param sub_id Subscription ID
-- @param url_suffix URL suffix (part added to the end of url, in Google API's it typically starts with ':')
-- @param method HTTP method (GET, POST, PUT, etc.)
-- @param has_body If true, request will have body
-- @param body Request body (optional)
-- @param callback Callback function to be called after request is completed
function plugin:pubsub_generic_request(sub_id, url_suffix, method, has_body, body, callback)
url_suffix = url_suffix or ""
local url = G_BASE_URL_PUBSUB .. "projects/" .. G_PROJECT_ID_PUBSUB .. "/subscriptions/" .. sub_id .. url_suffix
local opts = http.parseUrl(url)
opts.method = method
opts.headers = {
["Authorization"] = "Bearer " .. self.access_data.access_token
}
if has_body then
opts.headers["Content-Type"] = "application/json"
opts.headers["Content-Length"] = #body
end
local req = https.request(opts, function(res)
local data = ""
res:on('data', function(chunk)
data = data .. chunk
end)
res:on("end", function()
local json_data = json.parse(data)
if json_data and json_data.error and json_data.error.code == G_SDM_STATUS_UNAUTHENTICATED then
self:refresh_token(function()
self:pubsub_generic_request(sub_id, url_suffix, method, has_body, body, callback)
end)
else
if callback then
callback(data)
end
end
end)
end)
if has_body then
req:write(body)
end
req:done()
end
-- @brief Send PUT request to PubSub API
-- @see plugin:pubsub_generic_request
function plugin:pubsub_put_request(sub_id, url_suffix, body, callback)
self:pubsub_generic_request(sub_id, url_suffix, "PUT", true, body, callback)
end
-- @brief Send POST request to PubSub API
-- @see plugin:pubsub_generic_request
function plugin:pubsub_post_request(sub_id, url_suffix, body, callback)
self:pubsub_generic_request(sub_id, url_suffix, "POST", true, body, callback)
end
-- @brief Send GET request to PubSub API
-- @see plugin:pubsub_generic_request
function plugin:pubsub_get_request(sub_id, url_suffix, callback)
self:pubsub_generic_request(sub_id, url_suffix, "GET", false, nil, callback)
end
-- @brief Wrapper for Getting list of devices from SDM API
-- @param callback Callback function to be called after request is completed
function plugin:get_devices_list(callback)
self:get_request(G_BASE_URL_SDM .. "enterprises/" .. G_PROJECT_ID_DEVICE_ACCESS .. "/devices", true, function(data)
print(data)
local json_data = json.parse(data)
if json_data then
if json_data.error then
print("Cannot get devices list:", json_data.error)
return
end
if callback then
callback(json_data)
end
else
print("Cannot parse response")
end
end)
end
--[[ Authorization and access functions ]]
-- @brief Authorize plugin initially
--
-- This method will request initial refresh and access tokens from Google
-- using G_AUTH_CODE provided by user, and save it to the DB.
-- The obtained tokens will be used for both SDM and Pub/Sub APIs
function plugin:auth()
local body = querystring.stringify({
code = G_AUTH_CODE,
client_id = G_CLIENT_ID,
client_secret = G_CLIENT_SECRET,
redirect_uri = G_AUTH_REDIRECT_URI,
grant_type = "authorization_code"
});
self:post_request(G_BASE_URL_OAUTH, body, "application/x-www-form-urlencoded", false, function(data)
print("Got auth tokens")
local json_data = json.parse(data)
if json_data then
if json_data.error then
print("Error:")
print_variable(json_data.error, " ", 0)
return
end
-- Save tokens and other auth data to DB
self.access_data = {
project_id_device_access = G_PROJECT_ID_DEVICE_ACCESS,
project_id_pubsub = G_PROJECT_ID_PUBSUB,
client_id = G_CLIENT_ID,
client_secret = G_CLIENT_SECRET,
used_auth_code = G_AUTH_CODE,
access_token = json_data.access_token,
refresh_token = json_data.refresh_token,
expiry_time = tostring(json_data.expires_in + os.time())
}
self:update_records(self.access_data)
-- Initialize services after successful authentication
self:get_devices_list()
self:pubsub_create_subscription(G_PUBSUB_SUBSCRIPTION)
self:pubsub_start(nil)
else
print("Cannot parse response")
end
end)
end
-- @brief Helper function to refresh access token
-- @param callback Callback function to be called after token refresh
function plugin:refresh_token(callback)
local body = {
client_id = G_CLIENT_ID,
client_secret = G_CLIENT_SECRET,
grant_type = "refresh_token",
refresh_token = self.access_data.refresh_token
}
local body_str = querystring.stringify(body);
self:post_request(G_BASE_URL_OAUTH, body_str, "application/x-www-form-urlencoded", false, function(data)
local json_data = json.parse(data)
if json_data then
if json_data.error then
print("Cannot refresh token:", json_data.error)
return
end
self.access_data.access_token = json_data.access_token
self.access_data.expiry_time = tostring(json_data.expires_in + os.time())
self:update_records(self.access_data)
callback()
else
print("Cannot parse response")
end
end)
end
-- @brief Create Pub/Sub subscription
-- @param sub_id Subscription ID
function plugin:pubsub_create_subscription(sub_id)
print("Creating subscription " .. sub_id)
local body = {
topic = G_PUBSUB_TOPIC,
enableMessageOrdering = true,
}
self:pubsub_put_request(sub_id, nil, json.stringify(body), function(data)
local json_data = json.parse(data)
if json_data then
if json_data.error and json_data.error.code ~= G_SDM_STATUS_ALREADY_EXISTS then
print("Cannot create subscription:", json_data.error.message)
return
end
print("Subscription created")
else
print("Cannot parse response")
end
end)
end
-- @brief Start pulling messages from Pub/Sub, and process them
-- @param random_id Random ID for this pulling process, used to prevent multiple
-- pulling processes running at the same time, if
-- ID will be generated randomly
function plugin:pubsub_start(random_id)
random_id = random_id or tostring(math.random(1000000000))
if self.pulling_process and random_id ~= self.pulling_process then
print("Already pulling")
return
end
self.pulling_process = random_id
local body = json.stringify({
returnImmediately = false,
maxMessages = 100
})
self:pubsub_post_request(G_PUBSUB_SUBSCRIPTION, ":pull", body, function(res)
print("Pub/Sub pull response")
local json_data = json.parse(res)
if json_data then
if json_data.error then
print("Cannot pull messages:", json_data.error.message)
self:notify("Pub/Sub pull error: " .. json_data.error.message)
timer.setTimeout(5000, function()
self:pubsub_start(random_id)
end)
else
-- Process messages
if json_data.receivedMessages then
local ack_ids = {}
for _, message in ipairs(json_data.receivedMessages) do
local data = base64_decode(message.message.data)
table.insert(ack_ids, message.ackId)
local message_data = json.parse(data)
if message_data and message_data.resourceGroup then
local target_device_id = message_data.resourceGroup[1]
if target_device_id then
target_device_id = string.match(target_device_id,
"enterprises/" .. G_PROJECT_ID_DEVICE_ACCESS:gsub("%-", "%%-") .. "/devices/(.*)")
print("Target device ID: " .. target_device_id)
end
local devices_list = {
Thermostat = {
id = self.registered_devices_list[target_device_id .. "Thermostat"],
update_params = {}
},
Humidity = {
id = self.registered_devices_list[target_device_id .. "Humidity Sensor"],
update_params = {}
},
Temperature = {
id = self.registered_devices_list[target_device_id .. "Temperature Sensor"],
update_params = {}
}
}
for k, v in pairs(devices_list) do
if v.id then
print("Associated " .. k .. " ID: " .. v.id)
end
end
for trait, params in pairs(message_data.resourceUpdate.traits) do
if trait == "sdm.devices.traits.ThermostatMode" then
print("ThermostatMode")
if params.mode then
print("Mode: " .. params.mode)
devices_list.Thermostat.update_params.currentMode = convert_thermostat_mode_nest_to_ch(params
.mode)
if params.mode == "HEATCOOL" then
self:notify("⚠️ HeatCool mode is not currently supported ⚠️")
end
end
if params.availableModes then
devices_list.Thermostat.update_params.availableModes = {}
for _, mode in ipairs(params.availableModes) do
print("Available mode: " .. mode)
table.insert(devices_list.Thermostat.update_params.availableModes,
convert_thermostat_mode_nest_to_ch(mode))
end
end
elseif trait == "sdm.devices.traits.ThermostatTemperatureSetpoint" then
print("ThermostatTemperatureSetpoint")
devices_list.Thermostat.update_params.currentSetpoints = self:db_get_param(devices_list.Thermostat.id or 0, "currentSetpoints") or
{}
if params.coolCelsius then
print("Cool: " .. params.coolCelsius)
devices_list.Thermostat.update_params.currentSetpoints.Cool = round_setpoint_value(params.coolCelsius)
end
if params.heatCelsius then
print("Heat: " .. params.heatCelsius)
devices_list.Thermostat.update_params.currentSetpoints.Heat = round_setpoint_value(params.heatCelsius)
end
elseif trait == "sdm.devices.traits.ThermostatHvac" then
print("ThermostatHvac")
if params.status then
print("Status: " .. params.status)
devices_list.Thermostat.update_params.currentOperatingState =
convert_thermostat_state_nest_to_ch(params.status)
end
elseif trait == "sdm.devices.traits.Temperature" then
print("Temperature")
if params.ambientTemperatureCelsius then
print("Temperature: " .. params.ambientTemperatureCelsius)
devices_list.Thermostat.update_params.Value = round_sensor_value(params.ambientTemperatureCelsius)
devices_list.Temperature.update_params.Value = round_sensor_value(params.ambientTemperatureCelsius)
end
elseif trait == "sdm.devices.traits.Humidity" then
print("Humidity")
if params.ambientHumidityPercent then
print("Humidity: " .. params.ambientHumidityPercent)
devices_list.Humidity.update_params.Value = round_sensor_value(params.ambientHumidityPercent)
end
else
print("Unsupported Trait: " .. trait)
end
end
for _, dev_props in pairs(devices_list) do
if dev_props.id and next(dev_props.update_params) ~= nil then
self:custom_update_params(dev_props.id, dev_props.update_params)
if dev_props.update_params.currentMode then
self:db_update_param(dev_props.id, "currentMode", "string",
dev_props.update_params.currentMode)
end
if dev_props.update_params.currentSetpoints then
self:db_update_param(dev_props.id, "currentSetpoints", "json",
json.stringify(dev_props.update_params.currentSetpoints))
end
end
end
else
print("Cannot parse message data")
end
end
if #ack_ids > 0 then
local ack_body = {
ackIds = ack_ids
}
local ack_body_str = json.stringify(ack_body)
self:pubsub_post_request(G_PUBSUB_SUBSCRIPTION, ":acknowledge", ack_body_str, function(_)
print("Messages acknowledged")
end)
end
end
self:pubsub_start(random_id)
end
else
print("Cannot parse response")
self:notify("Pub/Sub bad response")
timer.setTimeout(5000, function()
self:pubsub_start(random_id)
end)
end
end)
end
--[[ Plugin lifecycle functions ]]
-- @brief Plugin initialization
function plugin:init(_)
print("Google Nest plugin init")
if not self:custom_db_has_table("tbl_access_data") then
self:custom_db_create_tables()
self:custom_db_initialize()
end
self.access_data = self:custom_db_load_access_data()
-- if there is refresh and access tokens in the DB and auth code in DB equals to
-- code from G_AUTH_CODE or G_AUTH_CODE is nil - use tokens from DB
-- otherwise - get new tokens from Google
if self.access_data.refresh_token and self.access_data.access_token and
(self.access_data.used_auth_code == G_AUTH_CODE or not G_AUTH_CODE) then
self:get_devices_list()
self:pubsub_create_subscription(G_PUBSUB_SUBSCRIPTION)
self:pubsub_start(nil)
else
self:auth()
end
-- Start timer for getting devices list
timer.setInterval(60000, function()
self:get_devices_list()
end)
end
-- @brief Plugin was registered in hub
function plugin:register(_)
print("Google Nest plugin registered")
end
-- @brief Plugin unregistered from hub
function plugin:unregister(_, _)
print("Google Nest plugin unregistered")
end
-- @brief Process incoming device discovery request
-- @param topic MQTT topic
-- @param payload MQTT payload
function plugin:discover_devices(topic, payload)
print("Google Nest discovery")
local discovery_result = {}
self:get_devices_list(function(json_data)
print("Successfully retrieved devices list")
print(json_data)
for k,v in pairs(json_data) do
print("k", k)
end
for i, v in ipairs(json_data["devices"]) do
print("Device " .. i)
if v.type ~= "sdm.devices.types.THERMOSTAT" then
print("Unsupported device type: " .. v.type)
else
-- device id is in the name fields however it is in strange format in the response from Google
-- so we need to extract it from the name
local id = string.match(v.name,
"enterprises/" .. G_PROJECT_ID_DEVICE_ACCESS:gsub("%-", "%%-") .. "/devices/(.*)")
local info = ""
if v.parentRelations and #v.parentRelations > 0 and v.parentRelations[1].displayName then
info = v.parentRelations[1].displayName
end
print("Device ID: " .. id)
print("Device info" .. info)
local devices = {
Thermostat = {
identity = id,
info = info .. " Thermostat",
type = "Thermostat",
icon = "ch-thermostat",
params = {
model="Thermostat",
manufacturer="Google Nest",
}
},
HumiditySensor = {
identity = id,
info = info .. " Humidity Sensor",
type = "HumiditySensor",
icon = "ch-humidity",
params = {
model="Humidity Sensor",
manufacturer="Google Nest",
}
},
TemperatureSensor = {
identity = id,
info = info .. " Temperature Sensor",
type = "TemperatureSensor",
icon = "ch-temperature",
params = {
model="Temperature Sensor",
manufacturer="Google Nest",
}
}
}
-- Setting device
for trait, trait_params in pairs(v.traits) do
if trait == "sdm.devices.traits.Info" then
-- Ignore this trait
elseif trait == "sdm.devices.traits.ThermostatMode" then
devices.Thermostat.params.availableModes = {}
for _, mode in ipairs(trait_params.availableModes) do
if mode == "OFF" then
table.insert(devices.Thermostat.params.availableModes, "Off")
elseif mode == "HEAT" then
table.insert(devices.Thermostat.params.availableModes, "Heat")
elseif mode == "COOL" then
table.insert(devices.Thermostat.params.availableModes, "Cool")
end
end
if trait_params.mode == "OFF" then
devices.Thermostat.params.currentMode = "Off"
elseif trait_params.mode == "HEAT" then
devices.Thermostat.params.currentMode = "Heat"
elseif trait_params.mode == "COOL" then
devices.Thermostat.params.currentMode = "Cool"
end
devices.Thermostat.params.autoModeIsActive = false -- trait_params.mode == "heatcool"
elseif trait == "sdm.devices.traits.ThermostatTemperatureSetpoint" then
devices.Thermostat.params.setpointCapabilities = {
Cool = {
minimum = 9,
maximum = 32,
precision = 0.5,
scale = 0,
size = 4
},
Heat = {
minimum = 9,
maximum = 32,
precision = 0.5,
scale = 0,
size = 4
},
}
devices.Thermostat.params.currentSetpoints = {
Cool = round_setpoint_value(trait_params.coolCelsius or trait_params.heatCelsius or 20),
Heat = round_setpoint_value(trait_params.heatCelsius or trait_params.coolCelsius or 20)
}
devices.Thermostat.params.step = 0.5
elseif trait == "sdm.devices.traits.ThermostatHvac" then
if trait_params.status == "OFF" then
devices.Thermostat.params.currentOperatingState = "Off"
elseif trait_params.status == "HEATING" then
devices.Thermostat.params.currentOperatingState = "Heating"
elseif trait_params.status == "COOLING" then
devices.Thermostat.params.currentOperatingState = "Cooling"
end
elseif trait == "sdm.devices.traits.Temperature" then
devices.Thermostat.params.Value = round_sensor_value(trait_params.ambientTemperatureCelsius)
devices.Thermostat.params.sensorType = "Temperature"
devices.Thermostat.params.valueUnit = "°C"
devices.Thermostat.params.valueScale = "Celcius"
devices.TemperatureSensor.params.Value = round_sensor_value(trait_params.ambientTemperatureCelsius)
devices.TemperatureSensor.params.sensorType = "Temperature"
devices.TemperatureSensor.params.valueUnit = "°C"
devices.TemperatureSensor.params.valueScale = "Celcius"
elseif trait == "sdm.devices.traits.Humidity" then
devices.HumiditySensor.params.Value = round_sensor_value(trait_params.ambientHumidityPercent)
devices.HumiditySensor.params.sensorType = "Humidity"
devices.HumiditySensor.params.valueUnit = "%"
devices.HumiditySensor.params.valueScale = "Percent"
elseif trait == "sdm.devices.traits.Connectivity" then
-- TODO: add connectivity reporting support
elseif trait == "sdm.devices.traits.Fan" then
-- TODO: add fan support
end
end
table.insert(discovery_result, devices.Thermostat)
table.insert(discovery_result, devices.TemperatureSensor)
table.insert(discovery_result, devices.HumiditySensor)
end
end
for _, v in ipairs(discovery_result) do
self.previous_discovery_result[v.identity .. v.type] = v
end
self:reply(topic, payload.message_id, 200, true, {
devices = discovery_result
})
end)
end
-- @brief Create params table for new device, from whatever initial data
-- @device Device table (from plugin.devices)
-- @identity Device identity (Google's device ID in this case)
local function get_params_name_value(device, identity)
local params = {}
for k, v in pairs(device.parameters) do
params[k] = v.value
end
if identity then
params.identity = identity
end
return params
end
-- @brief Merge two params tables
-- @base_params Base params table
-- @overlay_params Overlay params table
local function merge_params(base_params, overlay_params)
for k, v in pairs(overlay_params) do
base_params[k] = v
end
return base_params
end
-- @brief Process incoming device create request
-- This method will create a new device and send creation request back to hub
-- @param topic MQTT topic
-- @param payload MQTT payload
function plugin:device_create(topic, payload)
print("Google Nest device create requested")
local message_id = payload.message_id
local name = payload.body.params.name
local dev_type = payload.body.params.type or ""
local identity = payload.body.params.identity
local room_id = payload.body.params.room_id or 0
local params = {}
if self.previous_discovery_result[identity .. dev_type] then
params = self.previous_discovery_result[identity .. dev_type].params or {}
end
if not message_id or not name or not identity or not dev_type or not room_id then
self:reply(topic, message_id, 400, true, {
error = "Missing required parameters"
})
return
end
local new_device = plugin.devices and plugin.devices[dev_type]
if not new_device then
self:reply(topic, message_id, 400, true, {
error = "Unknown device type"
})
return
end
self:pub {
topic = "internal/plugins_hub/" .. self.uuid .. "/devices/create",
payload = json.stringify({
version = "2",
method = "POST",
message_id = self.mqtt:pending_message_add(new_device),
token = self.token,
body = {
plugin_type = dev_type,
name = name,
type = new_device.type,
role = new_device.role,
alive = true,
hidden = false,
room_id = room_id,
interfaces = new_device.interfaces,
icon = new_device.icon,
params = merge_params(get_params_name_value(new_device, identity), params)
}
}),
qos = 2
}
self:reply(topic, message_id, 200, true)
end
-- @brief Update device paramaeters in batches
-- @param device_id Device ID
-- @param params Table containing parameters to update (k = param name, v = new param value)
function plugin:custom_update_params(device_id, params)
self.mqtt:publish {
topic = "internal/plugins_hub/" .. self.uuid .. "/devices/parameters_set",
payload = json.stringify {
message_id = self.mqtt:pending_message_add {
uuid = self.uuid
},
token = self.token,
version = "2",
method = "POST",
body = {
device_id = device_id,
params = params
}
},
qos = 2,
retain = false
}
end
-- @brief Process device creation confirmation event
--
-- This method will save device info to the private DB
-- @param topic MQTT topic
-- @param payload MQTT payload
function plugin:device_created(_, payload)
print("Google Nest device created")
if not payload.body.success then
print("Device created error:", payload.status)
return
end
self:custom_update_params(payload.body.device_id, payload.body.params)
self.registered_devices_list[payload.body.params.identity .. payload.body.params.model] = payload.body.device_id
self:db_add_device(payload.body.device_id, payload.body.plugin_type)
self:db_add_default_params(payload.body.device_id, payload.body.interfaces, payload.body.params)
end
-- @brief Device registered event handler
-- @param device_id Device ID
-- @param name Device name
-- @param params Device parameters
function plugin:device_registered(device_id, name, params)
print("Google Nest device registered")
self.registered_devices_list[params.identity .. params.model] = device_id
self:notify("Registered device " .. device_id)
end
-- @brief Device removed event handler
-- @param device_id Device ID
-- @param name Device name
function plugin:device_removed(topic, payload)
print("Google Nest device removed")
if payload.body.success ~= true then
print("plugin device_removed error:", payload.status)
return
end
local device_id = payload.body.device_id
local google_device_id = self:db_get_param(device_id, "identity")
local device_model = self:db_get_param(device_id, "model")
self.registered_devices_list[google_device_id .. device_model] = nil
-- removing items from db
self:db_delete_params(device_id)
self:db_remove_device(device_id)
end
-- @brief Device action handler
-- @param topic MQTT topic
-- @param payload MQTT payload
function plugin:action(topic, payload)
print("Google Nest action call")
local device_id = tonumber(self:get_topic_segment(topic, 5))
if payload.body.interface == "ThermostatMode" then
if payload.body.method == "setMode" and payload.body.params then
self:set_mode(device_id, payload.body.params[1] or payload.body.params["mode"] or "Off")
end
elseif payload.body.interface == "ThermostatSetpoint" then
if payload.body.method == "setSetpoint" and payload.body.params then
local setpoint = payload.body.params[1] or payload.body.params["setpoint"] or "22"
local mode = payload.body.params[2] or payload.body.params["setpointType"] or "Heat"
local setpoint_number = tonumber(setpoint)
self:set_setpoint(device_id, setpoint_number, mode)
end
end
end
--[[ Device action handlers ]]
-- @brief Set thermostat mode
-- @param device_id Device ID
-- @param new_mode New mode
function plugin:set_mode(device_id, new_mode)
local nest_id = self:db_get_param(device_id, "identity")
local mode = string.upper(new_mode)
local nest_payload = {
command = "sdm.devices.commands.ThermostatMode.SetMode",
params = {
mode = mode
}
}
self:post_request(
G_BASE_URL_SDM .. "enterprises/" .. G_PROJECT_ID_DEVICE_ACCESS .. "/devices/" .. nest_id .. ":executeCommand",
json.stringify(nest_payload),
"application/json",
true,
function(data)
local json_data = json.parse(data)
if json_data then
if json_data.error then
if json_data.error.code == G_SDM_STATUS_RESOURCE_EXHAUSTED then
print("Rate limit exceeded ^^^")
if not self.deferred_commands[device_id] then
self.deferred_commands[device_id] = {}
end
self.deferred_commands[device_id].set_mode = {
mode = mode
}
end
print("Cannot set thermostat mode:", json_data.error)
return
else
self:db_update_param(device_id, "currentMode", "string", mode)
end
else
print("Cannot parse response")
end
end)
end
-- @brief Set device setpoint
-- @param device_id Device ID
-- @param new_setpoint New setpoint
-- @param mode Mode (Heat or Cool)
function plugin:set_setpoint(device_id, new_setpoint, mode)
local nest_id = self:db_get_param(device_id, "identity")
local currentSetpoints = self:db_get_param(device_id, "currentSetpoints")
if not currentSetpoints then
currentSetpoints = {
Cool = 20,
Heat = 20,
Auto = 20,
}
end
local setpoint = tonumber(new_setpoint)
local nest_payload = {
command = "",
params = {}
}
if mode == "Heat" then
nest_payload.command = "sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat"
nest_payload.params.heatCelsius = setpoint
currentSetpoints.Heat = setpoint
elseif mode == "Cool" then
nest_payload.command = "sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool"
nest_payload.params.coolCelsius = setpoint
currentSetpoints.Cool = setpoint
end
self:db_update_param(device_id, "currentSetpoints", "json", json.stringify(currentSetpoints))
self:post_request(
G_BASE_URL_SDM .. "enterprises/" .. G_PROJECT_ID_DEVICE_ACCESS .. "/devices/" .. nest_id .. ":executeCommand",
json.stringify(nest_payload),
"application/json",
true,
function(data)
local json_data = json.parse(data)
if json_data then
if json_data.error then
if json_data.error.code == G_SDM_STATUS_RESOURCE_EXHAUSTED then
if not self.deferred_commands[device_id] then
self.deferred_commands[device_id] = {}
end
self.deferred_commands[device_id].set_setpoint = {
mode = mode,
new_setpoint = new_setpoint,
}
end
print("Cannot set thermostat mode:", json_data.error)
return
end
else
print("Cannot parse response")
end
end)
end
return plugin