Skip to content

Commit

Permalink
Merge pull request SmartThingsCommunity#1126 from SmartThingsCommunit…
Browse files Browse the repository at this point in the history
…y/feat/philips-hue-automatic-add-delete
  • Loading branch information
dljsjr authored Jan 17, 2024
2 parents 55cbf26 + 2ceb196 commit fe7e701
Show file tree
Hide file tree
Showing 2 changed files with 186 additions and 32 deletions.
45 changes: 43 additions & 2 deletions drivers/SmartThings/philips-hue/src/disco.lua
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,31 @@ function HueDiscovery.search_for_bridges(driver, computed_mac_addresses, callbac
end
end

function HueDiscovery.scan_bridge_and_update_devices(driver, bridge_id)
if driver.ignored_bridges[bridge_id] then return end

local known_identifier_to_device_map = {}
for _, device in ipairs(driver:get_devices()) do
-- the bridge won't have a parent assigned key so we give that boolean short circuit preference
local dni = device.parent_assigned_child_key or device.device_network_id
known_identifier_to_device_map[dni] = device
end

local known_bridge_device = known_identifier_to_device_map[bridge_id]
if known_bridge_device and known_bridge_device:get_field(Fields.API_KEY) then
HueDiscovery.api_keys[bridge_id] = known_bridge_device:get_field(Fields.API_KEY)
end

HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, HueDiscovery.disco_api_instances[bridge_id],
function(hue_driver, svc_info, device_info)
discovered_device_callback(hue_driver, bridge_id, svc_info, device_info, known_identifier_to_device_map)
end,
"[Discovery: " ..
(known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge") .. " bridge re-scan]",
true
)
end

