diff --git a/drivers/SmartThings/philips-hue/src/fields.lua b/drivers/SmartThings/philips-hue/src/fields.lua index 5091354d21..08979059bf 100644 --- a/drivers/SmartThings/philips-hue/src/fields.lua +++ b/drivers/SmartThings/philips-hue/src/fields.lua @@ -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", diff --git a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua new file mode 100644 index 0000000000..1a1099fc5e --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua @@ -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 + 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 diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua index 64b1bcfa84..00258e5722 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua @@ -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 = {} @@ -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 diff --git a/drivers/SmartThings/philips-hue/src/hue/api.lua b/drivers/SmartThings/philips-hue/src/hue/api.lua index 512961d4b9..dbcc5c4c19 100644 --- a/drivers/SmartThings/philips-hue/src/hue/api.lua +++ b/drivers/SmartThings/philips-hue/src/hue/api.lua @@ -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 @@ -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? +---@return string? err nil on success +function PhilipsHueApi:get_zones() return self:get_all_reprs_for_rtype("zone") end + +---@return HueResourceResponse? +---@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? ---@return string? err nil on success @@ -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 @@ -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)) @@ -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 @@ -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) diff --git a/drivers/SmartThings/philips-hue/src/hue_driver_template.lua b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua index 09d7c1174d..1cda7fb921 100644 --- a/drivers/SmartThings/philips-hue/src/hue_driver_template.lua +++ b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua @@ -14,6 +14,8 @@ local lifecycle_handlers = require "handlers.lifecycle_handlers" local bridge_utils = require "utils.hue_bridge_utils" local utils = require "utils" +local batched_command_utils = require "utils.batched_command_utils" +local st_utils = require "st.utils" ---@param driver HueDriver ---@param device HueDevice @@ -104,6 +106,11 @@ function HueDriver.new_driver_template(dbg_config) [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, }, }, + + -- override the default capability message handler if batched receives are supported + capability_message_handler = type(cosock.socket.capability().receive_batch) == "function" and + HueDriver.batch_capability_message_handler or nil, + ignored_bridges = {}, joined_bridges = {}, hue_identifier_to_device_record = {}, @@ -274,4 +281,110 @@ function HueDriver.check_hue_repr_for_capability_support(hue_repr, capability_id end end +---@param cmd_table BatchedCommand +function HueDriver:handle_single_command(cmd_table) + local device = self:get_device_info(cmd_table.device_uuid) + if device ~= nil then + -- Default handler + device.thread:queue_event( + Driver.handle_capability_command, self, device, cmd_table.capability_command + ) + end +end + +---@param handler function +---@param bridge_device HueBridgeDevice +---@param group HueLightGroup +---@param cmd BatchedCommand +function HueDriver:handle_batch_command(handler, bridge_device, group, cmd) + local cap_command = cmd.capability_command + local capability = cap_command.capability + local command = cap_command.command + -- This really shouldn't ever fail especially if using unaware capabilities, but it is not the + -- end of the world if it does here since it would have failed for ALL the commands + local valid = + capabilities[capability].commands[command]:validate_and_normalize_command(cap_command) + if not valid then + log.error(string.format("Invalid batch capability command: %s.%s (%s)", + capability, command, st_utils.stringify_table(cap_command.args) + )) + else + log.info_with({hub_logs = true}, string.format( + " Handling command %s.%s (%s) as batch. Sending to %s(%s) with %d devices.", + capability, command, st_utils.stringify_table(cap_command.args), + group.type, group.metadata.name, #group.devices + )) + + bridge_device.thread:queue_event( + handler, self, bridge_device, group, cap_command, cmd.auxilary_command_data + ) + end +end + +function HueDriver:batch_capability_message_handler(capability_channel) + local batch, err = capability_channel:receive_batch() + + -- nil or empty batch + if not batch or #batch == 0 then + log.error(string.format("Error receiving batched commands: %s", err or "unknown error")) + return + end + + -- Handle common case of single command. + if #batch == 1 then + log.info("Batch not big enough to handle with group commands") + self:handle_single_command(batch[1]) + return + end + + -- Sort batch by matching commands, args, and bridge. + log.info(string.format("Sorting batch of %d commands", #batch)) + local sorted_batch, misfits = batched_command_utils.sort_batch(self, batch) + + -- Send off any misfits to be handled as a single command + if #misfits ~= 0 then + log.info(string.format("Handling %d batch misfits", #misfits)) + for _, command in ipairs(misfits) do + self:handle_single_command(command) + end + end + + log.info(string.format("Fanning out sorted batch for %d bridges", #sorted_batch)) + for bridge_device, by_command in pairs(sorted_batch) do + log.info(string.format( + " Fanning out %d distinct commands for %s", + #by_command, bridge_device.label or "unknown bridge" + )) + for command_name, by_matching_args in pairs(by_command) do + log.info(string.format( + " Fanning out %d distinct args variations for %s command", + #by_matching_args, command_name + )) + for _, matching_commands in ipairs(by_matching_args) do + log.info(string.format(" %d commands are exact arg matches", #matching_commands)) + -- Unique command + if #matching_commands == 1 then + for _, command in pairs(matching_commands) do + self:handle_single_command(command) + end + else + local matching_groups = + batched_command_utils.find_matching_groups(bridge_device, matching_commands) + -- Handle any matching groups + for group, command in pairs(matching_groups) do + local handler = batched_command_utils.get_handler(command) + if handler then -- Should never be nil at this point but to make the diagnostic happy + self:handle_batch_command(handler, bridge_device, group, command) + end + end + -- Handle any potential remaining commands + for _, command in pairs(matching_commands) do + self:handle_single_command(command) + end + end + end + end + end +end + return HueDriver diff --git a/drivers/SmartThings/philips-hue/src/types.lua b/drivers/SmartThings/philips-hue/src/types.lua index 56d006a001..56643ac96a 100644 --- a/drivers/SmartThings/philips-hue/src/types.lua +++ b/drivers/SmartThings/philips-hue/src/types.lua @@ -108,3 +108,11 @@ --- @class HueChildDevice:HueDevice --- @field public parent_assigned_child_key string + +--- @class HueGroupInfo:HueResourceInfo +--- @field public services HueServiceInfo[] +--- @field public children HueServiceInfo[] + +--- @class HueZoneInfo:HueGroupInfo + +--- @class HueRoomInfo:HueResourceInfo diff --git a/drivers/SmartThings/philips-hue/src/utils/batched_command_utils.lua b/drivers/SmartThings/philips-hue/src/utils/batched_command_utils.lua new file mode 100644 index 0000000000..bf2d615717 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/batched_command_utils.lua @@ -0,0 +1,215 @@ +local utils = require "utils" +local Fields = require "fields" +local command_handlers = require "handlers.grouped_light_commands" +local capabilities = require "st.capabilities" +local KVCounter = require "utils.kv_counter" + +--- Alias for clearer documentation. +---@alias CommandName string +--- Alias for clearer documentation. +---@alias DeviceUuid string + +--- Capability command before being normalized via st.capabilities +---@class RawCapCommand +---@field public capability string +---@field public component string +---@field public command CommandName +---@field public args any[] +---@field public named_args table? + +--- Serialized batched command from receive_batch. +--- This is already in a table format unlike the normal receive function. +---@class BatchedCommand +---@field public device_uuid DeviceUuid +---@field public capability_command RawCapCommand +---@field public auxilary_command_data table? + +--- Table where all commands match by args and any additional data needed for the command. +---@alias MatchingCommands table + +--- Table sorted by the command name. +---@alias SortedCommandNames table + +--- Sorted command batched. First sorted by the hue bridge. +---@alias SortedCommandBatch table + +--- A matching group that covers some portion of a command batch. +---@alias MatchingGroups table + + +local batched_command_utils = {} + +local switch_on_handler = command_handlers.switch_on_handler +local switch_off_handler = command_handlers.switch_off_handler +local switch_level_handler = command_handlers.switch_level_handler +local set_color_handler = command_handlers.set_color_handler +local set_hue_handler = command_handlers.set_hue_handler +local set_saturation_handler = command_handlers.set_saturation_handler +local set_color_temp_handler = command_handlers.set_color_temp_handler + +local capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_on_handler, + [capabilities.switch.commands.off.NAME] = switch_off_handler, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = switch_level_handler, + }, + [capabilities.colorControl.ID] = { + [capabilities.colorControl.commands.setColor.NAME] = set_color_handler, + [capabilities.colorControl.commands.setHue.NAME] = set_hue_handler, + [capabilities.colorControl.commands.setSaturation.NAME] = set_saturation_handler, + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, + }, +} + +-- Mapping for fields on the device that must also match for the commands to be handled in a group. +local command_name_to_aux_fields = { + [capabilities.colorControl.commands.setColor.NAME] = { + Fields.GAMUT + }, + [capabilities.colorControl.commands.setHue.NAME] = { + Fields.GAMUT, + Fields.COLOR_SATURATION, + }, + [capabilities.colorControl.commands.setSaturation.NAME] = { + Fields.GAMUT, + Fields.COLOR_HUE, + }, + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = { + Fields.MIN_KELVIN, + } +} + +---@param cmd BatchedCommand +---@return function? +function batched_command_utils.get_handler(cmd) + local capability = cmd.capability_command.capability + local command = cmd.capability_command.command + return capability_handlers[capability] and capability_handlers[capability][command] +end + +--- Sort the table by bridge, command, and matching args + any auxilary data needed for the command. +--- +--- The sorted batch is first sorted in to a table with the bridge device as a key and an inner +--- table as the value. +--- +--- The inner table is sorted by command name as the key and an inner array as the value. +--- +--- The inner array contains tables with the device id as the key and the BatchedCommand as the +--- value where all of the BatchedCommands have matching arguments and auxilary data. +--- +--- See SortedCommandBatch. +--- +---@param driver HueDriver +---@param batch BatchedCommand[] +---@return SortedCommandBatch +---@return BatchedCommand[] misfits Commands that cannot be attempted to handle in a batch +function batched_command_utils.sort_batch(driver, batch) + local sorted_batch = KVCounter() + local misfits = {} + + for _, to_inspect in ipairs(batch) do + -- Check if we can handle this in a batch + if not batched_command_utils.get_handler(to_inspect) then + misfits.insert(to_inspect) + goto continue + end + + -- First key off bridge. + local device = driver:get_device_info(to_inspect.device_uuid) + if not device then + misfits.insert(to_inspect) + goto continue + end + local parent_id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_id, true) + if not bridge_device then + misfits.insert(to_inspect) + goto continue + end + sorted_batch[bridge_device] = sorted_batch[bridge_device] or KVCounter() + local by_bridge = sorted_batch[bridge_device] + + -- Next, key off the command name. + -- Commands are unique across the capabilities supported here so avoid nesting another table + -- with capability name. + local command_name = to_inspect.capability_command.command + by_bridge[command_name] = by_bridge[command_name] or KVCounter() + local by_command = by_bridge[command_name] + + -- Add extra data that must match for the commands to be handled in a group + to_inspect.auxilary_command_data = {} + for _, field in ipairs(command_name_to_aux_fields[command_name] or {}) do + to_inspect.auxilary_command_data[field] = device:get_field(field) + end + + -- Initialize the index to the last position. + -- This will be updated if a matching command group is found. + local index = #by_command + 1 + -- Finally, group commands with matching bridge and command name by matching arguments + -- and auxilary command data. + for match_idx, matching_table in ipairs(by_command) do + -- Grab first command, all the arguments in this table are the same so it doesn't matter. + -- next is a defined function on KVCounter that uses a similar implementation as the default Lua next. + local _, to_match = matching_table.next(matching_table, nil) + + if utils.deep_table_eq(to_match.capability_command.args, to_inspect.capability_command.args) and + utils.deep_table_eq(to_match.auxilary_command_data, to_inspect.auxilary_command_data) then + -- These commands match + index = match_idx + break + end + end + if not by_command[index] then + table.insert(by_command, index, KVCounter()) + end + by_command[index][device.id] = to_inspect + + ::continue:: + end + return sorted_batch, misfits +end + +--- Find groups that the matching commands can use. +--- Larger groups are prefered and overlap is not allowed. +--- Removes commands from the provided matching commands as they are handled by matching groups. +---@param bridge_device HueBridgeDevice +---@param commands MatchingCommands +---@return MatchingGroups +function batched_command_utils.find_matching_groups(bridge_device, commands) + local groups = bridge_device:get_field(Fields.GROUPS) or {} + local matching_groups = {} + -- Groups are sortered from most to least children + for _, group in ipairs(groups) do + if #group.devices == 0 or #group.devices > #commands then + -- Can't match if the group has no light children or if it has more light children + -- than is in the command + goto continue + end + for _, device in ipairs(group.devices) do + if not commands[device.id] then + -- The commands didn't contain one of the light children for this group + goto continue + end + end + -- If we get here then we have a match. Save one of the commands for the handler + matching_groups[group] = commands[group.devices[1].id] + for _, device in ipairs(group.devices) do + -- clear out commands handled by the group + commands[device.id] = nil + end + if #commands == 0 then + -- Nothing else to handle + break + end + ::continue:: + end + return matching_groups +end + +return batched_command_utils + + diff --git a/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua new file mode 100644 index 0000000000..d1f1cd6753 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua @@ -0,0 +1,317 @@ +local log = require "log" +local Fields = require "fields" +local cosock = require "cosock" + +--- Room or zone with the children translated from their hue device id or light resource id to +--- their SmartThings represented device object. The grouped light resource id is also moved into +--- a separate field from the list of services for ease of use. +--- @class HueLightGroup:HueGroupInfo +--- @field public devices HueDevice[] +--- @field public grouped_light_rid string? + +local grouped_utils = {} + +grouped_utils.GROUP_TYPES = {room = true, zone = true} + +--- Build up mapping of hue device id to SmartThings device record +---@param bridge_device HueBridgeDevice +---@return table +local function build_hue_id_to_device_map(bridge_device) + local hue_id_to_device = {} + local children = bridge_device:get_child_list() + for _, device_record in ipairs(children) do + local hue_device_id = device_record:get_field(Fields.HUE_DEVICE_ID) + if hue_device_id ~= nil then + hue_id_to_device[hue_device_id] = device_record + end + end + return hue_id_to_device +end + +--- Search services to find grouped_light service of a room/zone +---@param group HueGroupInfo +---@return string? +local function find_grouped_light_rid(group) + local services = group.services or {} + for _, service in ipairs(services) do + if service.rtype == "grouped_light" then + return service.rid + end + end + return nil +end + +--- Take the children services in the group and transform them to smartthings device records. +--- This will be helpful when determining if a command batch matches a group and avoids having to +--- deal with hue specific ids for multiple services. +--- +--- If we don't have a device record for the child, then clear out the entire device map so +--- we don't interact with a device through groups that we don't have a record for. +---@param group HueGroupInfo|HueLightGroup +---@param hue_id_to_device table Mapping of hue ID to device record, for rooms. +---@param light_id_to_device table Mapping of light resource ID to device record, for zones. +---@return table +local function build_group_device_table(group, hue_id_to_device, light_id_to_device) + local devices = {} + local seen = {} + for _, child in ipairs(group.children) do + if child.rtype == "light" then + local device = light_id_to_device[child.rid] + if not device then + return {} + end + if not seen[device] then + seen[device] = true + table.insert(devices, device) + end + elseif child.rtype == "device" then + local device = hue_id_to_device[child.rid] + if not device then + return {} + end + if device:get_field(Fields.DEVICE_TYPE) == "light" and not seen[device] then + seen[device] = true + table.insert(devices, device) + end + end + end + return devices +end + +---@param group HueGroupInfo +---@param hue_id_to_device table +---@param light_id_to_device table +---@return HueLightGroup +local function build_hue_light_group(group, hue_id_to_device, light_id_to_device) + local light_group = group --[[@as HueLightGroup]] + light_group.devices = {} + + local grouped_light_id = find_grouped_light_rid(group) + -- If there is no way to control the lights then don't bother adding in the device records + if grouped_light_id == nil then + return light_group + end + light_group.grouped_light_rid = grouped_light_id + light_group.devices = build_group_device_table(light_group, hue_id_to_device, light_id_to_device) + + return light_group +end + +---@param group_kind string room or zone +---@param driver HueDriver +---@param hue_id_to_device table +---@param resp table? +---@param err any? +---@return HueLightGroup[]? +local function handle_group_scan_response(group_kind, driver, hue_id_to_device, resp, err) + if err or not resp then + log.error(string.format("Failed to scan for %s: %s", group_kind, err or "unknown error")) + return nil + end + if resp.errors and #resp.errors > 0 then + log.warn(string.format("Bridge replied with %d errors when scanning for %s", + #resp.errors, group_kind)) + return nil + end + if not resp.data then + log.warn(string.format("Bridge replied with no errors or data when scanning for %s", + group_kind)) + return nil + end + + log.info(string.format("Successfully got %d %s", #resp.data, group_kind)) + for _, group in ipairs(resp.data) do + build_hue_light_group(group, hue_id_to_device, driver.hue_identifier_to_device_record) + log.info(string.format("Found light group %s with %d device records", group.id, #group.devices)) + end + + return resp.data +end + +--- @param driver HueDriver +--- @param bridge_device HueBridgeDevice +--- @param api PhilipsHueApi +--- @param hue_id_to_device table +function grouped_utils.scan_groups(driver, bridge_device, api, hue_id_to_device) + local rooms, zones + while not (rooms and zones) do -- TODO: Should this be one and done? Timeout? + if not rooms then + rooms = handle_group_scan_response("rooms", driver, hue_id_to_device, api:get_rooms()) + end + if not zones then + zones = handle_group_scan_response("zones", driver, hue_id_to_device, api:get_zones()) + end + end + -- Combine rooms and zones. + for _, zone in ipairs(zones) do + table.insert(rooms, zone) + end + -- Sort from most devices to least for efficiency when checking batched commands. + table.sort(rooms, function (a, b) return #a.devices > #b.devices end) + bridge_device:set_field(Fields.GROUPS, rooms) +end + +--- Find group in known groups and return the index and group. +---@param groups HueLightGroup[] +---@param id string +---@param type string +---@return integer?, HueLightGroup? +local function find(groups, id, type) + for i, group in ipairs(groups) do + if group.id == id and group.type == type then + return i, group + end + end + return nil, nil +end + +--- Insert a group into the list of groups in correct index based off device count. +--- @param groups HueLightGroup[] +--- @param to_insert HueLightGroup +local function insert(groups, to_insert) + -- 1 if no other entries + -- last element if smallest group according to loop below + local index = #groups + 1 + + for i, group in ipairs(groups) do + if #group.devices <= #to_insert.devices then + -- if this is bigger or equal to the current index then insert here + index = i + break + end + end + table.insert(groups, index, to_insert) +end + +--- Handle room or zone update from SSE stream. +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param to_update table +function grouped_utils.group_update(driver, bridge_device, to_update) + local groups = bridge_device:get_field(Fields.GROUPS) + if groups == nil then + log.warn("Received group update before groups on bridge were initialized. Not handling.") + return + end + + local index, group = find(groups, to_update.id, to_update.type) + if not index or not group then + log.warn("Received update for group with no record. Not handling") + return + end + + local update_index = false + if to_update.children then + local devices = + build_group_device_table( + to_update, build_hue_id_to_device_map(bridge_device), driver.hue_identifier_to_device_record + ) + -- check if number of children has changed and if we need to move it + update_index = #devices ~= #group.devices + group.devices = devices + end + if to_update.services then + group.grouped_light_rid = find_grouped_light_rid(to_update) + if group.grouped_light_rid == nil then + group.devices = {} + update_index = true + end + end + + for key, value in pairs(to_update) do + group[key] = value + end + if update_index then + -- Move to the new correct index + table.remove(groups, index) + insert(groups, group) + end + log.info(string.format("Updating group %s, %d devices", group.id, #group.devices)) + bridge_device:set_field(Fields.GROUPS, groups) +end + +--- Handle new room or zone from SSE stream. +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param to_add table +function grouped_utils.group_add(driver, bridge_device, to_add) + local groups = bridge_device:get_field(Fields.GROUPS) + if groups == nil then + log.warn("Received group add before groups on bridge were initialized. Not handling.") + end + local hue_light_group = + build_hue_light_group( + to_add, build_hue_id_to_device_map(bridge_device), driver.hue_identifier_to_device_record + ) + insert(groups, hue_light_group) + log.info(string.format("Adding group %s, %d devices", + hue_light_group.id, #hue_light_group.devices)) + bridge_device:set_field(Fields.GROUPS, groups) +end + +--- Handle room or zone delete from SSE stream. +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param to_delete table +function grouped_utils.group_delete(driver, bridge_device, to_delete) + local groups = bridge_device:get_field(Fields.GROUPS) or {} + local index, group = find(groups, to_delete.id, to_delete.type) + if index and group then + log.info(string.format("Deleting group %s, %d devices", group.id, #group.devices)) + table.remove(groups, index) + bridge_device:set_field(Fields.GROUPS, groups) + else + log.warn("Received delete for group with no record.") + end +end + +function grouped_utils.set_field_on_group_devices(group, field, v) + for _, device in ipairs(group.devices) do + device:set_field(field, v) + end +end + + +function grouped_utils.queue_group_scan(driver, bridge_device) + local queue = bridge_device:get_field(Fields.GROUPS_SCAN_QUEUE) + if queue == nil then + local tx, rx = cosock.channel.new() + -- Set timeout to 30 seconds to allow for other queued scans to come in. + rx:settimeout(30) + cosock.spawn(function() + while true do + -- The goal here is to timeout on the receive. If we receive a message then another request + -- to scan came in. This queuing is to prevent a bunch of added devices from causing to have + -- to keep scanning over and over. + local _, err = rx:receive() + if err then + -- err is most likely a timeout but break for all errs + break + end + end + + -- Finally do the scan + local hue_device_table = build_hue_id_to_device_map(bridge_device) + local api = bridge_device:get_field(Fields.BRIDGE_API) + + if api == nil then + log.warn("Bridge api is nil, unable to do group scan") + -- Clear out tx for the next request + bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, nil) + return + end + + grouped_utils.scan_groups(driver, bridge_device, api, hue_device_table) + + -- Clear out tx for the next request + bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, nil) + end) + -- Set the queue to the tx + bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, tx) + else + -- Send on the tx that another request has come in. + queue:send(nil) + end +end + +return grouped_utils diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua index bbc1a0434f..53d2cd2698 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -21,6 +21,7 @@ local lifecycle_handlers = require "handlers.lifecycle_handlers" local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" local lunchbox_util = require "lunchbox.util" local utils = require "utils" +local grouped_utils = require "utils.grouped_utils" ---@class hue_bridge_utils local hue_bridge_utils = {} @@ -125,7 +126,8 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u ::continue:: end - end, string.format("Hue Bridge %s Zigbee Scan Task", bridge_device.label)) + grouped_utils.queue_group_scan(driver, bridge_device) + end, string.format("Hue Bridge %s On Connect Task", bridge_device.label)) end eventsource.onerror = function() @@ -174,6 +176,8 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u table.insert(resource_ids, rid) end end + elseif grouped_utils.GROUP_TYPES[update_data.type] then + grouped_utils.group_update(driver, bridge_device, update_data) else --- for a regular message from a device doing something normal, --- you get the resource id of the device service for that device in @@ -211,6 +215,8 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u child_device.log.trace("Attempting to delete Device UUID " .. tostring(child_device.id)) driver:do_hue_child_delete(child_device) end + elseif grouped_utils.GROUP_TYPES[delete_data.type] then + grouped_utils.group_delete(driver, bridge_device, delete_data) end end elseif event.type == "add" then @@ -234,6 +240,8 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u bridge_device ) end, "New Device Event Task") + elseif grouped_utils.GROUP_TYPES[add_data.type] then + grouped_utils.group_add(driver, bridge_device, add_data) end end end diff --git a/drivers/SmartThings/philips-hue/src/utils/init.lua b/drivers/SmartThings/philips-hue/src/utils/init.lua index 54f83edc95..4c5a29dc54 100644 --- a/drivers/SmartThings/philips-hue/src/utils/init.lua +++ b/drivers/SmartThings/philips-hue/src/utils/init.lua @@ -346,8 +346,9 @@ end ---@param device HueDevice ---@param parent_device_id string? ---@return HueBridgeDevice? bridge_device -function utils.get_hue_bridge_for_device(driver, device, parent_device_id) - log.trace(string.format("------------------------ Looking for bridge for %s with parent_device_id %s", device.label, device.parent_device_id)) +function utils.get_hue_bridge_for_device(driver, device, parent_device_id, quiet) + local _ = quiet or + log.trace(string.format("------------------------ Looking for bridge for %s with parent_device_id %s", device.label, device.parent_device_id)) if utils.is_bridge(driver, device) then log.trace(string.format("------------------------- %s is a bridge", device.label)) return device --[[ @as HueBridgeDevice ]] @@ -356,16 +357,18 @@ function utils.get_hue_bridge_for_device(driver, device, parent_device_id) local parent_device_id = parent_device_id or device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) local parent_device = driver:get_device_info(parent_device_id) if not parent_device then - log.trace(string.format("------------------------- get_device_info for %s was nil", parent_device_id)) + local _ = quiet or + log.trace(string.format("------------------------- get_device_info for %s was nil", parent_device_id)) return nil end - log.trace(string.format("------------------------- parent_device label is %s, checking if bridge", parent_device.label)) + local _ = quiet or + log.trace(string.format("------------------------- parent_device label is %s, checking if bridge", parent_device.label)) if parent_device and utils.is_bridge(driver, parent_device) then return parent_device --[[ @as HueBridgeDevice ]] end - return utils.get_hue_bridge_for_device(driver, parent_device) + return utils.get_hue_bridge_for_device(driver, parent_device, quiet) end --- build a exponential backoff time value generator diff --git a/drivers/SmartThings/philips-hue/src/utils/kv_counter.lua b/drivers/SmartThings/philips-hue/src/utils/kv_counter.lua new file mode 100644 index 0000000000..1c2bdbe3a2 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/kv_counter.lua @@ -0,0 +1,61 @@ +--- Table with metamethods to track the number of items inside of a table including KV pairs. +local KVCounter = {} + +-- Helper function to implement next on KVCounter. +-- There is no metamethod to do this but is useful for getting a value without knowing the keys. +local function kv_counter_next(t, index) + local mt = getmetatable(t) + return next(mt.inner, index) +end + +local function kv_counter_index(t, k) + local mt = getmetatable(t) + + if k == "next" then + return kv_counter_next + end + + return mt.inner[k] +end + +local function kv_counter_newindex(t, k, v) + assert(k ~= "next", "next is an unallowed key in KVCounter") + + local mt = getmetatable(t) + local existed = mt.inner[k] ~= nil + if existed and v == nil then + mt.count = mt.count - 1 + elseif not existed and v ~= nil then + mt.count = mt.count + 1 + end + mt.inner[k] = v +end + +local function kv_counter_pairs(t) + local mt = getmetatable(t) + return pairs(mt.inner) +end + +local function kv_counter_len(t) + local mt = getmetatable(t) + return mt.count +end + +local function kv_counter_factory() + local mt = { + -- Avoid blowing up lua closures by defining these outside of the function. + __index = kv_counter_index, + __newindex = kv_counter_newindex, + __pairs = kv_counter_pairs, + __len = kv_counter_len, + count = 0, + inner = {} + } + return setmetatable({}, mt) +end + +setmetatable(KVCounter, { + __call = kv_counter_factory +}) + +return KVCounter