Govee
Про плагін
Цей плагін забезпечує інтеграцію з пристроями Govee.
Підготовка
Щоб використовувати цей плагін, вам потрібно отримати API ключ від Govee. Ви можете отримати його, подавши запит через додаток Govee Home.
- Відкрийте додаток Govee Home
- Торкніться значка свого профілю в правому нижньому куті
- Торкніться значка налаштувань
- Торкніться Apply for API Key (Подати заявку на API ключ)
- Заповніть форму
- Введіть своє ім'я в поле Name (Ім'я)
- Введіть причину, чому вам потрібен API ключ, наприклад, "Інтеграція зі стороннім розумним домашнім хабом" у поле Reason for application (Причина подачі заявки)
- Поставте прапорець, щоб погодитися з умовами використання
- Торкніться Submit (Відправити)
- Дочекайтеся, поки API ключ буде надіслано на вашу електронну пошту
Інструкція зі встановлення
Встановлення плагіна
Дотримуйтесь загальних інструкцій зі встановлення, доки не вставите код плагіна в текстову область. Не натискайте кнопку Save (Зберегти) поки що.
Вам потрібно замінити наступну константу в коді плагіна:
- GOVEE_API_KEY - на ваш Govee API ключ
щоб це виглядало так, але з вашим значенням:
local GOVEE_API_KEY = "abcdef12-3456-7890-abcd-ef1234567890"
Продовжуйте згідно з загальними інструкціями зі встановлення
Вихідний код
Вихідний код плагіна
local os = require("os")
local json = require("json")
local timer = require("timer")
local http = require("http")
local https = require("https")
local querystring = require("querystring")
local timer = require('timer')
local uuid5 = require("uuid5")
--[[ GOVEE API KEY ]] --
local GOVEE_API_KEY = "abcdef12-3456-7890-abcd-ef1234567890"
--[[ Govee API URLs ]] --
local GOVEE_URL_BASE = "https://openapi.api.govee.com/router/api/v1"
local GOVEE_URL_GET_DEVICES = GOVEE_URL_BASE .. "/user/devices"
local GOVEE_URL_GET_DEVICE = GOVEE_URL_BASE .. "/device/state"
local GOVEE_URL_CONTROL_DEVICE = GOVEE_URL_BASE .. "/device/control"
--[[ Govee Device Types ]] --
local GOVEE_DEVICE_TYPE_LIGHT = "devices.types.light"
local GOVEE_DEVICE_TYPE_AIR_PURIFIER = "devices.types.air_purifier"
local GOVEE_DEVICE_TYPE_THERMOMETER = "devices.types.thermometer"
local GOVEE_DEVICE_TYPE_SOCKET = "devices.types.socket"
local GOVEE_DEVICE_TYPE_SENSOR = "devices.types.sensor"
local GOVEE_DEVICE_TYPE_HEATER = "devices.types.heater"
local GOVEE_DEVICE_TYPE_HUMIDIFIER = "devices.types.humidifier"
local GOVEE_DEVICE_TYPE_DEHUMIDIFIER = "devices.types.dehumidifier"
local GOVEE_DEVICE_TYPE_ICE_MAKER = "devices.types.ice_maker"
local GOVEE_DEVICE_TYPE_AROMA_DIFFUSER = "devices.types.aroma_diffuser"
local GOVEE_DEVICE_TYPE_BOX = "devices.types.box"
--[[ HTTP Status Codes ]] --
local HTTP_OK = 200
local HTTP_BAD_REQUEST = 400
local HTTP_UNAUTHORIZED = 401
local HTTP_TOO_MANY_REQUESTS = 429
local HTTP_INTERNAL_SERVER_ERROR = 500
--[[ Misc Constants ]] --
local GOVEE_DEFAULT_POLL_INTERVAL = 60
local plugin = {}
plugin.name = "Govee"
plugin.version = { 0, 0, 1 }
-- plugin settings for all devices
plugin.settings = {
api_key = {
value = nil,
default = "",
type = "string",
description = {
en = "Govee API Key",
ua = "API Ключ Govee",
ru = "API Ключ Govee"
}
}
}
-- new interfaces provided by the plugin
plugin.provided_interfaces = {
GoveeCloudDevice = {
actions = {},
parameters = {
identity = {
type = "string",
value = "AA:BB:CC:DD:EE:FF:00:11"
},
device_name = {
type = "string",
value = "Govee device name"
},
model = {
type = "string",
value = "H9001"
},
manufacturer = {
type = "string",
value = "Govee"
}
}
}
}
-- controller present interfaces overridden by the plugin
plugin.overridden_interfaces = {
SwitchBinary = {
actions = {
setStatus = {}
}
},
SwitchMultilevel = {
actions = {
setLevel = {}
}
},
SwitchColor = {
actions = {
setColor = {}
}
}
}
-- complete device information profiles
plugin.devices = {
GoveeRgbLight = {
type = "DevDimmerColor",
role = "Lighting",
icon = "ch-lightbulb",
interfaces = { "GoveeCloudDevice", "SwitchBinary", "SwitchMultilevel", "SwitchColor" },
parameters = {
identity = {
type = "string",
value = "",
role = "show",
locale = {
en = "Device ID",
ua = "ID пристрою",
ru = "ID устройства",
}
},
device_name = {
type = "string",
value = "",
role = "show",
locale = {
en = "Device Name",
ua = "Ім'я пристрою",
ru = "Имя устройства"
}
},
model = {
type = "string",
value = "",
role = "show",
locale = {
en = "Device Model",
ua = "Модель пристрою",
ru = "Модель устройства"
}
},
manufacturer = {
type = "string",
value = "Govee",
role = "show",
locale = {
en = "Manufacturer",
ua = "Виробник",
ru = "Производитель"
}
},
--[[ Parameters for SwitchBinary interface ]] --
Status = {
type = "boolean",
value = false
},
pulsable = {
type = "boolean",
value = false
},
--[[ Parameters for SwitchMultilevel interface ]] --
Level = {
type = "integer",
value = 0
},
--[[ Parameters for SwitchColor interface ]] --
supportedComponents = {
type = "json",
value = {
"Red", "Green", "Blue", "WarmWhite", "ColdWhite"
}
},
currentComponentValues = {
type = "json",
value = {
Red = 255,
Green = 255,
Blue = 255,
WarmWhite = 0,
ColdWhite = 0
}
}
}
}
}
-- Saved device discovery result
plugin.previous_discovery_result = {}
--[[ Log print wrapper ]]
local original_print = print
---
--- @brief Define the new print function with the prefix
--- @param ... any Arguments to print
function print(...)
-- Create a table to hold the new arguments
local new_args = { "[Govee]" }
-- Add the original arguments to the new arguments table
for i = 1, select("#", ...) do
new_args[#new_args + 1] = select(i, ...)
end
-- Call the original print function with the new arguments
original_print(table.unpack(new_args))
end
--[[ API wrapper functions ]]
--- @brief Generic Govee Cloud request wrapper
--- @param method string - request method (GET, POST, etc..)
--- @param url string - request url
--- @param body ?string - request body
--- @param callback ?function - callback function
---
--- @return boolean
function plugin:govee_request(method, url, body, callback)
local opts = http.parseUrl(url)
opts.method = method
opts.headers = {
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["Govee-API-Key"] = GOVEE_API_KEY
}
if body then
opts.headers["Content-Length"] = #body
else
opts.headers["Content-Length"] = 0
end
local request = https.request(opts, function(res)
local data = ""
res:on("data", function(chunk)
data = data .. chunk
end)
res:on("end", function()
if callback then
callback(res, data)
end
end)
end)
print("Request created")
if body then
request:write(body)
end
request:done()
return true
end
--- @brief Get devices list
--- @param callback ?function - callback function with argument contating parsed response json or nil
---
--- @return boolean
function plugin:govee_get_devices(callback)
return self:govee_request("GET", GOVEE_URL_GET_DEVICES, nil, function(res, body)
-- TODO: handle rate limiting
if res.statusCode == HTTP_OK then
print(body)
if callback then
callback(json.parse(body))
end
else
if callback then
callback(nil)
end
end
end)
end
--- @brief Get device info
--- @param identity string device identity
--- @param model string device model
--- @param callback ?function - callback function with argument contating parsed response json or nil
---
--- @return boolean
function plugin:govee_get_device(identity, model, callback)
return self:govee_request(
"POST",
GOVEE_URL_GET_DEVICE,
json.stringify {
requestId = uuid5.get("rd" .. identity .. model .. math.random(1000, 9999)),
payload = {
device = identity,
sku = model
}
},
function(res, body)
-- TODO: handle rate limiting
if res.statusCode == HTTP_OK then
print(body)
if callback then
callback(json.parse(body))
end
else
if callback then
callback(nil)
end
end
end)
end
--- @brief Control device
--- @param identity string device identity
--- @param model string device model
--- @param capability table device capability
--- @param callback ?function - callback function with argument contating parsed response json or nil
---
--- @return boolean
function plugin:govee_control_device(identity, model, capability, callback)
local payload = json.stringify {
requestId = uuid5.get("cd" .. identity .. model .. math.random(1000, 9999)),
payload = {
device = identity,
sku = model,
capability = capability
}
}
if type(payload) ~= "string" then
-- So that IDE will STFU
print("Failed to stringify payload")
return false
end
return self:govee_request(
"POST",
GOVEE_URL_CONTROL_DEVICE,
payload,
function(res, body)
-- TODO: handle rate limiting
if res.statusCode == HTTP_OK then
print(body)
if callback then
callback(json.parse(body))
end
else
if callback then
callback(nil)
end
end
end)
end
--[[ Some conversion helpers ]] --
--- @brief Convert RGB color to integer
--- @param r number Red component
--- @param g number Green component
--- @param b number Blue component
---
--- @return number RGB color as integer
function plugin:govee_convert_rgb_to_integer(r, g, b)
return r * 0x10000 + g * 0x100 + b
end
--- @brief Convert RGB color from integer to separate components
--- @param rgb number RGB color as integer
--- @return number r Red component
--- @return number g Green component
--- @return number b Blue component
function plugin:govee_convert_rgb_from_integer(rgb)
return math.floor(rgb / 0x10000), math.floor((rgb % 0x10000) / 0x100), rgb % 0x100
end
--- @brief Convert separate cold and warm color components to color temperature
--- @param cold number Cold component
--- @param warm number Warm component
--- @param min number Minimum temperature value
--- @param max number Maximum temperature value
---
--- @return number temperature Color temperature
function plugin:govee_convert_cw_to_temperature(cold, warm, min, max)
return min + math.floor((cold/255) * (max - min) + 0.5)
end
--- @brief Convert color temperature to separate cold and warm color components
--- @param temperature number Color temperature
--- @param min number Minimum temperature value
--- @param max number Maximum temperature value
---
--- @return number cold Cold component
--- @return number warm Warm component
function plugin:govee_convert_cw_from_temperature(temperature, min, max)
local cT = (temperature - min) / (max - min)
local warm = cT * 255
local cold = 255 - warm
return math.floor(cold + 0.5), math.floor(warm + 0.5)
end
--[[ Cached State ]] --
local device_state_cache = {}
--[[ Plugin functions implementations ]]
--- @brief Plugin registration callback
function plugin:register()
print("Registered")
self:govee_get_devices(function(response)
if not response then
print("Failed to get devices list")
return
end
if response.code ~= HTTP_OK then
print("Failed to get device list. Error:", response.code, response.message)
return
end
for _, device in ipairs(response.data) do
for _, registered_device in pairs(device_state_cache) do
if registered_device.identity == device.device and registered_device.model == device.sku then
local capabilities = {}
for _, cap in ipairs(device.capabilities) do
print("Capability:", cap.instance, "of type:", cap.type)
capabilities[cap.instance] = {
type = cap.type,
parameters = cap.parameters
}
end
registered_device.capabilities = capabilities
end
end
end
end)
end
--- @brief Devices discovery callback
--- Should respond to `{topic}/reply` with the list of descovered devices
function plugin:discover_devices(topic, payload)
print("Device discovery started")
self:govee_get_devices(function(response)
if not response then
print("Failed to get devices list")
return
end
if response.code ~= HTTP_OK then
print("Failed to get device list. Error:", response.code, response.message)
return
end
local discovery_result = {}
for _, device in ipairs(response.data) do
print("Detected device:", device.deviceName, device.sku, device.device)
local device_type = ""
if device.type == GOVEE_DEVICE_TYPE_LIGHT then
device_type = "GoveeRgbLight"
else
-- TODO: Implement other devices
print("Unsupported device type:", device.type)
goto continue
end
table.insert(discovery_result, {
identity = device.sku .. " " .. device.device,
info = device.deviceName,
type = device_type,
icon = "ch-lightbulb",
params = {
identity = device.device,
model = device.sku,
device_name = device.deviceName,
manufacturer = "Govee"
}
})
::continue::
end
self.previous_discovery_result = response.data
self:reply(topic, payload.message_id, 200, true, {
devices = discovery_result
})
end)
end
--- @brief Create params table for new device, from whatever initial data
--- @param device table Device table (from plugin.devices)
--- @param identity any 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
--- @param base_params table Base params table
--- @param overlay_params table 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 This function creates device creation request and send it to the main hub application
function plugin:device_create(topic, payload)
print("Creating device")
local message_id = payload.message_id
local name = payload.body.params.name
local dev_type = payload.body.params.type or ""
local identity_separator_pos = string.find(payload.body.params.identity, " ") or 0
local identity = string.sub(payload.body.params.identity, identity_separator_pos + 1)
local model = string.sub(payload.body.params.identity, 1, identity_separator_pos - 1)
local params = {}
print("Creating device of type:", dev_type, "with identity:", "'" .. identity .. "'", "model:", "'" .. model .. "'")
local room_id = payload.body.params.room_id or 0
local params = {}
local new_device = plugin.devices and plugin.devices[dev_type]
if not new_device then
self:reply(topic, message_id, 400, false, {
error = "Invalid device type"
})
return
end
local discovery_device_data = nil
if self.previous_discovery_result then
for _, device in pairs(self.previous_discovery_result) do
if device.device == identity and device.sku == model then
discovery_device_data = device
break
end
end
end
if not discovery_device_data then
self:reply(topic, message_id, 400, false, {
error = "Device not found"
})
return
end
self:govee_get_device(identity, model, function(response)
if not response then
self:reply(topic, message_id, 400, false, {
error = "Failed to get device info"
})
return
end
if response.code ~= HTTP_OK then
self:reply(topic, message_id, 400, false, {
error = "Failed to get device info. Error: " .. response.code .. " " .. response.message
})
print("Failed to get device info. Error:", response.code, response.message)
return
end
print("HTTP OK")
local capabilities = {}
for _, cap in ipairs(response.payload.capabilities) do
print("Capability:", cap.instance, "of type:", cap.type)
capabilities[cap.instance] = {
type = cap.type,
state = cap.state
}
end
-- colorTemperature is value between 2000 and 9000
-- 2000 is warm, 9000 is cold
local colorTemperature = capabilities["colorTemperatureK"] and
capabilities["colorTemperatureK"].state.value or 2000
local colorW, colorC = self:govee_convert_cw_from_temperature(colorTemperature, 2000, 9000)
local color = capabilities["colorRgb"] and capabilities["colorRgb"].state.value or 0
local colorR, colorG, colorB = self:govee_convert_rgb_from_integer(color)
params.identity = identity
params.device_name = discovery_device_data.deviceName
params.model = discovery_device_data.sku
params.manufacturer = "Govee"
params.Status = capabilities["powerSwitch"].state.value ~= 0
params.Level = capabilities["brightness"].state.value or 0
params.currentComponentValues = {
Red = colorR,
Green = colorG,
Blue = colorB,
WarmWhite = colorW,
ColdWhite = colorC
}
params = merge_params(get_params_name_value(new_device, identity), params)
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 = params
}
},
qos = 2
}
self:reply(topic, message_id, 200, true)
end)
end
--- @brief Update device paramaeters in batches
--- @param device_id integer Device ID
--- @param params table 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 Device created by the main applcation
--- This function is called when the main application created new device for the plugin
--- (or the creation was unsuccessful) and the plugin should handle the new device
--- @param _ string Topic (unused)
--- @param payload table Payload table
function plugin:device_created(_, payload)
print("Device created")
if not payload.body.success then
print("Device creation failed:", payload.status)
return
end
self:custom_update_params(payload.body.device_id, payload.body.params)
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)
-- TODO: Implement device state polling
end
--- @brief Device removed by the main application
--- @param topic string Topic
--- @param payload table Payload table
function plugin:device_removed(topic, payload)
print("Device removed")
local device_id = tonumber(self:get_topic_segment(topic, 5))
if device_id == nil then
print("Invalid device ID")
end
-- local identity = self:db_get_param(device_id, "identity")
self:db_delete_params(device_id)
self:db_remove_device(device_id)
end
--- @brief Plugin initialized
function plugin:init(reason)
print("Initialization")
end
--- @brief Polling function for the device
--- Upon first call, this function will start periodical device state polling
---
--- @param device table Device identifier
function plugin:poll_device(device)
print("Polling device " .. device.device_name .. "(" .. device.device_id_in_hub .. ")")
local device_id = device.device_id_in_hub
if device_state_cache[device_id].timer then
timer.clearTimeout(device_state_cache[device_id].timer)
end
self:govee_get_device(device.identity, device.model, function(result)
if not result then
print("Failed to get device info")
else
local capabilities = {}
for _, cap in ipairs(result.payload.capabilities) do
capabilities[cap.instance] = {
type = cap.type,
state = cap.state
}
end
local colorTemperature = capabilities["colorTemperatureK"] and
capabilities["colorTemperatureK"].state.value or 2000
local colorW, colorC = self:govee_convert_cw_from_temperature(colorTemperature, 2000, 9000)
local color = capabilities["colorRgb"] and capabilities["colorRgb"].state.value or 0
local colorR, colorG, colorB = self:govee_convert_rgb_from_integer(color)
local params = {
Status = capabilities["powerSwitch"].state.value ~= 0,
Level = capabilities["brightness"].state.value or 0,
currentComponentValues = {
Red = colorR,
Green = colorG,
Blue = colorB,
WarmWhite = colorW,
ColdWhite = colorC
}
}
self:custom_update_params(device_id, params)
device_state_cache[device_id].cached_state = params
end
device_state_cache[device_id].timer = timer.setTimeout(GOVEE_DEFAULT_POLL_INTERVAL * 1000, function()
self:poll_device(device_state_cache[device_id])
end)
end)
end
--- @brief Device registered
--- @param device_id integer Device ID
--- @param name string Device name
--- @param params table Device parameters
function plugin:device_registered(device_id, name, params)
print("Registered device " .. name .. "(" .. device_id .. ")")
self:notify("Registered device " .. name .. "(" .. device_id .. ")")
device_state_cache[device_id] = {
device_name_in_hub = name,
device_id_in_hub = device_id,
identity = params.identity,
device_name = params.device_name,
model = params.model,
manufacturer = params.manufacturer,
cached_state = {},
timer = nil
}
self:poll_device(device_state_cache[device_id])
end
--- @brief Cast argument to desired type if needed and possible, otherwise return nil
--- @param arg any Argument value
--- @param desired_type string Desired argument type
---
--- @return any|nil Argument value casted to desired type or nil
local function cast_argument(arg, desired_type)
if (desired_type == "string" and type(arg) == "string") or
(desired_type == "number" and type(arg) == "number") or
(desired_type == "boolean" and type(arg) == "boolean") then
return arg
elseif desired_type == "string" then
return tostring(arg)
elseif desired_type == "number" then
return tonumber(arg)
elseif desired_type == "boolean" then
return arg == "true"
elseif desired_type == "table" or desired_type == "json" then
return json.parse(arg)
else
return nil
end
end
--- @brief Function used to retrieve action argument from the payload
--- This function will take into account if it is the positonal call
--- @param payload table Payload table
--- @param name string Argument name
--- @param position number Argument position in the topic
--- @param desired_type string Desired argument type
---
--- @return string|nil Action argument value or nil in case there is none
local function get_action_argument(payload, name, position, desired_type)
if payload.body and payload.body.params then
for k, v in pairs(payload.body.params) do
if k == name or k == position then
return cast_argument(v, desired_type)
end
end
end
return nil
end
--- @brief Callback for action calls overriden by the plugin
--- @param topic string Topic
--- @param payload table Payload table
function plugin:action(topic, payload)
print("Action called")
local device_id = tonumber(self:get_topic_segment(topic, 5))
if device_id == nil then
print("Invalid device ID")
return
end
local identity = self:db_get_param(device_id, "identity")
local model = self:db_get_param(device_id, "model")
local capability = nil
for k,v in pairs(device_state_cache[device_id].cached_state) do
print(k,v)
end
if payload.body.interface == "SwitchBinary" and payload.body.method == "setStatus" then
capability = {
instance = "powerSwitch",
type = "device.capabilities.on_off",
value = get_action_argument(payload, "new_state", 1, "boolean") and 1 or 0
}
device_state_cache[device_id].cached_state.Status = capability.value
elseif payload.body.interface == "SwitchMultilevel" and payload.body.method == "setLevel" then
capability = {
instance = "brightness",
type = "device.capabilities.range",
value = get_action_argument(payload, "new_state", 1, "number")
}
device_state_cache[device_id].cached_state.Level = capability.value
elseif payload.body.interface == "SwitchColor" and payload.body.method == "setColor" then
local color = get_action_argument(payload, "color", 1, "json")
local cached_color = device_state_cache[device_id].cached_state.currentComponentValues
if not color then
print("Invalid color")
return
end
if color.Red ~= cached_color.Red or color.Green ~= cached_color.Green or color.Blue ~= cached_color.Blue then
device_state_cache[device_id].cached_state.currentComponentValues.Red = color.Red
device_state_cache[device_id].cached_state.currentComponentValues.Green = color.Green
device_state_cache[device_id].cached_state.currentComponentValues.Blue = color.Blue
capability = {
instance = "colorRgb",
type = "device.capabilities.color_setting",
value = self:govee_convert_rgb_to_integer(color.Red, color.Green, color.Blue)
}
elseif color.WarmWhite ~= cached_color.WarmWhite or color.ColdWhite ~= cached_color.ColdWhite then
device_state_cache[device_id].cached_state.currentComponentValues.WarmWhite = color.WarmWhite
device_state_cache[device_id].cached_state.currentComponentValues.ColdWhite = color.ColdWhite
local min = device_state_cache[device_id].capabilities["colorTemperatureK"].parameters.range.min
local max = device_state_cache[device_id].capabilities["colorTemperatureK"].parameters.range.max
local temp = self:govee_convert_cw_to_temperature(color.ColdWhite, color.WarmWhite, min, max)
print(color.WarmWhite, color.ColdWhite, min, max, temp)
capability = {
instance = "colorTemperatureK",
type = "device.capabilities.color_setting",
value = temp
}
else
print("No color change")
end
end
if capability then
self:govee_control_device(identity, model, capability, function(response)
print("Device control response:", response)
end)
else
print("Unsupported action")
end
end
return plugin