Skip to content

Commit 3307b88

Browse files
committed
philips-hue: Add batched command handling support
1 parent d3c0baf commit 3307b88

File tree

2 files changed

+336
-0
lines changed

2 files changed

+336
-0
lines changed

drivers/SmartThings/philips-hue/src/hue_driver_template.lua

+117
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ local lifecycle_handlers = require "handlers.lifecycle_handlers"
1414

1515
local bridge_utils = require "utils.hue_bridge_utils"
1616
local utils = require "utils"
17+
local batched_command_utils = require "utils.batched_command_utils"
18+
local st_utils = require "st.utils"
1719

1820
---@param driver HueDriver
1921
---@param device HueDevice
@@ -104,6 +106,11 @@ function HueDriver.new_driver_template(dbg_config)
104106
[capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler,
105107
},
106108
},
109+
110+
-- override the default capability message handler if batched receives are supported
111+
capability_message_handler = type(cosock.socket.capability().receive_batch) == "function" and
112+
HueDriver.batch_capability_message_handler or nil,
113+
107114
ignored_bridges = {},
108115
joined_bridges = {},
109116
hue_identifier_to_device_record = {},
@@ -274,4 +281,114 @@ function HueDriver.check_hue_repr_for_capability_support(hue_repr, capability_id
274281
end
275282
end
276283

284+
---@param cmd_table BatchedCommand
285+
function HueDriver:handle_single_command(cmd_table)
286+
local device = self:get_device_info(cmd_table.device_uuid)
287+
if device ~= nil then
288+
-- Default handler
289+
device.thread:queue_event(
290+
Driver.handle_capability_command, self, device, cmd_table.capability_command
291+
)
292+
end
293+
end
294+
295+
---@param handler function
296+
---@param bridge_device HueBridgeDevice
297+
---@param group HueLightGroup
298+
---@param cmd BatchedCommand
299+
function HueDriver:handle_batch_command(handler, bridge_device, group, cmd)
300+
local cap_command = cmd.capability_command
301+
local capability = cap_command.capability
302+
local command = cap_command.command
303+
-- This really shouldn't ever fail especially if using unaware capabilities, but it is not the
304+
-- end of the world if it does here since it would have failed for ALL the commands
305+
local valid =
306+
capabilities[capability].commands[command]:validate_and_normalize_command(cap_command)
307+
if not valid then
308+
log.error(string.format("Invalid batch capability command: %s.%s (%s)",
309+
capability, command, st_utils.stringify_table(cap_command.args)
310+
))
311+
else
312+
log.info_with({hub_logs = true}, string.format(
313+
" Handling command %s.%s (%s) as batch. Sending to %s(%s) with %d devices.",
314+
capability, command, st_utils.stringify_table(cap_command.args),
315+
group.type, group.metadata.name, #group.devices
316+
))
317+
318+
bridge_device.thread:queue_event(
319+
handler, self, bridge_device, group, cap_command, cmd.auxilary_command_data
320+
)
321+
end
322+
end
323+
324+
function HueDriver:batch_capability_message_handler(capability_channel)
325+
local batch, err = capability_channel:receive_batch()
326+
327+
-- nil or empty batch
328+
if not batch or #batch == 0 then
329+
log.error(string.format("Error receiving batched commands: %s", err or "unknown error"))
330+
return
331+
end
332+
333+
-- Handle common case of single command.
334+
--
335+
-- TODO: Optimize more, only try to handle in a batch for x number of commands?
336+
if #batch == 1 then
337+
log.info("Batch not big enough to handle with group commands")
338+
self:handle_single_command(batch[1])
339+
return
340+
end
341+
342+
-- Sort batch by matching commands, args, and bridge.
343+
log.info(string.format("Sorting batch of %d commands", #batch))
344+
local sorted_batch, misfits = batched_command_utils.sort_batch(self, batch)
345+
346+
-- Send off any misfits to be handled as a single command
347+
if #misfits ~= 0 then
348+
log.info(string.format("Handling %d batch misfits", #misfits))
349+
for _, command in ipairs(misfits) do
350+
self:handle_single_command(command)
351+
end
352+
end
353+
354+
log.info(string.format("Fanning out sorted batch for %d bridges", #sorted_batch))
355+
for bridge_device, by_command in pairs(sorted_batch) do
356+
log.info(string.format(
357+
" Fanning out %d distinct commands for %s",
358+
#by_command, bridge_device.label or "unknown bridge"
359+
))
360+
for command_name, by_matching_args in pairs(by_command) do
361+
log.info(string.format(
362+
" Fanning out %d distinct args variations for %s command",
363+
#by_matching_args, command_name
364+
))
365+
for _, matching_commands in ipairs(by_matching_args) do
366+
log.info(string.format(" %d commands are exact arg matches", #matching_commands))
367+
-- Unique command
368+
--
369+
-- TODO: Optimize more, only try to handle in a batch for x number of commands?
370+
if #matching_commands == 1 then
371+
for _, command in pairs(matching_commands) do
372+
self:handle_single_command(command)
373+
end
374+
else
375+
local matching_groups =
376+
batched_command_utils.find_matching_groups(bridge_device, matching_commands)
377+
-- Handle any matching groups
378+
for group, command in pairs(matching_groups) do
379+
local handler = batched_command_utils.get_handler(command)
380+
if handler then -- Should never be nil at this point but to make the diagnostic happy
381+
self:handle_batch_command(handler, bridge_device, group, command)
382+
end
383+
end
384+
-- Handle any potential remaining commands
385+
for _, command in pairs(matching_commands) do
386+
self:handle_single_command(command)
387+
end
388+
end
389+
end
390+
end
391+
end
392+
end
393+
277394
return HueDriver
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
local utils = require "utils"
2+
local Fields = require "fields"
3+
local command_handlers = require "handlers.grouped_light_commands"
4+
local capabilities = require "st.capabilities"
5+
local KVCounter = require "utils.kv_counter"
6+
7+
--- Alias for clearer documentation.
8+
---@alias CommandName string
9+
--- Alias for clearer documentation.
10+
---@alias DeviceUuid string
11+
12+
--- Capability command before being normalized via st.capabilities
13+
---@class RawCapCommand
14+
---@field public capability string
15+
---@field public component string
16+
---@field public command CommandName
17+
---@field public args any[]
18+
---@field public named_args table?
19+
20+
--- Serialized batched command from receive_batch.
21+
--- This is already in a table format unlike the normal receive function.
22+
---@class BatchedCommand
23+
---@field public device_uuid DeviceUuid
24+
---@field public capability_command RawCapCommand
25+
---@field public auxilary_command_data table?
26+
27+
--- Table where all commands match by args and any additional data needed for the command.
28+
---@alias MatchingCommands table<DeviceUuid, BatchedCommand>
29+
30+
--- Table sorted by the command name.
31+
---@alias SortedCommandNames table<CommandName, MatchingCommands[]>
32+
33+
--- Sorted command batched. First sorted by the hue bridge.
34+
---@alias SortedCommandBatch table<HueBridgeDevice, SortedCommandNames>
35+
36+
--- A matching group that covers some portion of a command batch.
37+
---@alias MatchingGroups table<HueLightGroup, BatchedCommand>
38+
39+
40+
local batched_command_utils = {}
41+
42+
local switch_on_handler = command_handlers.switch_on_handler
43+
local switch_off_handler = command_handlers.switch_off_handler
44+
local switch_level_handler = command_handlers.switch_level_handler
45+
local set_color_handler = command_handlers.set_color_handler
46+
local set_hue_handler = command_handlers.set_hue_handler
47+
local set_saturation_handler = command_handlers.set_saturation_handler
48+
local set_color_temp_handler = command_handlers.set_color_temp_handler
49+
50+
local capability_handlers = {
51+
[capabilities.switch.ID] = {
52+
[capabilities.switch.commands.on.NAME] = switch_on_handler,
53+
[capabilities.switch.commands.off.NAME] = switch_off_handler,
54+
},
55+
[capabilities.switchLevel.ID] = {
56+
[capabilities.switchLevel.commands.setLevel.NAME] = switch_level_handler,
57+
},
58+
[capabilities.colorControl.ID] = {
59+
[capabilities.colorControl.commands.setColor.NAME] = set_color_handler,
60+
[capabilities.colorControl.commands.setHue.NAME] = set_hue_handler,
61+
[capabilities.colorControl.commands.setSaturation.NAME] = set_saturation_handler,
62+
},
63+
[capabilities.colorTemperature.ID] = {
64+
[capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler,
65+
},
66+
}
67+
68+
-- Mapping for fields on the device that must also match for the commands to be handled in a group.
69+
local command_name_to_aux_fields = {
70+
[capabilities.colorControl.commands.setColor.NAME] = {
71+
Fields.GAMUT
72+
},
73+
[capabilities.colorControl.commands.setHue.NAME] = {
74+
Fields.GAMUT,
75+
Fields.COLOR_SATURATION,
76+
},
77+
[capabilities.colorControl.commands.setSaturation.NAME] = {
78+
Fields.GAMUT,
79+
Fields.COLOR_HUE,
80+
},
81+
[capabilities.colorTemperature.commands.setColorTemperature.NAME] = {
82+
Fields.MIN_KELVIN,
83+
}
84+
}
85+
86+
---@param cmd BatchedCommand
87+
---@return function?
88+
function batched_command_utils.get_handler(cmd)
89+
local capability = cmd.capability_command.capability
90+
local command = cmd.capability_command.command
91+
return capability_handlers[capability] and capability_handlers[capability][command]
92+
end
93+
94+
--- Sort the table by bridge, command, and matching args + any auxilary data needed for the command.
95+
---
96+
--- The sorted batch is first sorted in to a table with the bridge device as a key and an inner
97+
--- table as the value.
98+
---
99+
--- The inner table is sorted by command name as the key and an inner array as the value.
100+
---
101+
--- The inner array contains tables with the device id as the key and the BatchedCommand as the
102+
--- value where all of the BatchedCommands have matching arguments and auxilary data.
103+
---
104+
--- See SortedCommandBatch.
105+
---
106+
---@param driver HueDriver
107+
---@param batch BatchedCommand[]
108+
---@return SortedCommandBatch
109+
---@return BatchedCommand[] misfits Commands that cannot be attempted to handle in a batch
110+
function batched_command_utils.sort_batch(driver, batch)
111+
local sorted_batch = KVCounter()
112+
local misfits = {}
113+
114+
for _, to_inspect in ipairs(batch) do
115+
-- Check if we can handle this in a batch
116+
if not batched_command_utils.get_handler(to_inspect) then
117+
misfits.insert(to_inspect)
118+
goto continue
119+
end
120+
121+
-- First key off bridge.
122+
local device = driver:get_device_info(to_inspect.device_uuid)
123+
if not device then
124+
misfits.insert(to_inspect)
125+
goto continue
126+
end
127+
local parent_id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)
128+
local bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_id, true)
129+
if not bridge_device then
130+
misfits.insert(to_inspect)
131+
goto continue
132+
end
133+
sorted_batch[bridge_device] = sorted_batch[bridge_device] or KVCounter()
134+
local by_bridge = sorted_batch[bridge_device]
135+
136+
-- Next, key off the command name.
137+
-- Commands are unique across the capabilities supported here so avoid nesting another table
138+
-- with capability name.
139+
local command_name = to_inspect.capability_command.command
140+
by_bridge[command_name] = by_bridge[command_name] or KVCounter()
141+
local by_command = by_bridge[command_name]
142+
143+
-- Add extra data that must match for the commands to be handled in a group
144+
to_inspect.auxilary_command_data = {}
145+
for _, field in ipairs(command_name_to_aux_fields[command_name] or {}) do
146+
to_inspect.auxilary_command_data[field] = device:get_field(field)
147+
end
148+
149+
-- Initialize the index to the last position.
150+
-- This will be updated if a matching command group is found.
151+
local index = #by_command + 1
152+
-- Finally, group commands with matching bridge and command name by matching arguments
153+
-- and auxilary command data.
154+
for match_idx, matching_table in ipairs(by_command) do
155+
-- Grab first command, all the arguments in this table are the same so it doesn't matter.
156+
local _, to_match = next(matching_table, nil)
157+
if to_match == nil then
158+
-- This shouldn't be possible, but just put command in the last index if it does happen
159+
break
160+
end
161+
162+
if utils.deep_table_eq(to_match.capability_command.args, to_inspect.capability_command.args) and
163+
utils.deep_table_eq(to_match.auxilary_command_data, to_inspect.auxilary_command_data) then
164+
-- These commands match
165+
index = match_idx
166+
break
167+
end
168+
end
169+
if not by_command[index] then
170+
table.insert(by_command, index, KVCounter())
171+
end
172+
by_command[index][device.id] = to_inspect
173+
174+
::continue::
175+
end
176+
return sorted_batch, misfits
177+
end
178+
179+
--- Find groups that the matching commands can use.
180+
--- Larger groups are prefered and overlap is not allowed.
181+
--- Removes commands from the provided matching commands as they are handled by matching groups.
182+
---@param bridge_device HueBridgeDevice
183+
---@param commands MatchingCommands
184+
---@return MatchingGroups
185+
function batched_command_utils.find_matching_groups(bridge_device, commands)
186+
local groups = bridge_device:get_field(Fields.GROUPS) or {}
187+
local matching_groups = {}
188+
-- Groups are sortered from most to least children
189+
for _, group in ipairs(groups) do
190+
if #group.devices == 0 or #group.devices > #commands then
191+
-- Can't match if the group has no light children or if it has more light children
192+
-- than is in the command
193+
goto continue
194+
end
195+
for _, device in ipairs(group.devices) do
196+
if not commands[device.id] then
197+
-- The commands didn't contain one of the light children for this group
198+
goto continue
199+
end
200+
end
201+
-- If we get here then we have a match. Save one of the commands for the handler
202+
matching_groups[group] = commands[group.devices[1].id]
203+
for _, device in ipairs(group.devices) do
204+
-- clear out commands handled by the group
205+
commands[device.id] = nil
206+
end
207+
if #commands == 0 then
208+
-- Nothing else to handle
209+
break
210+
end
211+
-- TODO: Break after a single group match?
212+
::continue::
213+
end
214+
return matching_groups
215+
end
216+
217+
return batched_command_utils
218+
219+

0 commit comments

Comments
 (0)