Skip to content

Commit 7f479e2

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

File tree

2 files changed

+335
-0
lines changed

2 files changed

+335
-0
lines changed

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

Lines changed: 117 additions & 0 deletions
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
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
local to_match_table = {}
114+
115+
for _, to_inspect in ipairs(batch) do
116+
-- Check if we can handle this in a batch
117+
if not batched_command_utils.get_handler(to_inspect) then
118+
misfits.insert(to_inspect)
119+
goto continue
120+
end
121+
122+
-- First key off bridge.
123+
local device = driver:get_device_info(to_inspect.device_uuid)
124+
if not device then
125+
misfits.insert(to_inspect)
126+
goto continue
127+
end
128+
local parent_id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)
129+
local bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_id, true)
130+
if not bridge_device then
131+
misfits.insert(to_inspect)
132+
goto continue
133+
end
134+
sorted_batch[bridge_device] = sorted_batch[bridge_device] or KVCounter()
135+
local by_bridge = sorted_batch[bridge_device]
136+
137+
-- Next, key off the command name.
138+
-- Commands are unique across the capabilities supported here so avoid nesting another table
139+
-- with capability name.
140+
local command_name = to_inspect.capability_command.command
141+
by_bridge[command_name] = by_bridge[command_name] or KVCounter()
142+
local by_command = by_bridge[command_name]
143+
144+
-- Add extra data that must match for the commands to be handled in a group
145+
to_inspect.auxilary_command_data = {}
146+
for _, field in ipairs(command_name_to_aux_fields[command_name] or {}) do
147+
to_inspect.auxilary_command_data[field] = device:get_field(field)
148+
end
149+
150+
-- Initialize the index to the last position.
151+
-- This will be updated if a matching command group is found.
152+
local index = #by_command + 1
153+
-- Finally, group commands with matching bridge and command name by matching arguments
154+
-- and auxilary command data.
155+
for match_idx, _ in ipairs(by_command) do
156+
-- Grab first command, all the arguments in this table are the same so it doesn't matter.
157+
local to_match = to_match_table[match_idx]
158+
159+
if utils.deep_table_eq(to_match.capability_command.args, to_inspect.capability_command.args) and
160+
utils.deep_table_eq(to_match.auxilary_command_data, to_inspect.auxilary_command_data) then
161+
-- These commands match
162+
index = match_idx
163+
break
164+
end
165+
end
166+
if not by_command[index] then
167+
table.insert(by_command, index, KVCounter())
168+
-- Save this off to grab it easily later
169+
to_match_table[index] = to_inspect
170+
end
171+
by_command[index][device.id] = to_inspect
172+
173+
::continue::
174+
end
175+
return sorted_batch, misfits
176+
end
177+
178+
--- Find groups that the matching commands can use.
179+
--- Larger groups are prefered and overlap is not allowed.
180+
--- Removes commands from the provided matching commands as they are handled by matching groups.
181+
---@param bridge_device HueBridgeDevice
182+
---@param commands MatchingCommands
183+
---@return MatchingGroups
184+
function batched_command_utils.find_matching_groups(bridge_device, commands)
185+
local groups = bridge_device:get_field(Fields.GROUPS) or {}
186+
local matching_groups = {}
187+
-- Groups are sortered from most to least children
188+
for _, group in ipairs(groups) do
189+
if #group.devices == 0 or #group.devices > #commands then
190+
-- Can't match if the group has no light children or if it has more light children
191+
-- than is in the command
192+
goto continue
193+
end
194+
for _, device in ipairs(group.devices) do
195+
if not commands[device.id] then
196+
-- The commands didn't contain one of the light children for this group
197+
goto continue
198+
end
199+
end
200+
-- If we get here then we have a match. Save one of the commands for the handler
201+
matching_groups[group] = commands[group.devices[1].id]
202+
for _, device in ipairs(group.devices) do
203+
-- clear out commands handled by the group
204+
commands[device.id] = nil
205+
end
206+
if #commands == 0 then
207+
-- Nothing else to handle
208+
break
209+
end
210+
-- TODO: Break after a single group match?
211+
::continue::
212+
end
213+
return matching_groups
214+
end
215+
216+
return batched_command_utils
217+
218+

0 commit comments

Comments
 (0)