Govee
About
This plugin provides integration with Govee devices
Preparations
To use this plugin you need to obtain an API key from Govee. You can get it by requesting it through Govee Home app.
- Open Govee Home app
- Tap on your profile icon in the bottom right corner
- Tap on settings icon
- Tap on Apply for API Key
- Fill the form
- Enter your name into Name
- Type the reason why you need the API key, for example "Integration with 3rd party smart home hub" into Reason for application
- Check the checkbox to agree with the terms and conditions
- Tap on Submit
- Wait for the API key to be sent to your email
Installation guide
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 constant in the plugin code:
- GOVEE_API_KEY - with your Govee API key
so it looks like this but with your value:
local GOVEE_API_KEY = "abcdef12-3456-7890-abcd-ef1234567890"
Continue on with generic installation instructions
Source Code
Plugin source code
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