Skip to content

Philips-hue batched command handling support #2075

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 6 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions drivers/SmartThings/philips-hue/src/fields.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ local Fields = {
DEVICE_TYPE = "devicetype",
EVENT_SOURCE = "eventsource",
GAMUT = "gamut",
GROUPS = "groups",
GROUPS_SCAN_QUEUE = "groups_scan_queue",
HUE_DEVICE_ID = "hue_device_id",
IPV4 = "ipv4",
IS_ONLINE = "is_online",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
local log = require "log"
local st_utils = require "st.utils"
local Consts = require "consts"
local Fields = require "fields"
local HueColorUtils = require "utils.cie_utils"
local grouped_utils = require "utils.grouped_utils"
local utils = require "utils"


---@class GroupedLightCommandHandlers
local GroupedLightCommandHandlers = {}

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
local function do_switch_action(driver, bridge_device, group, args)
local on = args.command == "on"

local grouped_light_id = group.grouped_light_rid
if not grouped_light_id then
log.error(string.format("Couldn't find grouped light id for group %s",
group.id or "unknown group id"))
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we fail this call does it fall back to unicast commands?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not right now, but it could be added. In the case of grouped_light_id being nil, this shouldn't be possible because we clear out the device table if it isn't on the group response. This is a remnant of when I was iterating through the services in the commands and wasn't saving the service off on group intake.

end

local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]]
if not hue_api then
log.error(string.format("Couldn't find api instance for bridge %s",
bridge_device.label or bridge_device.id or "unknown bridge"))
return
end

local resp, err = hue_api:set_grouped_light_on_state(grouped_light_id, on)
if not resp or (resp.errors and #resp.errors == 0) then
if err ~= nil then
log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err)
elseif resp and #resp.errors > 0 then
for _, error in ipairs(resp.errors) do
log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description)
end
end
end
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
local function do_switch_level_action(driver, bridge_device, group, args)
local level = st_utils.clamp_value(args.args.level, 1, 100)

local grouped_light_id = group.grouped_light_rid
if not grouped_light_id then
log.error(string.format("Couldn't find grouped light id for group %s",
group.id or "unknown group id"))
return
end

local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]]
if not hue_api then
log.error(string.format("Couldn't find api instance for bridge %s",
bridge_device.label or bridge_device.id or "unknown bridge"))
return
end

-- An individual command checks the state of the device before doing this.
-- It is probably not worth iterating through all the devices to check their state.
local resp, err = hue_api:set_grouped_light_on_state(grouped_light_id, true)
if not resp or (resp.errors and #resp.errors == 0) then
if err ~= nil then
log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err)
elseif resp and #resp.errors > 0 then
for _, error in ipairs(resp.errors) do
log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description)
end
end
end

local resp, err = hue_api:set_grouped_light_level(grouped_light_id, level)
if not resp or (resp.errors and #resp.errors == 0) then
if err ~= nil then
log.error_with({ hub_logs = true }, "Error performing switch level action: " .. err)
elseif resp and #resp.errors > 0 then
for _, error in ipairs(resp.errors) do
log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description)
end
end
end
end


---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
local function do_color_action(driver, bridge_device, group, args, aux)
local hue, sat = (args.args.color.hue / 100), (args.args.color.saturation / 100)
if hue == 1 then -- 0 and 360 degrees are equivalent in HSV, but not in our conversion function
hue = 0
grouped_utils.set_field_on_group_devices(group, Fields.WRAPPED_HUE, true)
end

local grouped_light_id = group.grouped_light_rid
if not grouped_light_id then
log.error(string.format("Couldn't find grouped light id for group %s",
group.id or "unknown group id"))
return
end

local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]]
if not hue_api then
log.error(string.format("Couldn't find api instance for bridge %s",
bridge_device.label or bridge_device.id or "unknown bridge"))
return
end

local red, green, blue = st_utils.hsv_to_rgb(hue, sat)
local xy = HueColorUtils.safe_rgb_to_xy(red, green, blue, aux[Fields.GAMUT])

