Google Nest
Про плагін
Цей плагін дозволяє Butler взаємодіяти з пристроями Google Nest, зокрема з Google Nest Thermostat.
Для використання інтеграцій сторонніх розробників Google вимагає одноразову безповоротну плату в розмірі 5 доларів США станом на момент написання цієї інструкції.
Цей плагін потребує розширеної багатоетапної конфігурації, створення проєкту Google Cloud Console, налаштування необхідних API та отримання кодів авторизації
Інструкція зі встановлення
I. Налаштування проєкту Google Cloud Platform (GCP)
1. Створення проєкту
-
Перейдіть до Google Cloud Console та увійдіть за допомогою свого облікового запису Google
-
Створіть новий проєкт
- У верхній панелі навігації натисніть на випадаюче меню проєкту, там може бути написано "Select a project" (Виберіть проєкт)
- Натисніть кнопку New Project (Новий проєкт)
- У полі Project name (Назва проєкту) введіть назву для свого проєкту, це може бути будь-що
- Натисніть кнопку Create (Створити)
- Зачекайте, поки завершиться створення проєкту
-
Виберіть щойно створений проєкт
-
Скопіюйте Project ID (Ідентифікатор проєкту) з панелі керування проєктом, він вам знадобиться пізніше
2. Увімкніть необхідні API
-
Перейдіть до бібліотеки API (Menu > APIs & Services > Library (Меню > API та сервіси > Бібліотека)).
-
Увімкніть SDM API
- У полі пошуку вгорі сторінки введіть:
Smart Device Management
(Керування розумними пристроями) та натисніть на результат пошуку
- Натисніть кнопку Enable (Увімкнути)
- У полі пошуку вгорі сторінки введіть:
-
Увімкніть Pub/Sub API
- У полі пошуку вгорі сторінки введіть:
Pub/Sub API
та натисніть на результат пошуку
- Натисніть кнопку Enable (Увімкнути)
- У полі пошуку вгорі сторінки введіть:
II. Налаштування ідентифікатора клієнта OAuth 2.0
1. Налаштування авторизації
- Виберіть панель пошуку вгорі на сторінці GCP Console, введіть там Google Auth Platform і натисніть на результат пошуку
- Натисніть кнопку Get Started (Почати)
- На сторінці App Information (Інформація про додаток) введіть назву програми на ваш вибір, виберіть свою електронну адресу у випадаючому меню User support email (Електронна пошта підтримки користувачів) і натисніть кнопку Next (Далі)
- На сторінці Audience (Аудиторія) виберіть External (Зовнішня) і натисніть кнопку Next (Далі)
- На сторінці Contact Information (Контактна інформація) введіть свою адресу електронної пошти та натисніть кнопку Next (Далі)
- На сторінці Finish (Завершення) погодьтеся з Політикою даних користувачів Google API Services і натисніть кнопку Continue (Продовжити)
- Натисніть кнопку Create (Створити)
Майте на увазі, що цей процес може не вдатися з ряду причин, наприклад, через невідповідну назву, причина буде вказана в повідомленні про помилку в такому випадку
2. Налаштування екрана згоди
- Натисніть на пункт меню Branding (Брендування)
- Прокрутіть сторінку до розділу Authorized domains (Авторизовані домени)
- Натисніть кнопку + Add Domain (+ Додати домен) і введіть
c-home.ua
у поле Authorized domain 1 (Авторизований домен 1) - Прокрутіть до низу сторінки та натисніть кнопку Save (Зберегти)
3. Створення ідентифікатора клієнта OAuth
Вам потрібно зачекати 5-10 хвилин, перш ніж ви зможете продовжити
- Виберіть меню Clients (Клієнти) і натисніть там кнопку + Create Client (+ Створити клієнта)
- Виберіть Web application (Веб-застосунок) у Application type (Тип застосунку)
- Введіть будь-яке ім'я в поле Name (Назва), наприклад
c-home
- Прокрутіть сторінку до Authorized redirect URIs (Авторизовані URI перенаправлення) і натисніть кнопку Add URI (Додати URI)
- Введіть
https://c-home.ua/code-confirmation.html
у поле URIs 1 (URI 1) і натисніть кнопку Create (Створити) - Натисніть кнопку Download (Завантажити) біля створеного клієнта (виглядає як стрілка вниз)
- Скопіюйте Client ID (Ідентифікатор клієнта) і Client secret (Секрет клієнта) і збережіть їх десь для подальших кроків
III. Налаштування доступу до пристрою
1. Зареєструйтеся для отримання доступу до пристрою
- Перейдіть до Device Access Console
- Прочитайте та прийміть Умови використання Google API та Умови використання Sandbox
- Натисніть кнопку Continue to payment (Перейти до оплати) та сплатіть реєстраційний внесок
2. Створіть проєкт доступу до пристрою
- Натисніть кнопку + Create Project (+ Створити проєкт)
- Введіть назву проєкту, наприклад
c-home
, і натисніть кнопку Next (Далі) - На наступному екрані введіть Client ID (Ідентифікатор клієнта), який ви зберегли раніше, і натисніть кнопку Next (Далі)
- Виберіть Enable (Увімкнути), щоб увімкнути події в реальному часі, і натисніть Create Project (Створити проєкт)
- Після цього проєкт буде створено, і буде відкрито інформацію про проєкт
- Скопіюйте та збережіть Project ID (Ідентифікатор проєкту) десь для подальших кроків
IV. Отримання кодів авторизації
- Вставте Client ID (Ідентифікатор клієнта) з Google Auth Platform у форму нижче та відкрийте згенероване посилання
- Виберіть свій обліковий запис Google і натисніть кнопку Allow (Дозволити)
- Натисніть посилання Advanced (Додатково)
- Натисніть Go to c-home.ua (Перейти до c-home.ua)
- Натисніть прапорець Select all (Вибрати все), щоб усі необхідні дозволи були вибрані, і натисніть кнопку Continue (Продовжити)
- Скопіюйте код авторизації зі сторінки та збережіть його для подальших кроків
V. Встановлення плагіна
Дотримуйтесь загальних інструкцій зі встановлення, поки не вставите код плагіна в текстову область. Не натискайте кнопку Save (Зберегти) ще.
Вам потрібно замінити наступні константи в коді плагіна:
- G_CLIENT_ID - на Client ID (Ідентифікатор клієнта) з Google Auth Platform
- G_CLIENT_SECRET - на Client Secret (Секрет клієнта) з Google Auth Platform
- G_PROJECT_ID_DEVICE_ACCESS - на Project ID (Ідентифікатор проєкту) з Device Access Console
- G_PROJECT_ID_PUBSUB - на Project ID (Ідентифікатор проєкту) з Google Cloud Console
- G_AUTH_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"
Вихідний код
Вихідний код плагіна
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 = ""
--[[============================================]]
--[[ ]]
--[[ NO NEED TO CHANGE ANYTHING BELOW THIS LINE ]]
--[[ ]]
--[[============================================]]
local G_PUBSUB_TOPIC = "projects/sdm-prod/topics/enterprise-" .. G_PROJECT_ID_DEVICE_ACCESS
local G_PUBSUB_SUBSCRIPTION = "chome_sub"
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://www.google.com"
-- 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)
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 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")
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