---@param driver HueDriver
---@param bridge_ip string
---@param bridge_id string
Expand All @@ -109,7 +134,7 @@ discovered_bridge_callback = function(driver, bridge_ip, bridge_id, known_identi
utils.labeled_socket_builder((known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge"))
)

HueDiscovery.search_bridge_for_supported_devices(driver, HueDiscovery.disco_api_instances[bridge_id],
HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, HueDiscovery.disco_api_instances[bridge_id],
function(hue_driver, svc_info, device_info)
discovered_device_callback(hue_driver, bridge_id, svc_info, device_info, known_identifier_to_device_map)
end,
Expand Down Expand Up @@ -182,9 +207,10 @@ discovered_bridge_callback = function(driver, bridge_ip, bridge_id, known_identi
end

---@param driver HueDriver
---@param bridge_id string
---@param api_instance PhilipsHueApi
---@param callback fun(driver: HueDriver, svc_info: table, device_data: table)
function HueDiscovery.search_bridge_for_supported_devices(driver, api_instance, callback, log_prefix)
function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, api_instance, callback, log_prefix, do_delete)
local prefix = ""
if type(log_prefix) == "string" and #log_prefix > 0 then prefix = log_prefix .. " " end

Expand All @@ -203,9 +229,11 @@ function HueDiscovery.search_bridge_for_supported_devices(driver, api_instance,
return
end

local device_is_joined_to_bridge = {}
for _, device_data in ipairs(devices.data or {}) do
for _, svc_info in ipairs(device_data.services or {}) do
if is_device_service_supported(svc_info) then
device_is_joined_to_bridge[device_data.id] = true
log.info_with({ hub_logs = true }, string.format(
prefix ..
"Processing supported svc [rid: %s | type: %s] for Hue device [v2_id: %s | v1_id: %s], with Hue provided name: %s",
Expand All @@ -222,6 +250,19 @@ function HueDiscovery.search_bridge_for_supported_devices(driver, api_instance,
end
end
end

if do_delete then
for _, device in ipairs(driver:get_devices()) do ---@cast device HueDevice
local not_known_to_bridge = device_is_joined_to_bridge[device:get_field(Fields.HUE_DEVICE_ID) or ""]
local parent_device_id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) or ""
local parent_bridge_device = driver:get_device_info(parent_device_id)
local is_child_of_bridge = parent_bridge_device and (parent_bridge_device:get_field(Fields.BRIDGE_ID) == bridge_id)
if is_child_of_bridge and not not_known_to_bridge then
device.log.info(string.format("Device is no longer joined to Hue Bridge %q, deleting", parent_bridge_device.label))
driver:do_hue_light_delete(device)
end
end
end
end

---@param driver HueDriver
Expand Down
173 changes: 143 additions & 30 deletions drivers/SmartThings/philips-hue/src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -694,10 +694,10 @@ light_added = function(driver, device, parent_device_id, resource_id)
handlers.refresh_handler(driver, device)
end

local function do_bridge_network_init(driver, device, bridge_url, api_key)
if not device:get_field(Fields.EVENT_SOURCE) then
local function do_bridge_network_init(driver, bridge_device, bridge_url, api_key)
if not bridge_device:get_field(Fields.EVENT_SOURCE) then
log.info_with({ hub_logs = true }, "Creating SSE EventSource for bridge " ..
(device.label or device.device_network_id or device.id or "unknown bridge"))
(bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge"))
local url_table = lunchbox_util.force_url_table(bridge_url .. "/eventstream/clip/v2")
local eventsource = EventSource.new(
url_table,
Expand All @@ -707,14 +707,15 @@ local function do_bridge_network_init(driver, device, bridge_url, api_key)

eventsource.onopen = function(msg)
log.info_with({ hub_logs = true },
string.format("Event Source Connection for Hue Bridge \"%s\" established, marking online", device.label))
device:online()
string.format("Event Source Connection for Hue Bridge \"%s\" established, marking online", bridge_device.label))
bridge_device:online()

local bridge_api = device:get_field(Fields.BRIDGE_API)
local bridge_api = bridge_device:get_field(Fields.BRIDGE_API)
cosock.spawn(function()
Discovery.scan_bridge_and_update_devices(driver, bridge_device:get_field(Fields.BRIDGE_ID))
local child_device_map = {}
local children = device:get_child_list()
device.log.debug(string.format("Scanning connectivity of %s child devices", #children))
local children = bridge_device:get_child_list()
bridge_device.log.debug(string.format("Scanning connectivity of %s child devices", #children))
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
Expand All @@ -730,7 +731,7 @@ local function do_bridge_network_init(driver, device, bridge_url, api_key)
connectivity_status, rest_err = bridge_api:get_connectivity_status()
if rest_err ~= nil then
log.error(string.format("Couldn't query Hue Bridge %s for zigbee connectivity status for child devices: %s",
device.label, st_utils.stringify_table(rest_err, "Rest Error", true)))
bridge_device.label, st_utils.stringify_table(rest_err, "Rest Error", true)))
goto continue
end

Expand All @@ -739,7 +740,7 @@ local function do_bridge_network_init(driver, device, bridge_url, api_key)
string.format(
"Hue Bridge %s replied with the following error message(s) " ..
"when querying child device connectivity status:",
device.label
bridge_device.label
)
)
for idx, err in ipairs(connectivity_status.errors) do
Expand Down Expand Up @@ -770,18 +771,18 @@ local function do_bridge_network_init(driver, device, bridge_url, api_key)
end
::continue::
end
end, string.format("Hue Bridge %s Zigbee Scan Task", device.label))
end, string.format("Hue Bridge %s Zigbee Scan Task", bridge_device.label))
end

eventsource.onerror = function()
log.error_with({ hub_logs = true }, string.format("Hue Bridge \"%s\" Event Source Error", device.label))
log.error_with({ hub_logs = true }, string.format("Hue Bridge \"%s\" Event Source Error", bridge_device.label))

for _, device_record in ipairs(device:get_child_list()) do
for _, device_record in ipairs(bridge_device:get_child_list()) do
device_record:set_field(Fields.IS_ONLINE, false)
device_record:offline()
end

device:offline()
bridge_device:offline()
end

eventsource.onmessage = function(msg)
Expand All @@ -802,9 +803,6 @@ local function do_bridge_network_init(driver, device, bridge_url, api_key)
end

for _, event in ipairs(events) do
log.trace(
string.format("Bridge %s processing event from SSE stream: %s",
(device.label or device.id or "unknown device"), st_utils.stringify_table(event)))
if event.type == "update" then
for _, update_data in ipairs(event.data) do
--- for a regular message from a light doing something normal,
Expand All @@ -830,38 +828,144 @@ local function do_bridge_network_init(driver, device, bridge_url, api_key)
string.format(
"Light device \"%s\" was deleted from hue bridge %s",
(light_device.label or light_device.id or "unknown device"),
(device.label or device.device_network_id or device.id or "unknown bridge")
(bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge")
)
)
light_device:set_field(Fields.IS_ONLINE, false)
light_device:offline()
light_device.log.trace("Attempting to delete Device UUID " .. tostring(light_device.id))
driver:do_hue_light_delete(light_device)
end
end
end
elseif event.type == "add" then
for _, add_data in ipairs(event.data) do
if add_data.type == "light" then
if add_data.type == "light" and add_data.owner and add_data.owner.rtype == "device" then
log.info(
string.format(
"New light added to Hue Bridge \"%s\", light properties: \"%s\", " ..
"re-run discovery to join new lights to SmartThings",
device.label, st_utils.stringify_table(add_data, nil, false)
"New light added to Hue Bridge \"%s\", light properties: \"%s\"",
bridge_device.label, json.encode(add_data)
)
)

cosock.spawn(function()
local hue_api = bridge_device:get_field(Fields.BRIDGE_API)
if hue_api == nil then
bridge_device.log.warn("No Hue API instance available for new light event.")
return
end

local hue_device_rid = add_data.owner.rid
local rest_resp, rest_err = hue_api:get_device_by_id(hue_device_rid)

if rest_err ~= nil then
log.error(
string.format(
"Error getting device information for new light \"%s\" with device RID %s: %s",
add_data.metadata.name,
hue_device_rid,
st_utils.stringify_table(rest_err)
)
)
return
end

if rest_resp == nil then
log.error("REST Response while handling New Light Event unexpectedly nil without error message")
return
end

if rest_resp.errors and #rest_resp.errors > 0 then
for _, hue_error in ipairs(rest_resp.errors) do
log.error_with({ hub_logs = true }, "Error in Hue API response: " .. hue_error.description)
end
return
end

local new_device_info = nil
for _, hue_device in ipairs(rest_resp.data or {}) do
for _, svc_info in ipairs(hue_device.services or {}) do
if svc_info.rtype == "light" and svc_info.rid == add_data.id then
new_device_info = hue_device
break
end
end
if new_device_info ~= nil then break end
end

if new_device_info == nil then
log.warn("Couldn't get all device info for new light, unable to join. Try using Scan Nearby to find new Hue lights.")
return
end

log.info(
string.format(
"Adding light \"%s\"",
add_data.metadata.name
)
)

local profile_ref

if add_data.color then
if add_data.color_temperature then
profile_ref = "white-and-color-ambiance"
else
profile_ref = "legacy-color"
end
elseif add_data.color_temperature then
profile_ref = "white-ambiance" -- all color temp products support `white` (dimming)
elseif add_data.dimming then
profile_ref = "white" -- `white` refers to dimmable and includes filament bulbs
else
log.warn(
string.format(
"Light resource [%s] does not seem to be A White/White-Ambiance/White-Color-Ambiance device, currently unsupported"
,
add_data.id
)
)
return
end

local create_device_msg = {
type = "EDGE_CHILD",
label = add_data.metadata.name,
vendor_provided_label = new_device_info.product_data.product_name,
profile = profile_ref,
manufacturer = new_device_info.product_data.manufacturer_name,
model = new_device_info.product_data.model_id,
parent_device_id = bridge_device.id,
parent_assigned_child_key = add_data.id,
}

Discovery.light_state_disco_cache[add_data.id] = {
hue_provided_name = add_data.metadata.name,
id = add_data.id,
on = add_data.on,
color = add_data.color,
dimming = add_data.dimming,
color_temperature = add_data.color_temperature,
mode = add_data.mode,
parent_device_id = bridge_device.id,
hue_device_id = add_data.owner.rid,
hue_device_data = new_device_info
}

driver:try_create_device(create_device_msg)
end, "New Device Event Task")
end
end
end
end
end
end

device:set_field(Fields.EVENT_SOURCE, eventsource, { persist = false })
bridge_device:set_field(Fields.EVENT_SOURCE, eventsource, { persist = false })
end
device:set_field(Fields._INIT, true, { persist = false })
bridge_device:set_field(Fields._INIT, true, { persist = false })
local ids_to_remove = {}
for id, light_device in ipairs(driver._lights_pending_refresh) do
local bridge_id = light_device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)
if bridge_id == device.id then
local bridge_id = light_device.parent_device_id or bridge_device:get_field(Fields.PARENT_DEVICE_ID)
if bridge_id == bridge_device.id then
table.insert(ids_to_remove, id)
handlers.refresh_handler(driver, light_device)
end
Expand All @@ -872,7 +976,7 @@ local function do_bridge_network_init(driver, device, bridge_url, api_key)
driver.stray_bulb_tx:send({
type = StrayDeviceMessageTypes.FoundBridge,
driver = driver,
device = device
device = bridge_device
})
end

Expand Down Expand Up @@ -1149,7 +1253,7 @@ cosock.spawn(function()
(msg.device.label or msg.device.device_network_id or msg.device.id or "unknown bridge")
)
)
Discovery.search_bridge_for_supported_devices(thread_local_driver, api_instance,
Discovery.search_bridge_for_supported_devices(thread_local_driver, msg_device:get_field(Fields.BRIDGE_ID), api_instance,
function(hue_driver, svc_info, device_data)
if not (svc_info.rid and svc_info.rtype and svc_info.rtype == "light") then return end

Expand Down Expand Up @@ -1383,6 +1487,15 @@ local hue = Driver("hue",
stray_bulb_tx = stray_bulb_tx,
_lights_pending_refresh = {},
emit_light_status_events = emit_light_status_events,
do_hue_light_delete = function(driver, device)
if type(driver.try_delete_device) ~= "function" then
device.log.warn("Requesting device delete on API version that doesn't support it. Marking device offline.")
device:offline()
return
end

driver:try_delete_device(device.id)
end,
check_hue_repr_for_capability_support = function(hue_repr, capability_id)
local handler = support_check_handlers[capability_id]
if type(handler) == "function" then
Expand Down

0 comments on commit fe7e701

Please sign in to comment.