local resp, err = hue_api:set_grouped_light_color_xy(grouped_light_id, xy)
if not resp or (resp.errors and #resp.errors == 0) then
if err ~= nil then
log.error_with({ hub_logs = true }, "Error performing color action: " .. err)
elseif resp and #resp.errors > 0 then
for _, error in ipairs(resp.errors) do
log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description)
end
end
end
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
local function do_setHue_action(driver, bridge_device, group, args, aux)
local currentSaturation = aux[Fields.COLOR_SATURATION] or 0
args.args.color = {
hue = args.args.hue,
saturation = currentSaturation
}
do_color_action(driver, bridge_device, group, args, aux)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
local function do_setSaturation_action(driver, bridge_device, group, args, aux)
local currentHue = aux[Fields.COLOR_HUE] or 0
args.args.color = {
hue = currentHue,
saturation = args.args.saturation
}
do_color_action(driver, bridge_device, group, args, aux)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
local function do_color_temp_action(driver, bridge_device, group, args, aux)
local kelvin = args.args.temperature

local grouped_light_id = group.grouped_light_rid
if not grouped_light_id then
log.error(string.format("Couldn't find grouped light id for group %s",
group.id or "unknown group id"))
return
end

local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]]
if not hue_api then
log.error(string.format("Couldn't find api instance for bridge %s",
bridge_device.label or bridge_device.id or "unknown bridge"))
return
end

local min = aux[Fields.MIN_KELVIN] or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE
local clamped_kelvin = st_utils.clamp_value(kelvin, min, Consts.MAX_TEMP_KELVIN)
local mirek = math.floor(utils.kelvin_to_mirek(clamped_kelvin))

local resp, err = hue_api:set_grouped_light_color_temp(grouped_light_id, mirek)

if not resp or (resp.errors and #resp.errors == 0) then
if err ~= nil then
log.error_with({ hub_logs = true }, "Error performing color temp action: " .. err)
elseif resp and #resp.errors > 0 then
for _, error in ipairs(resp.errors) do
log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description)
end
end
end
grouped_utils.set_field_on_group_devices(group, Fields.COLOR_TEMP_SETPOINT, clamped_kelvin);
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
function GroupedLightCommandHandlers.switch_on_handler(driver, bridge_device, group, args, aux)
do_switch_action(driver, bridge_device, group, args)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
function GroupedLightCommandHandlers.switch_off_handler(driver, bridge_device, group, args, aux)
do_switch_action(driver, bridge_device, group, args)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
function GroupedLightCommandHandlers.switch_level_handler(driver, bridge_device, group, args, aux)
do_switch_level_action(driver, bridge_device, group, args)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
function GroupedLightCommandHandlers.set_color_handler(driver, bridge_device, group, args, aux)
do_color_action(driver, bridge_device, group, args, aux)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
function GroupedLightCommandHandlers.set_hue_handler(driver, bridge_device, group, args, aux)
do_setHue_action(driver, bridge_device, group, args, aux)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
function GroupedLightCommandHandlers.set_saturation_handler(driver, bridge_device, group, args, aux)
do_setSaturation_action(driver, bridge_device, group, args, aux)
end

---@param driver HueDriver
---@param bridge_device HueBridgeDevice
---@param group table
---@param args table
---@param aux table auxilary data needed for the command that the devices all had in common
function GroupedLightCommandHandlers.set_color_temp_handler(driver, bridge_device, group, args, aux)
do_color_temp_action(driver, bridge_device, group, args, aux)
end


return GroupedLightCommandHandlers
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ local HueDeviceTypes = require "hue_device_types"
local StrayDeviceHelper = require "stray_device_helper"

local utils = require "utils"
local grouped_utils = require "utils.grouped_utils"

---@class LightLifecycleHandlers
local LightLifecycleHandlers = {}
Expand Down Expand Up @@ -197,6 +198,13 @@ function LightLifecycleHandlers.added(driver, device, parent_device_id, resource
command = capabilities.refresh.commands.refresh.NAME,
args = {}
})

local bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_device_id)
if bridge_device then
grouped_utils.queue_group_scan(driver, bridge_device)
else
log.warn("Unable to queue group scan on device added, missing bridge device")
end
end

---@param driver HueDriver
Expand Down
50 changes: 46 additions & 4 deletions drivers/SmartThings/philips-hue/src/hue/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ st_utils.stringify_table = st_utils.stringify_table

local HueDeviceTypes = require "hue_device_types"

local GROUPED_LIGHT = "grouped_light"

-- trick to fix the VS Code Lua Language Server typechecking
---@type fun(val: any?, name: string?, multi_line: boolean?): string
st_utils.stringify_table = st_utils.stringify_table
Expand Down Expand Up @@ -325,6 +327,14 @@ function PhilipsHueApi:get_devices() return self:get_all_reprs_for_rtype("device
---@return string? err nil on success
function PhilipsHueApi:get_connectivity_status() return self:get_all_reprs_for_rtype("zigbee_connectivity") end

---@return HueResourceResponse<HueZoneInfo>?
---@return string? err nil on success
function PhilipsHueApi:get_zones() return self:get_all_reprs_for_rtype("zone") end

---@return HueResourceResponse<HueRoomInfo>?
---@return string? err nil on success
function PhilipsHueApi:get_rooms() return self:get_all_reprs_for_rtype("room") end

---@param light_resource_id string
---@return HueResourceResponse<HueLightInfo>?
---@return string? err nil on success
Expand Down Expand Up @@ -400,7 +410,15 @@ end
---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error
---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself.
function PhilipsHueApi:set_light_on_state(id, on)
local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, id)
return self:set_light_on_state_by_device_type(id, on, HueDeviceTypes.LIGHT)
end

function PhilipsHueApi:set_grouped_light_on_state(id, on)
return self:set_light_on_state_by_device_type(id, on, GROUPED_LIGHT)
end

function PhilipsHueApi:set_light_on_state_by_device_type(id, on, device_type)
local url = string.format("/clip/v2/resource/%s/%s", device_type, id)

if type(on) ~= "boolean" then
if on then
Expand All @@ -420,8 +438,16 @@ end
---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error
---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself.
function PhilipsHueApi:set_light_level(id, level)
return self:set_light_level_by_device_type(id, level, HueDeviceTypes.LIGHT)
end

function PhilipsHueApi:set_grouped_light_level(id, level)
return self:set_light_level_by_device_type(id, level, GROUPED_LIGHT)
end

function PhilipsHueApi:set_light_level_by_device_type(id, level, device_type)
if type(level) == "number" then
local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, id)
local url = string.format("/clip/v2/resource/%s/%s", device_type, id)
local payload_table = { dimming = { brightness = level } }

return do_put(self, url, json.encode(payload_table))
Expand All @@ -436,11 +462,19 @@ end
---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error
---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself.
function PhilipsHueApi:set_light_color_xy(id, xy_table)
return self:set_light_color_xy_by_device_type(id, xy_table, HueDeviceTypes.LIGHT)
end

function PhilipsHueApi:set_grouped_light_color_xy(id, xy_table)
return self:set_light_color_xy_by_device_type(id, xy_table, GROUPED_LIGHT)
end

function PhilipsHueApi:set_light_color_xy_by_device_type(id, xy_table, device_type)
local x_valid = (xy_table ~= nil) and ((xy_table.x ~= nil) and (type(xy_table.x) == "number"))
local y_valid = (xy_table ~= nil) and ((xy_table.y ~= nil) and (type(xy_table.y) == "number"))

if x_valid and y_valid then
local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, id)
local url = string.format("/clip/v2/resource/%s/%s", device_type, id)
local payload = json.encode { color = { xy = xy_table }, on = { on = true } }
return do_put(self, url, payload)
else
Expand All @@ -454,8 +488,16 @@ end
---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error
---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself.
function PhilipsHueApi:set_light_color_temp(id, mirek)
return self:set_light_color_temp_by_device_type(id, mirek, HueDeviceTypes.LIGHT)
end

function PhilipsHueApi:set_grouped_light_color_temp(id, mirek)
return self:set_light_color_temp_by_device_type(id, mirek, GROUPED_LIGHT)
end

function PhilipsHueApi:set_light_color_temp_by_device_type(id, mirek, device_type)
if type(mirek) == "number" then
local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, id)
local url = string.format("/clip/v2/resource/%s/%s", device_type, id)
local payload = json.encode { color_temperature = { mirek = mirek }, on = { on = true } }

return do_put(self, url, payload)
Expand Down
Loading
Loading