diff --git a/drivers/SmartThings/philips-hue/PROGRESS.md b/drivers/SmartThings/philips-hue/PROGRESS.md index 254cfb61c4..8ba23092cd 100644 --- a/drivers/SmartThings/philips-hue/PROGRESS.md +++ b/drivers/SmartThings/philips-hue/PROGRESS.md @@ -8,35 +8,35 @@ Tracking TODOs for this refactor in this file; this is mostly to allow for creat #### Tasks -- [x] Convert supported resources map to a map of handlers instead of boolean ✅ 2024-04-16 -- [x] Move handlers to their own file ✅ 2024-04-16 -- [x] Rename the "light_state_disco_cache" key to be service type agnostic ✅ 2024-04-16 -- [x] Update the `discovered_device_callback` function to allow for other device types ✅ 2024-04-16 +- [x] Convert supported resources map to a map of handlers instead of boolean +- [x] Move handlers to their own file +- [x] Rename the "light_state_disco_cache" key to be service type agnostic +- [x] Update the `discovered_device_callback` function to allow for other device types ### Capability Handlers #### Tasks -- [x] Create a table of handlers for dispatching refreshes by device type ✅ 2024-04-17 -- [x] Fix `refresh_all_for_bridge` to remove assumptions that all child devices are lights ✅ 2024-04-17 +- [x] Create a table of handlers for dispatching refreshes by device type +- [x] Fix `refresh_all_for_bridge` to remove assumptions that all child devices are lights ### Driver (init.lua) Refactors #### Tasks -- [x] Extract lifecycle handlers to their own module(s) ✅ 2024-04-22 - - [x] 2024-04-18 Update: Initial code review missed that `is_*_bridge` and `is_*_light` calls in `utils` were implemented such that the check for light was based on failing the check for bridge. So those need to be fixed as well. ✅ 2024-04-22 -- [x] Extract attribute event emitters to their own module(s) ✅ 2024-04-17 -- [ ] Refactor Stray Light Handler to be a general Stray Device Handler -- [ ] Refactor SSE `onmessage` callback to remove light-specific assumptions - - [ ] `update` messages are hard coded to emit light events with no checks - - [ ] `add` message handling rejects non-light devices instead of being written to be extensible +- [x] Extract lifecycle handlers to their own module(s) + - [x] 2024-04-18 Update: Initial code review missed that `is_*_bridge` and `is_*_light` calls in `utils` were implemented such that the check for light was based on failing the check for bridge. So those need to be fixed as well. +- [x] Extract attribute event emitters to their own module(s) +- [x] Refactor Stray Light Handler to be a general Stray Device Handler +- [x] Refactor SSE `onmessage` callback to remove light-specific assumptions + - [x] `update` messages are hard coded to emit light events with no checks + - [x] `add` message handling rejects non-light devices instead of being written to be extensible ### Miscellaneous/Custodial #### Tasks - [ ] Refactor fresh handlers to be a single generic refresh handler, which is only possible once all of the above is complete. -- [ ] Update all doc strings that claim we only support bridges and lights -- [ ] Update any dangling utility methods/variables/symbols that use "light" when they should use "device" -- [ ] Normalize modules to all use `/init.lua` instead of `.lua` as a sibling to ``. +- [x] Update all doc strings that claim we only support bridges and lights +- [x] Update any dangling utility methods/variables/symbols that use "light" when they should use "device" +- [x] Normalize modules to all use `/init.lua` instead of `.lua` as a sibling to ``. diff --git a/drivers/SmartThings/philips-hue/profiles/4-button-remote.yml b/drivers/SmartThings/philips-hue/profiles/4-button-remote.yml new file mode 100644 index 0000000000..e5beb3872e --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/4-button-remote.yml @@ -0,0 +1,30 @@ +name: 4-button-remote +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/philips-hue/profiles/motion-sensor.yml b/drivers/SmartThings/philips-hue/profiles/motion-sensor.yml index a2e3a07499..c8067e0e89 100644 --- a/drivers/SmartThings/philips-hue/profiles/motion-sensor.yml +++ b/drivers/SmartThings/philips-hue/profiles/motion-sensor.yml @@ -4,12 +4,12 @@ components: capabilities: - id: motionSensor version: 1 - - id: battery - version: 1 - id: illuminanceMeasurement version: 1 - id: temperatureMeasurement version: 1 + - id: battery + version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/philips-hue/profiles/plug.yml b/drivers/SmartThings/philips-hue/profiles/plug.yml new file mode 100644 index 0000000000..e5901cff1b --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/plug.yml @@ -0,0 +1,12 @@ +name: plug +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: samsungim.hueSyncMode + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/philips-hue/profiles/single-button.yml b/drivers/SmartThings/philips-hue/profiles/single-button.yml new file mode 100644 index 0000000000..49003127c4 --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/single-button.yml @@ -0,0 +1,12 @@ +name: single-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/philips-hue/profiles/two-button.yml b/drivers/SmartThings/philips-hue/profiles/two-button.yml new file mode 100644 index 0000000000..f5fb34ff54 --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/two-button.yml @@ -0,0 +1,18 @@ +name: two-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/philips-hue/src/disco/button.lua b/drivers/SmartThings/philips-hue/src/disco/button.lua new file mode 100644 index 0000000000..fce804f0e7 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/disco/button.lua @@ -0,0 +1,170 @@ +local log = require "log" +local socket = require "cosock".socket +local st_utils = require "st.utils" + +local HueDeviceTypes = require "hue_device_types" + +---@class DiscoveredButtonHandler: DiscoveredChildDeviceHandler +local M = {} + +-- TODO This should be generalizable to all "sensors", including buttons. +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_info HueDeviceInfo +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +local function _do_update(driver, api_instance, device_service_info, bridge_network_id, cache) + log.debug("------------ _do_update") + local rid_by_rtype = {} + local button_services = {} + local num_buttons = 0 + + for _, svc in ipairs(device_service_info.services) do + if svc.rtype == HueDeviceTypes.BUTTON then + num_buttons = num_buttons + 1 + table.insert(button_services, svc.rid) + else + rid_by_rtype[svc.rtype] = svc.rid + end + end + + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + local button_remote_description = { + hue_provided_name = device_service_info.metadata.name, + parent_device_id = bridge_device.id, + hue_device_id = device_service_info.id, + hue_device_data = device_service_info, + num_buttons = num_buttons, + sensor_list = { power_id = HueDeviceTypes.DEVICE_POWER } + } + + for _, button_rid in ipairs(button_services) do + local button_repr, err = api_instance:get_button_by_id(button_rid) + if err or not button_repr then + log.error("Error getting button representation: " .. tostring(err or "unknown error")) + else + local control_id = button_repr.data[1].metadata.control_id + local button_key = string.format("button%s", control_id) + local button_id_key = string.format("%s_id", button_key) + button_remote_description[button_key] = button_repr.data[1].button + button_remote_description[button_id_key] = button_repr.data[1].id + + if control_id == 1 then + button_remote_description.id = button_repr.data[1].id + end + + button_remote_description.sensor_list[button_id_key] = HueDeviceTypes.BUTTON + end + end + + local battery, battery_err = api_instance:get_device_power_by_id(rid_by_rtype[HueDeviceTypes.DEVICE_POWER]) + if battery_err then return nil, battery_err end + + if battery and battery.data and battery.data[1] then + button_remote_description.power_id = battery.data[1].id + button_remote_description.power_state = battery.data[1].power_state + end + + if type(cache) == "table" then + cache[button_remote_description.id] = button_remote_description + if device_service_info.id_v1 then + cache[device_service_info.id_v1] = button_remote_description + end + end + + return button_remote_description +end + +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_id string +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +function M.update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id, cache) + log.debug("----------- Calling REST API") + local device_service_info, err = api_instance:get_device_by_id(device_service_id) + if err or not (device_service_info and device_service_info.data) then + log.error("Couldn't get device info for button, error: " .. st_utils.stringify_table(err)) + return + end + + return _do_update(driver, api_instance, device_service_info.data[1], bridge_network_id, cache) +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services table +---@param device_service_info HueDeviceInfo +---@param device_state_disco_cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +function M.handle_discovered_device( + driver, bridge_network_id, api_instance, + primary_services, device_service_info, + device_state_disco_cache, st_metadata_callback +) + local button_description, err = _do_update( + driver, api_instance, device_service_info, bridge_network_id, device_state_disco_cache + ) + if err then + log.error("Error updating contact button initial state: " .. st_utils.stringify_table(err)) + return + end + + if type(st_metadata_callback) == "function" then + if not (button_description and HueDeviceTypes.supports_button_configuration(button_description)) then + button_description = button_description or {num_buttons = "unknown"} + log.error( + string.format( + "Driver does not currently support remotes with %s buttons, cannot create device", button_description.num_buttons + ) + ) + return + end + + local button_profile_ref + -- For Philips Hue Smart Button or single switch In-Wall Switch module which contains only 1 button + if button_description.num_buttons == 1 then + button_profile_ref = "single-button" + -- For double switch In-Wall Switch module + elseif button_description.num_buttons == 2 then + button_profile_ref = "two-button" + -- For Philips Hue Dimmer Remote and Tap Dial, which contains 4 buttons + elseif button_description.num_buttons == 4 then + button_profile_ref = "4-button-remote" + else + log.error( + string.format( + "Do not currently have a profile for device %s with %s buttons, skipping device create", + device_service_info.metadata.name, + button_description.num_buttons + ) + ) + return + end + + local bridge_device = driver:get_device_by_dni(bridge_network_id) or {} + local st_metadata = { + type = "EDGE_CHILD", + label = device_service_info.metadata.name, + vendor_provided_label = device_service_info.product_data.product_name, + profile = button_profile_ref, + manufacturer = device_service_info.product_data.manufacturer_name, + model = device_service_info.product_data.model_id, + parent_device_id = bridge_device.id, + parent_assigned_child_key = string.format("%s:%s", HueDeviceTypes.BUTTON, button_description.id) + } + + log.debug(st_utils.stringify_table(st_metadata, "button create", true)) + + st_metadata_callback(driver, st_metadata) + -- rate limit ourself. + socket.sleep(0.1) + end +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/disco/contact.lua b/drivers/SmartThings/philips-hue/src/disco/contact.lua index 14376dbcde..ff05439dea 100644 --- a/drivers/SmartThings/philips-hue/src/disco/contact.lua +++ b/drivers/SmartThings/philips-hue/src/disco/contact.lua @@ -1,4 +1,4 @@ -local log = require "log" +local log = require "logjam" local socket = require "cosock".socket local st_utils = require "st.utils" @@ -7,13 +7,16 @@ local HueDeviceTypes = require "hue_device_types" ---@class DiscoveredContactSensorHandler: DiscoveredChildDeviceHandler local M = {} +-- TODO This should be generalizable to all "sensors", including buttons. +---@param driver HueDriver ---@param api_instance PhilipsHueApi ---@param device_service_info HueDeviceInfo ----@param bridge_id string +---@param bridge_network_id string ---@param cache table? ---@return table? description nil on error ---@return string? err nil on success -local function _do_update(api_instance, device_service_info, bridge_id, cache) +local function _do_update(driver, api_instance, device_service_info, bridge_network_id, cache) + log.debug("------------ _do_update") local rid_by_rtype = {} for _, svc in ipairs(device_service_info.services) do rid_by_rtype[svc.rtype] = svc.rid @@ -29,10 +32,11 @@ local function _do_update(api_instance, device_service_info, bridge_id, cache) if battery_err then return nil, battery_err end local resource_id = rid_by_rtype[HueDeviceTypes.CONTACT] + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] local contact_sensor_description = { hue_provided_name = device_service_info.metadata.name, id = resource_id, - parent_device_id = bridge_id, + parent_device_id = bridge_device.id, hue_device_id = device_service_info.id, hue_device_data = device_service_info, } @@ -53,6 +57,12 @@ local function _do_update(api_instance, device_service_info, bridge_id, cache) contact_sensor_description.tamper_reports = tamper.data[1].tamper_reports end + contact_sensor_description.sensor_list = { + id = HueDeviceTypes.CONTACT, + power_id = HueDeviceTypes.DEVICE_POWER, + tamper_id = HueDeviceTypes.TAMPER + } + if type(cache) == "table" then cache[resource_id] = contact_sensor_description if device_service_info.id_v1 then @@ -63,13 +73,14 @@ local function _do_update(api_instance, device_service_info, bridge_id, cache) return contact_sensor_description end +---@param driver HueDriver ---@param api_instance PhilipsHueApi ---@param device_service_id string ----@param bridge_id string +---@param bridge_network_id string ---@param cache table? ---@return table? description nil on error ---@return string? err nil on success -function M.update_all_services_for_sensor(api_instance, device_service_id, bridge_id, cache) +function M.update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id, cache) log.debug("----------- Calling REST API") local device_service_info, err = api_instance:get_device_by_id(device_service_id) if err or not (device_service_info and device_service_info.data) then @@ -77,25 +88,24 @@ function M.update_all_services_for_sensor(api_instance, device_service_id, bridg return end - log.debug("------------ _do_update") - return _do_update(api_instance, device_service_info.data[1], bridge_id, cache) + return _do_update(driver, api_instance, device_service_info.data[1], bridge_network_id, cache) end ---@param driver HueDriver ----@param bridge_id string +---@param bridge_network_id string ---@param api_instance PhilipsHueApi ----@param resource_id string +---@param primary_services table ---@param device_service_info HueDeviceInfo ---@param device_state_disco_cache table ---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? function M.handle_discovered_device( - driver, bridge_id, api_instance, - resource_id, device_service_info, + driver, bridge_network_id, api_instance, + primary_services, device_service_info, device_state_disco_cache, st_metadata_callback ) local err = select(2, _do_update( - api_instance, device_service_info, bridge_id, device_state_disco_cache + driver, api_instance, device_service_info, bridge_network_id, device_state_disco_cache ) ) if err then @@ -104,7 +114,8 @@ function M.handle_discovered_device( end if type(st_metadata_callback) == "function" then - local bridge_device = driver:get_device_by_dni(bridge_id) or {} + local resource_id = primary_services[HueDeviceTypes.CONTACT][1].rid + local bridge_device = driver:get_device_by_dni(bridge_network_id) or {} local st_metadata = { type = "EDGE_CHILD", label = device_service_info.metadata.name, diff --git a/drivers/SmartThings/philips-hue/src/disco/init.lua b/drivers/SmartThings/philips-hue/src/disco/init.lua index 2c048da2ce..e26578ff5d 100644 --- a/drivers/SmartThings/philips-hue/src/disco/init.lua +++ b/drivers/SmartThings/philips-hue/src/disco/init.lua @@ -1,4 +1,4 @@ -local log = require "logjam" +local log = require "log" local socket = require "cosock.socket" local mdns = require "st.mdns" @@ -15,7 +15,7 @@ local SERVICE_TYPE = "_hue._tcp" local DOMAIN = "local" ---@class DiscoveredChildDeviceHandler ----@field public handle_discovered_device fun(driver: HueDriver, bridge_id: string, api_instance: PhilipsHueApi, resource_id: string, device_service_info: table, device_state_disco_cache: table, st_metadata_callback: fun(driver: HueDriver, metadata: table)?) +---@field public handle_discovered_device fun(driver: HueDriver, bridge_network_id: string, api_instance: PhilipsHueApi, primary_services: { [HueDeviceTypes]: HueServiceInfo[] }, device_service_info: table, device_state_disco_cache: table, st_metadata_callback: fun(driver: HueDriver, metadata: table)?) -- This `api_keys` table is an in-memory fall-back table. It gets overwritten -- with a reference to a driver datastore table before the Driver's `run` loop @@ -24,6 +24,9 @@ local DOMAIN = "local" ---@field public api_keys table ---@field public disco_api_instances table ---@field public device_state_disco_cache table> +---@field public ServiceType string +---@field public Domain string +---@field public discovery_active boolean local HueDiscovery = { api_keys = {}, disco_api_instances = {}, @@ -44,77 +47,51 @@ local function is_device_service_supported(svc_info) return discovered_device_handlers[svc_info.rtype or ""] ~= nil end ----@param driver HueDriver ----@param bridge_id string ----@param svc_info HueServiceInfo ----@param device_info table -local function discovered_device_callback(driver, bridge_id, svc_info, device_info) - local v1_dni = bridge_id .. "/" .. (device_info.id_v1 or "UNKNOWN"):gsub("/lights/", "") - local v2_resource_id = svc_info.rid or "" - if driver:get_device_by_dni(v1_dni) or driver.hue_identifier_to_device_record[v2_resource_id] then return end - - local api_instance = HueDiscovery.disco_api_instances[bridge_id] - if not api_instance then - log.warn("No API instance for bridge_id ", bridge_id) - return - end - - HueDiscovery.handle_discovered_child_device( - driver, - bridge_id, - api_instance, - svc_info, - device_info - ) -end - -- "forward declarations" ---@param driver HueDriver ---@param bridge_ip string ----@param bridge_id string -local function discovered_bridge_callback(driver, bridge_ip, bridge_id) - if driver.ignored_bridges[bridge_id] then return end +---@param bridge_network_id string +local function discovered_bridge_callback(driver, bridge_ip, bridge_network_id) + if driver.ignored_bridges[bridge_network_id] then return end - local known_bridge_device = driver:get_device_by_dni(bridge_id) + local known_bridge_device = driver:get_device_by_dni(bridge_network_id) if known_bridge_device and known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) then - HueDiscovery.api_keys[bridge_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) + HueDiscovery.api_keys[bridge_network_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) end if known_bridge_device ~= nil - and driver.joined_bridges[bridge_id] - and HueDiscovery.api_keys[bridge_id] + and driver.joined_bridges[bridge_network_id] + and HueDiscovery.api_keys[bridge_network_id] then - log.info_with({ hub_logs = true }, string.format("Scanning bridge %s for devices...", bridge_id)) + log.info_with({ hub_logs = true }, string.format("Scanning bridge %s for devices...", bridge_network_id)) - HueDiscovery.disco_api_instances[bridge_id] = HueDiscovery.disco_api_instances[bridge_id] + HueDiscovery.disco_api_instances[bridge_network_id] = HueDiscovery.disco_api_instances[bridge_network_id] or HueApi.new_bridge_manager( "https://" .. bridge_ip, - HueDiscovery.api_keys[bridge_id], - utils.labeled_socket_builder((known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge")) + HueDiscovery.api_keys[bridge_network_id], + utils.labeled_socket_builder((known_bridge_device.label or bridge_network_id or known_bridge_device.id or "unknown bridge")) ) 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) - end, + bridge_network_id, + HueDiscovery.disco_api_instances[bridge_network_id], + HueDiscovery.handle_discovered_child_device, "[Discovery: " .. - (known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge") .. + (known_bridge_device.label or bridge_network_id or known_bridge_device.id or "unknown bridge") .. " bridge scan]" ) return end - if not HueDiscovery.api_keys[bridge_id] then - local socket_builder = utils.labeled_socket_builder(bridge_id) + if not HueDiscovery.api_keys[bridge_network_id] then + local socket_builder = utils.labeled_socket_builder(bridge_network_id) local api_key_response, err, _ = HueApi.request_api_key(bridge_ip, socket_builder) if err ~= nil or not api_key_response then log.warn(string.format( "Error while trying to request Bridge API Key for %s: %s", - bridge_id, + bridge_network_id, err ) ) @@ -123,40 +100,40 @@ local function discovered_bridge_callback(driver, bridge_ip, bridge_id) for _, item in ipairs(api_key_response) do if item.error ~= nil then - log.warn(string.format("Error payload in bridge %s API key response: %s", bridge_id, item.error.description)) + log.warn(string.format("Error payload in bridge %s API key response: %s", bridge_network_id, item.error.description)) elseif item.success and item.success.username then - log.info(string.format("API key received for Hue Bridge %s", bridge_id)) + log.info(string.format("API key received for Hue Bridge %s", bridge_network_id)) local api_key = item.success.username local bridge_base_url = "https://" .. bridge_ip local api_instance = HueApi.new_bridge_manager(bridge_base_url, api_key, socket_builder) - HueDiscovery.api_keys[bridge_id] = api_key - HueDiscovery.disco_api_instances[bridge_id] = api_instance + HueDiscovery.api_keys[bridge_network_id] = api_key + HueDiscovery.disco_api_instances[bridge_network_id] = api_instance end end end - if HueDiscovery.api_keys[bridge_id] and not driver.joined_bridges[bridge_id] then - local bridge_info = driver.datastore.bridge_netinfo[bridge_id] + if HueDiscovery.api_keys[bridge_network_id] and not driver.joined_bridges[bridge_network_id] then + local bridge_info = driver.datastore.bridge_netinfo[bridge_network_id] if not bridge_info then - log.debug(string.format("Bridge info for %s not yet available", bridge_id)) + log.debug(string.format("Bridge info for %s not yet available", bridge_network_id)) return end if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then log.warn(string.format("Found bridge %s that does not support CLIP v2 API, ignoring", bridge_info.name)) - driver.ignored_bridges[bridge_id] = true + driver.ignored_bridges[bridge_network_id] = true return end - driver.joined_bridges[bridge_id] = true + driver.joined_bridges[bridge_network_id] = true if not known_bridge_device then local create_device_msg = { type = "LAN", - device_network_id = bridge_id, + device_network_id = bridge_network_id, label = (bridge_info.name or "Philips Hue Bridge"), profile = "hue-bridge", manufacturer = "Signify Netherlands B.V.", @@ -186,8 +163,8 @@ function HueDiscovery.discover(driver, _, should_continue) HueDiscovery.do_mdns_scan(driver) HueDiscovery.search_for_bridges( driver, - function(hue_driver, bridge_ip, bridge_id) - discovered_bridge_callback(hue_driver, bridge_ip, bridge_id) + function(hue_driver, bridge_ip, bridge_network_id) + discovered_bridge_callback(hue_driver, bridge_ip, bridge_network_id) end ) socket.sleep(1.0) @@ -200,9 +177,9 @@ end ---@param callback fun(driver: HueDriver, ip: string, id: string) function HueDiscovery.search_for_bridges(driver, callback) local scanned_bridges = driver.datastore.bridge_netinfo or {} - for bridge_id, bridge_info in pairs(scanned_bridges) do + for bridge_network_id, bridge_info in pairs(scanned_bridges) do if type(callback) == "function" and bridge_info ~= nil then - callback(driver, bridge_info.ip, bridge_id) + callback(driver, bridge_info.ip, bridge_network_id) else log.warn( "Argument passed in `callback` position for " @@ -213,25 +190,23 @@ function HueDiscovery.search_for_bridges(driver, callback) end ---@param driver HueDriver ----@param bridge_id string -function HueDiscovery.scan_bridge_and_update_devices(driver, bridge_id) - if driver.ignored_bridges[bridge_id] then return end +---@param bridge_network_id string +function HueDiscovery.scan_bridge_and_update_devices(driver, bridge_network_id) + if driver.ignored_bridges[bridge_network_id] then return end - local known_bridge_device = driver:get_device_by_dni(bridge_id) + local known_bridge_device = driver:get_device_by_dni(bridge_network_id) if known_bridge_device then if known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) then - HueDiscovery.api_keys[bridge_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) + HueDiscovery.api_keys[bridge_network_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) 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) - end, + bridge_network_id, + HueDiscovery.disco_api_instances[bridge_network_id], + HueDiscovery.handle_discovered_child_device, "[Discovery: " .. - (known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge") .. + (known_bridge_device.label or bridge_network_id or known_bridge_device.id or "unknown bridge") .. " bridge re-scan]", true ) @@ -239,12 +214,12 @@ function HueDiscovery.scan_bridge_and_update_devices(driver, bridge_id) end ---@param driver HueDriver ----@param bridge_id string +---@param bridge_network_id string ---@param api_instance PhilipsHueApi ----@param callback fun(driver: HueDriver, svc_info: HueServiceInfo, device_data: table) +---@param callback fun(driver: HueDriver, bridge_network_id: string, primary_services: table, device_data: table) ---@param log_prefix string? ---@param do_delete boolean? -function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, api_instance, callback, log_prefix, do_delete) +function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_network_id, api_instance, callback, log_prefix, do_delete) local prefix = "" if type(log_prefix) == "string" and #log_prefix > 0 then prefix = log_prefix .. " " end @@ -265,32 +240,8 @@ function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, api local device_is_joined_to_bridge = {} for _, device_data in ipairs(devices.data or {}) do - local primary_device_service - for _, svc_info in ipairs(device_data.services or {}) do - if is_device_service_supported(svc_info) then - driver.services_for_device_rid[device_data.id] = driver.services_for_device_rid[device_data.id] or {} - driver.services_for_device_rid[device_data.id][svc_info.rid] = svc_info.rtype - if HueDeviceTypes.can_join_device_for_service(svc_info.rtype) then - primary_device_service = svc_info - device_is_joined_to_bridge[device_data.id] = true - end - end - end - if primary_device_service and type(callback) == "function" then - 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", - primary_device_service.rid, primary_device_service.rtype, device_data.id, device_data.id_v1, device_data.metadata.name - ) - ) - callback(driver, primary_device_service, device_data) - else - log.warn( - prefix .. "Argument passed in `callback` position for " - .. "`HueDiscovery.search_bridge_for_supported_devices` is not a function" - ) - end + device_is_joined_to_bridge[device_data.id] = + HueDiscovery.process_device_service(driver, bridge_network_id, device_data, callback, log_prefix) end if do_delete then @@ -299,8 +250,8 @@ function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, api if utils.is_bridge(driver, device) then goto continue end 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) + local parent_bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_device_id) + local is_child_of_bridge = parent_bridge_device and (parent_bridge_device:get_field(Fields.BRIDGE_ID) == bridge_network_id) if parent_bridge_device and is_child_of_bridge and not not_known_to_bridge and device.id then device.log.info(string.format("Device is no longer joined to Hue Bridge %q, deleting", parent_bridge_device.label)) driver:do_hue_child_delete(device) @@ -311,16 +262,92 @@ function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, api end ---@param driver HueDriver ----@param bridge_id string ----@param api_instance PhilipsHueApi ----@param svc_info table +---@param bridge_network_id string +---@param device_data HueDeviceInfo +---@param callback fun(driver: HueDriver, bridge_network_id: string, primary_services: table, device_data: table, bridge_device: HueBridgeDevice?)? +---@param log_prefix string? +---@param bridge_device HueBridgeDevice? +---@return boolean device_joined_to_bridge true if device was sent through the join process +function HueDiscovery.process_device_service(driver, bridge_network_id, device_data, callback, log_prefix, bridge_device) + local prefix = "" + if type(log_prefix) == "string" and #log_prefix > 0 then prefix = log_prefix .. " " end + local primary_device_services = {} + + local device_joined_to_bridge = false + for _, + svc_info in ipairs(device_data.services or {}) do + if is_device_service_supported(svc_info) then + driver.services_for_device_rid[device_data.id] = driver.services_for_device_rid[device_data.id] or {} + driver.services_for_device_rid[device_data.id][svc_info.rid] = svc_info.rtype + if HueDeviceTypes.can_join_device_for_service(svc_info.rtype) then + local services_for_type = primary_device_services[svc_info.rtype] or {} + table.insert(services_for_type, svc_info) + primary_device_services[svc_info.rtype] = services_for_type + device_joined_to_bridge = true + end + end + end + if next(primary_device_services) then + if type(callback) == "function" then + log.info_with( + { hub_logs = true }, + string.format( + prefix .. + "Processing supported services [%s] for Hue device [v2_id: %s | v1_id: %s], with Hue provided name: %s", + st_utils.stringify_table(primary_device_services), device_data.id, device_data.id_v1, device_data.metadata.name + ) + ) + callback(driver, bridge_network_id, primary_device_services, device_data, bridge_device) + else + log.warn( + prefix .. "Argument passed in `callback` position for " + .. "`HueDiscovery.search_bridge_for_supported_devices` is not a function" + ) + end + else + log.warn(string.format("No primary services for %s", device_data.metadata.name)) + log.warn(st_utils.stringify_table(device_data.services, "services", true)) + end + + return device_joined_to_bridge +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param primary_services table array of services that *can* map to device records. Multi-buttons wouldn't do so, but compound lights would. ---@param device_info table -function HueDiscovery.handle_discovered_child_device(driver, bridge_id, api_instance, svc_info, device_info) - discovered_device_handlers[svc_info.rtype].handle_discovered_device( +---@param bridge_device HueBridgeDevice? If the parent bridge is known, it can be passed in here +function HueDiscovery.handle_discovered_child_device(driver, bridge_network_id, primary_services, device_info, bridge_device) + local v1_dni = bridge_network_id .. "/" .. (device_info.id_v1 or "UNKNOWN"):gsub("/lights/", "") + local primary_service_type = HueDeviceTypes.determine_main_service_rtype(device_info, primary_services) + if not primary_service_type then + log.error( + string.format( + "Couldn't determine primary service type for device %s, unable to join", + (device_info.metadata.name) + ) + ) + return + end + + for _, svc_info in ipairs(primary_services[primary_service_type]) do + local v2_resource_id = svc_info.rid or "" + if driver:get_device_by_dni(v1_dni) or driver.hue_identifier_to_device_record[v2_resource_id] then return end + end + + local api_instance = + (bridge_device and bridge_device:get_field(Fields.BRIDGE_API)) or + HueDiscovery.disco_api_instances[bridge_network_id] + if not api_instance then + log.warn("No API instance for bridge_network_id ", bridge_network_id) + return + end + + discovered_device_handlers[primary_service_type].handle_discovered_device( driver, - bridge_id, + bridge_network_id, api_instance, - svc_info.rid, + primary_services, device_info, HueDiscovery.device_state_disco_cache, driver.try_create_device @@ -390,7 +417,7 @@ function HueDiscovery.do_mdns_scan(driver) end bridge_info.ip = ip_addr - local bridge_id = bridge_info.mac:gsub("-", ""):gsub(":", ""):upper() + local bridge_network_id = bridge_info.mac:gsub("-", ""):gsub(":", ""):upper() -- sanitize userdata nulls from JSON decode for k, v in pairs(bridge_info) do @@ -401,16 +428,16 @@ function HueDiscovery.do_mdns_scan(driver) ---@type boolean? local update_needed = false - if not utils.deep_table_eq((bridge_netinfo[bridge_id] or {}), bridge_info) then - bridge_netinfo[bridge_id] = bridge_info + if not utils.deep_table_eq((bridge_netinfo[bridge_network_id] or {}), bridge_info) then + bridge_netinfo[bridge_network_id] = bridge_info update_needed = true end - if driver.joined_bridges[bridge_id] and not driver.ignored_bridges[bridge_id] then - local bridge_device = driver:get_device_by_dni(bridge_id, true) + if driver.joined_bridges[bridge_network_id] and not driver.ignored_bridges[bridge_network_id] then + local bridge_device = driver:get_device_by_dni(bridge_network_id, true) update_needed = update_needed or (bridge_device and (bridge_device:get_field(Fields.IPV4) ~= bridge_info.ip)) if update_needed then - driver:update_bridge_netinfo(bridge_id, bridge_info) + driver:update_bridge_netinfo(bridge_network_id, bridge_info) end end ::continue:: diff --git a/drivers/SmartThings/philips-hue/src/disco/light.lua b/drivers/SmartThings/philips-hue/src/disco/light.lua index 5bade096c4..8acc6a5669 100644 --- a/drivers/SmartThings/philips-hue/src/disco/light.lua +++ b/drivers/SmartThings/philips-hue/src/disco/light.lua @@ -1,21 +1,157 @@ -local log = require "logjam" +local log = require "log" local socket = require "cosock".socket local st_utils = require "st.utils" ----@class DiscoveredLightHandler: DiscoveredChildDeviceHandler -local M = {} +local HueDeviceTypes = require "hue_device_types" + +local function join_light(driver, light, device_service_info, parent_device_id, st_metadata_callback) + local profile_ref + if light.color then + if light.color_temperature then + profile_ref = "white-and-color-ambiance" + else + profile_ref = "legacy-color" + end + elseif light.color_temperature then + profile_ref = "white-ambiance" -- all color temp products support `white` (dimming) + elseif light.dimming then + profile_ref = "white" -- `white` refers to dimmable and includes filament bulbs + elseif light.on then -- Case for plug which uses same category as 'light' + profile_ref = "plug" + else + log.warn( + string.format( + "Light resource [%s] does not seem to be A White/White-Ambiance/White-Color-Ambiance/Plug device, currently unsupported" + , + light.id + ) + ) + return + end + + local device_name + if light.metadata.name == device_service_info.metadata.name then + device_name = device_service_info.metadata.name + else + device_name = string.format("%s %s", device_service_info.metadata.name, light.metadata.name) + end + + local parent_assigned_child_key = string.format("%s:%s", light.type, light.id) + + local st_metadata = { + type = "EDGE_CHILD", + label = device_name, + vendor_provided_label = device_service_info.product_data.product_name, + profile = profile_ref, + manufacturer = device_service_info.product_data.manufacturer_name, + model = device_service_info.product_data.model_id, + parent_device_id = parent_device_id, + parent_assigned_child_key = parent_assigned_child_key + } + + log.debug(true, st_utils.stringify_table(st_metadata, "light create", true)) + st_metadata_callback(driver, st_metadata) + -- rate limit ourself. + socket.sleep(0.1) +end + +local function get_light_state_table_and_update_cache(light, parent_device_id, device_service_info, cache) + local light_resource_description = { + hue_provided_name = light.metadata.name, + id = light.id, + on = light.on, + color = light.color, + dimming = light.dimming, + color_temperature = light.color_temperature, + mode = light.mode, + parent_device_id = parent_device_id, + hue_device_id = light.owner.rid, + hue_device_data = device_service_info + } + + if type(cache) == "table" then + cache[light.id] = light_resource_description + if device_service_info.id_v1 then + cache[device_service_info.id_v1] = light_resource_description + end + end + return light_resource_description +end + +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param services HueServiceInfo[] +---@param device_service_info HueDeviceInfo +---@param bridge_network_id string +---@param cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +local function handle_compound_light( + driver, api_instance, services, + device_service_info, bridge_network_id, cache, st_metadata_callback +) + ---@type HueLightInfo[] + local all_lights = {} + local main_light_resource_id + for _, svc in ipairs(services) do + local light_resource, err, _ = api_instance:get_light_by_id(svc.rid) + if not light_resource or (light_resource and #light_resource.errors > 0) or err then + log.error(string.format("Couldn't get light resource for rid %s, skipping", svc.rid)) + goto continue + end + table.insert(all_lights, light_resource.data[1]) + if light_resource.data[1].service_id and light_resource.data[1].service_id == 1 then + main_light_resource_id = light_resource.data[1].id + end + ::continue:: + end + + if type(main_light_resource_id) ~= "string" then + log.warn( + string.format( + "Couldn't determine the primary light for compound light [%s] from V1 ID, picking the first light service", + device_service_info.metadata.name + ) + ) + main_light_resource_id = services[1].rid + end + + ---@type HueLightInfo[] + local grandchild_lights = {} + for _, light in pairs(all_lights) do + if light.id == main_light_resource_id then + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + get_light_state_table_and_update_cache(light, bridge_device.id, device_service_info, cache) + if type(st_metadata_callback) == "function" then + join_light( + driver, light, device_service_info, bridge_device.id, st_metadata_callback + ) + end + else + table.insert(grandchild_lights, { + waiting_resource_info = light, + join_callback = function(driver, waiting_info, parent_device) + get_light_state_table_and_update_cache(waiting_info, parent_device.id, device_service_info, cache) + join_light( + driver, waiting_info, device_service_info, parent_device.id, st_metadata_callback + ) + end + }) + end + end + + driver:queue_grandchild_device_for_join(grandchild_lights, main_light_resource_id) +end ---@param driver HueDriver ----@param bridge_id string ---@param api_instance PhilipsHueApi ---@param resource_id string ---@param device_service_info HueDeviceInfo ----@param device_state_disco_cache table +---@param bridge_network_id string +---@param cache table ---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? -function M.handle_discovered_device( - driver, bridge_id, api_instance, - resource_id, device_service_info, - device_state_disco_cache, st_metadata_callback +local function handle_simple_light( + driver, api_instance, resource_id, + device_service_info, bridge_network_id, cache, st_metadata_callback ) local light_resource, err, _ = api_instance:get_light_by_id(resource_id) if err ~= nil or not light_resource then @@ -27,74 +163,50 @@ function M.handle_discovered_device( if light_resource.errors and #light_resource.errors > 0 then log.error_with({ hub_logs = true }, "Errors found in API response:") - for idx, err in ipairs(light_resource.errors) do - log.error_with({ hub_logs = true }, st_utils.stringify_table(err, "Error " .. idx, true)) + for idx, rest_err in ipairs(light_resource.errors) do + log.error_with({ hub_logs = true }, st_utils.stringify_table(rest_err, "Error " .. idx, true)) end return end - for _, light in ipairs(light_resource.data or {}) do - local bridge_device = driver:get_device_by_dni(bridge_id) --[[@as HueBridgeDevice]] - local light_resource_description = { - hue_provided_name = light.metadata.name, - id = light.id, - on = light.on, - color = light.color, - dimming = light.dimming, - color_temperature = light.color_temperature, - mode = light.mode, - parent_device_id = bridge_device.id, - hue_device_id = light.owner.rid, - hue_device_data = device_service_info - } - device_state_disco_cache[light.id] = light_resource_description - if device_service_info.id_v1 then - device_state_disco_cache[device_service_info.id_v1] = light_resource_description - end + local light = light_resource.data[1] + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + get_light_state_table_and_update_cache(light, bridge_device.id, device_service_info, cache) - if type(st_metadata_callback) == "function" then - local profile_ref - if light.color then - if light.color_temperature then - profile_ref = "white-and-color-ambiance" - else - profile_ref = "legacy-color" - end - elseif light.color_temperature then - profile_ref = "white-ambiance" -- all color temp products support `white` (dimming) - elseif light.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" - , - resource_id - ) - ) - goto continue - end + if type(st_metadata_callback) == "function" then + join_light( + driver, light, device_service_info, bridge_device.id, st_metadata_callback + ) + end +end - local device_name = light.metadata.name - local parent_assigned_child_key = string.format("%s:%s", light.type, light.id) - - local st_metadata = { - type = "EDGE_CHILD", - label = device_name, - vendor_provided_label = device_service_info.product_data.product_name, - profile = profile_ref, - manufacturer = device_service_info.product_data.manufacturer_name, - model = device_service_info.product_data.model_id, - parent_device_id = bridge_device.id, - parent_assigned_child_key = parent_assigned_child_key - } - - log.debug(true, st_utils.stringify_table(st_metadata, "light create", true)) - st_metadata_callback(driver, st_metadata) - -- rate limit ourself. - socket.sleep(0.1) - end - ::continue:: +---@class DiscoveredLightHandler: DiscoveredChildDeviceHandler +local M = {} + +---@param driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services table +---@param device_service_info HueDeviceInfo +---@param device_state_disco_cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +function M.handle_discovered_device( + driver, bridge_network_id, api_instance, + primary_services, device_service_info, + device_state_disco_cache, st_metadata_callback +) + local light_services = primary_services[HueDeviceTypes.LIGHT] + local is_compound_light = #light_services > 1 + if is_compound_light then + handle_compound_light( + driver, api_instance, light_services, device_service_info, + bridge_network_id, device_state_disco_cache, st_metadata_callback + ) + else + handle_simple_light( + driver, api_instance, light_services[1].rid, device_service_info, + bridge_network_id, device_state_disco_cache, st_metadata_callback + ) end end diff --git a/drivers/SmartThings/philips-hue/src/disco/motion.lua b/drivers/SmartThings/philips-hue/src/disco/motion.lua index cfdbd0895c..e7fb077748 100644 --- a/drivers/SmartThings/philips-hue/src/disco/motion.lua +++ b/drivers/SmartThings/philips-hue/src/disco/motion.lua @@ -1,4 +1,4 @@ -local log = require "log" +local log = require "logjam" local socket = require "cosock".socket local st_utils = require "st.utils" @@ -7,13 +7,16 @@ local HueDeviceTypes = require "hue_device_types" ---@class DiscoveredMotionSensorHandler: DiscoveredChildDeviceHandler local M = {} +-- TODO This should be generalizable to all "sensors", including buttons. +---@param driver HueDriver ---@param api_instance PhilipsHueApi ---@param device_service_info HueDeviceInfo ----@param bridge_id string +---@param bridge_network_id string ---@param cache table? ---@return table? description nil on error ---@return string? err nil on success -local function _do_update(api_instance, device_service_info, bridge_id, cache) +local function _do_update(driver, api_instance, device_service_info, bridge_network_id, cache) + log.debug("------------ _do_update") local rid_by_rtype = {} for _, svc in ipairs(device_service_info.services) do rid_by_rtype[svc.rtype] = svc.rid @@ -32,10 +35,11 @@ local function _do_update(api_instance, device_service_info, bridge_id, cache) if battery_err then return nil, battery_err end local resource_id = rid_by_rtype[HueDeviceTypes.MOTION] + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] local motion_sensor_description = { hue_provided_name = device_service_info.metadata.name, id = resource_id, - parent_device_id = bridge_id, + parent_device_id = bridge_device.id, hue_device_id = device_service_info.id, hue_device_data = device_service_info, } @@ -63,6 +67,13 @@ local function _do_update(api_instance, device_service_info, bridge_id, cache) motion_sensor_description.light_level_enabled = illuminance.data[1].enabled end + motion_sensor_description.sensor_list = { + id = HueDeviceTypes.MOTION, + power_id = HueDeviceTypes.DEVICE_POWER, + temperature_id = HueDeviceTypes.TEMPERATURE, + light_level_id = HueDeviceTypes.LIGHT_LEVEL + } + if type(cache) == "table" then cache[resource_id] = motion_sensor_description if device_service_info.id_v1 then @@ -73,13 +84,14 @@ local function _do_update(api_instance, device_service_info, bridge_id, cache) return motion_sensor_description end +---@param driver HueDriver ---@param api_instance PhilipsHueApi ---@param device_service_id string ----@param bridge_id string +---@param bridge_network_id string ---@param cache table? ---@return table? description nil on error ---@return string? err nil on success -function M.update_all_services_for_sensor(api_instance, device_service_id, bridge_id, cache) +function M.update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id, cache) log.debug("----------- Calling REST API") local device_service_info, err = api_instance:get_device_by_id(device_service_id) if err or not (device_service_info and device_service_info.data) then @@ -87,25 +99,24 @@ function M.update_all_services_for_sensor(api_instance, device_service_id, bridg return end - log.debug("------------ _do_update") - return _do_update(api_instance, device_service_info.data[1], bridge_id, cache) + return _do_update(driver, api_instance, device_service_info.data[1], bridge_network_id, cache) end ---@param driver HueDriver ----@param bridge_id string +---@param bridge_network_id string ---@param api_instance PhilipsHueApi ----@param resource_id string +---@param primary_services table ---@param device_service_info HueDeviceInfo ---@param device_state_disco_cache table ---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? function M.handle_discovered_device( - driver, bridge_id, api_instance, - resource_id, device_service_info, + driver, bridge_network_id, api_instance, + primary_services, device_service_info, device_state_disco_cache, st_metadata_callback ) local err = select(2, _do_update( - api_instance, device_service_info, bridge_id, device_state_disco_cache + driver, api_instance, device_service_info, bridge_network_id, device_state_disco_cache ) ) if err then @@ -114,7 +125,8 @@ function M.handle_discovered_device( end if type(st_metadata_callback) == "function" then - local bridge_device = driver:get_device_by_dni(bridge_id) or {} + local resource_id = primary_services[HueDeviceTypes.MOTION][1].rid + local bridge_device = driver:get_device_by_dni(bridge_network_id) or {} local st_metadata = { type = "EDGE_CHILD", label = device_service_info.metadata.name, diff --git a/drivers/SmartThings/philips-hue/src/fields.lua b/drivers/SmartThings/philips-hue/src/fields.lua index 5eca9656f9..0df3c06aec 100644 --- a/drivers/SmartThings/philips-hue/src/fields.lua +++ b/drivers/SmartThings/philips-hue/src/fields.lua @@ -16,17 +16,20 @@ local Fields = { BRIDGE_API = "bridge_api", BRIDGE_ID = "bridgeid", BRIDGE_SW_VERSION = "swversion", + BUTTON_INDEX_MAP = "button_rid_to_index", DEVICE_TYPE = "devicetype", EVENT_SOURCE = "eventsource", GAMUT = "gamut", HUE_DEVICE_ID = "hue_device_id", IPV4 = "ipv4", + IS_ONLINE = "is_online", + IS_MULTI_SERVICE = "is_multi_service", MIN_DIMMING = "mindim", MIN_KELVIN = "mintemp", MODEL_ID = "modelid", - IS_ONLINE = "is_online", PARENT_DEVICE_ID = "parent_device_id_local", RESOURCE_ID = "rid", + RETRY_MIGRATION = "retry_migration", WRAPPED_HUE = "_wrapped_hue" } diff --git a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua index 46b2f1492d..1604889708 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua @@ -7,7 +7,7 @@ st_utils.stringify_table = st_utils.stringify_table local Consts = require "consts" local Fields = require "fields" -local HueColorUtils = require "hue.cie_utils" +local HueColorUtils = require "utils.cie_utils" local HueDeviceTypes = require "hue_device_types" local utils = require "utils" @@ -114,41 +114,96 @@ end function AttributeEmitters.connectivity_update(child_device, zigbee_status) if zigbee_status.status == "connected" then - child_device.log.info_with({hub_logs=true}, "Light status event, marking device online") + child_device.log.info_with({hub_logs=true}, "Device zigbee status event, marking device online") child_device:online() child_device:set_field(Fields.IS_ONLINE, true) elseif zigbee_status.status == "connectivity_issue" then - child_device.log.info_with({hub_logs=true}, "Light status event, marking device offline") + child_device.log.info_with({hub_logs=true}, "Device zigbee status event, marking device offline") child_device:set_field(Fields.IS_ONLINE, false) child_device:offline() end end +function AttributeEmitters.emit_button_attribute_events(button_device, button_info) + if button_device == nil or (button_device and button_device.id == nil) then + log.warn("Tried to emit attribute events for a device that has been deleted") + return + end + + if button_info.power_state and type(button_info.power_state.battery_level) == "number" then + log.debug(true, "emit power") + button_device:emit_event( + capabilities.battery.battery( + st_utils.clamp_value(button_info.power_state.battery_level, 0, 100) + ) + ) + end + + local button_idx_map = button_device:get_field(Fields.BUTTON_INDEX_MAP) + if not button_idx_map then + log.error( + string.format( + "Button ID to Button Index map lost, " .. + "cannot find componenet to emit attribute event on for button [%s]", + (button_device and button_device.lable) or "unknown button" + ) + ) + return + end + + local idx = button_idx_map[button_info.id] or 1 + local component_idx + if idx == 1 then + component_idx = "main" + else + component_idx = string.format("button%s", idx) + end + + local button_report = (button_info.button and button_info.button.button_report) or { event = "" } + + if button_report.event == "long_press" and not button_device:get_field("button_held") then + button_device:set_field("button_held", true) + button_device.profile.components[component_idx]:emit_event( + capabilities.button.button.held({state_change = true}) + ) + end + + if button_report.event == "long_release" and button_device:get_field("button_held") then + button_device:set_field("button_held", false) + end + + if button_report.event == "short_release" and not button_device:get_field("button_held") then + button_device.profile.components[component_idx]:emit_event( + capabilities.button.button.pushed({state_change = true}) + ) + end +end + function AttributeEmitters.emit_contact_sensor_attribute_events(sensor_device, sensor_info) if sensor_device == nil or (sensor_device and sensor_device.id == nil) then log.warn("Tried to emit attribute events for a device that has been deleted") return end - if sensor_info.power_state then + if sensor_info.power_state and type(sensor_info.power_state.battery_level) == "number" then log.debug(true, "emit power") sensor_device:emit_event(capabilities.battery.battery(st_utils.clamp_value(sensor_info.power_state.battery_level, 0, 100))) end if sensor_info.tamper_reports then log.debug(true, "emit tamper") - local num_reports = #sensor_info.tamper_reports - local not_tampered = 0 + local tampered = false for _, tamper in ipairs(sensor_info.tamper_reports) do - if tamper.state == "not_tampered" then - not_tampered = not_tampered + 1 + if tamper.state == "tampered" then + tampered = true + break end end - if not_tampered == num_reports then - sensor_device:emit_event(capabilities.tamperAlert.tamper.clear()) - else + if tampered then sensor_device:emit_event(capabilities.tamperAlert.tamper.detected()) + else + sensor_device:emit_event(capabilities.tamperAlert.tamper.clear()) end end @@ -168,7 +223,7 @@ function AttributeEmitters.emit_motion_sensor_attribute_events(sensor_device, se return end - if sensor_info.power_state then + if sensor_info.power_state and type(sensor_info.power_state.battery_level) == "number" then log.debug(true, "emit power") sensor_device:emit_event(capabilities.battery.battery(st_utils.clamp_value(sensor_info.power_state.battery_level, 0, 100))) end @@ -224,8 +279,9 @@ function AttributeEmitters.emitter_for_device_type(device_type) end -- TODO: Generalize this like the other handlers, and maybe even separate out non-primary services +device_type_emitter_map[HueDeviceTypes.BUTTON] = AttributeEmitters.emit_button_attribute_events +device_type_emitter_map[HueDeviceTypes.CONTACT] = AttributeEmitters.emit_contact_sensor_attribute_events device_type_emitter_map[HueDeviceTypes.LIGHT] = AttributeEmitters.emit_light_attribute_events device_type_emitter_map[HueDeviceTypes.MOTION] = AttributeEmitters.emit_motion_sensor_attribute_events -device_type_emitter_map[HueDeviceTypes.CONTACT] = AttributeEmitters.emit_contact_sensor_attribute_events return AttributeEmitters diff --git a/drivers/SmartThings/philips-hue/src/handlers/commands.lua b/drivers/SmartThings/philips-hue/src/handlers/commands.lua index b118659382..7fdb337325 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/commands.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/commands.lua @@ -4,7 +4,7 @@ local st_utils = require "st.utils" local Consts = require "consts" local Fields = require "fields" -local HueColorUtils = require "hue.cie_utils" +local HueColorUtils = require "utils.cie_utils" local utils = require "utils" @@ -21,7 +21,7 @@ local CommandHandlers = {} local function do_switch_action(driver, device, args) local on = args.command == "on" local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) + local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) if not bridge_device then log.warn( @@ -66,7 +66,7 @@ end local function do_switch_level_action(driver, device, args) local level = st_utils.clamp_value(args.args.level, 1, 100) local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) + local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) if not bridge_device then log.warn( @@ -130,7 +130,7 @@ local function do_color_action(driver, device, args) device:set_field(Fields.WRAPPED_HUE, true) end local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) + local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) if not bridge_device then log.warn( @@ -207,7 +207,7 @@ end local function do_color_temp_action(driver, device, args) local kelvin = args.args.temperature local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) + local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) if not bridge_device then log.warn( @@ -304,8 +304,9 @@ local refresh_handlers = require "handlers.refresh_handlers" ---@param driver HueDriver ---@param device HueDevice ---@param cmd table? +---@return table? refreshed_device_info function CommandHandlers.refresh_handler(driver, device, cmd) - refresh_handlers.handler_for_device_type(device:get_field(Fields.DEVICE_TYPE))(driver, device, cmd) + return refresh_handlers.handler_for_device_type(device:get_field(Fields.DEVICE_TYPE))(driver, device, cmd) end return CommandHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/button.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/button.lua new file mode 100644 index 0000000000..e1f60af2ab --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/button.lua @@ -0,0 +1,165 @@ +local capabilities = require "st.capabilities" +local log = require "log" +local st_utils = require "st.utils" + +local refresh_handler = require("handlers.commands").refresh_handler + +local Discovery = require "disco" +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" +local StrayDeviceHelper = require "stray_device_helper" + +local button_disco = require "disco.button" +local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" +local utils = require "utils" + +---@class ButtonLifecycleHandlers +local ButtonLifecycleHandlers = {} + +---@param driver HueDriver +---@param device HueChildDevice +---@param parent_device_id string? +---@param resource_id string? +function ButtonLifecycleHandlers.added(driver, device, parent_device_id, resource_id) + log.info( + string.format("Button Added for device %s", (device.label or device.id or "unknown device"))) + local device_button_resource_id = resource_id or utils.get_hue_rid(device) + if not device_button_resource_id then + log.error( + string.format( + "Could not determine the Hue Resource ID for added button %s", + (device and device.label) or "unknown button" + ) + ) + return + end + + local button_info = Discovery.device_state_disco_cache[device_button_resource_id] + if not button_info then + log.error( + string.format( + "Expected button info to be cached, sending button %s to stray resolver", + (device and device.label) or "unknown button" + ) + ) + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + + local button_rid_to_index_map = {} + if button_info.button then + driver.hue_identifier_to_device_record[button_info.id] = device + button_rid_to_index_map[button_info.id] = 1 + end + + if button_info.num_buttons then + for var = 1, button_info.num_buttons do + local button_key = string.format("button%s", var) + local button_id_key = string.format("%s_id", button_key) + local button = button_info[button_key] + local button_id = button_info[button_id_key] + + if button and button_id then + driver.hue_identifier_to_device_record[button_id] = device + button_rid_to_index_map[button_id] = var + + local supported_button_values = utils.get_supported_button_values(button.event_values) + local component + if var == 1 then + component = "main" + else + component = button_key + end + device.profile.components[component]:emit_event( + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + end + end + end + + if button_info.power_id then + driver.hue_identifier_to_device_record[button_info.power_id] = device + end + + log.debug(st_utils.stringify_table(button_rid_to_index_map, "button index map", true)) + device:set_field(Fields.BUTTON_INDEX_MAP, button_rid_to_index_map, { persist = true }) + device:set_field(Fields.DEVICE_TYPE, HueDeviceTypes.BUTTON, { persist = true }) + device:set_field(Fields.HUE_DEVICE_ID, button_info.hue_device_id, { persist = true }) + device:set_field(Fields.PARENT_DEVICE_ID, button_info.parent_device_id, { persist = true }) + device:set_field(Fields.RESOURCE_ID, device_button_resource_id, { persist = true }) + device:set_field(Fields._ADDED, true, { persist = true }) + device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) + + driver.hue_identifier_to_device_record[device_button_resource_id] = device +end + +---@param driver HueDriver +---@param device HueChildDevice +function ButtonLifecycleHandlers.init(driver, device) + log.info( + string.format("Init Button for device %s", (device and device.label or device.id or "unknown button"))) + device:set_field(Fields.IS_MULTI_SERVICE, true, { persist = true }) + local device_button_resource_id = + utils.get_hue_rid(device) or + device.device_network_id + + log.debug("resource id " .. tostring(device_button_resource_id)) + + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + if not driver.hue_identifier_to_device_record[device_button_resource_id] then + driver.hue_identifier_to_device_record[device_button_resource_id] = device + end + local button_info, err + button_info = Discovery.device_state_disco_cache[device_button_resource_id] + if not button_info then + log.debug("no button info") + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) + local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) + + if parent_bridge and api_instance then + button_info, err = button_disco.update_state_for_all_device_services( + driver, + api_instance, + hue_device_id, + parent_bridge.device_network_id, + Discovery.device_state_disco_cache + ) + if err then + log.error( + st_utils.stringify_table( + err, + string.format( + "Error populating initial state for button %s", + (device and device.label) or "unknown button" + ), + true + ) + ) + end + end + end + if not button_info then + log.warn(string.format("Button %s parent bridge not ready, queuing refresh", device and device.label)) + driver._devices_pending_refresh[device.id] = device + else + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, button_info, HueDeviceTypes.BUTTON + ) + end + device:set_field(Fields._INIT, true, { persist = false }) + if device:get_field(Fields._REFRESH_AFTER_INIT) then + refresh_handler(driver, device) + device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) + end +end + +return ButtonLifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua index 2f4ad1a805..64be383c56 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua @@ -1,4 +1,4 @@ -local log = require "log" +local log = require "logjam" local st_utils = require "st.utils" local refresh_handler = require("handlers.commands").refresh_handler @@ -9,6 +9,7 @@ local HueDeviceTypes = require "hue_device_types" local StrayDeviceHelper = require "stray_device_helper" local contact_sensor_disco = require "disco.contact" +local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" local utils = require "utils" ---@class ContactLifecycleHandlers @@ -66,7 +67,7 @@ end function ContactLifecycleHandlers.init(driver, device) log.info( string.format("Init Contact Sensor for device %s", (device and device.label or device.id or "unknown sensor"))) - + device:set_field(Fields.IS_MULTI_SERVICE, true, { persist = true }) local device_sensor_resource_id = utils.get_hue_rid(device) or device.device_network_id @@ -81,14 +82,14 @@ function ContactLifecycleHandlers.init(driver, device) sensor_info = Discovery.device_state_disco_cache[device_sensor_resource_id] if not sensor_info then log.debug("no sensor info") - local parent_bridge = driver:get_device_info(device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)) - local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) or Discovery.api_keys[(parent_bridge and parent_bridge.device_network_id) or ""] + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) + local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) - if not (parent_bridge and api_instance) then - log.warn(string.format("Contact Sensor %s parent bridge not ready, queuing refresh", device and device.label)) - driver._devices_pending_refresh[device.id] = device - else - sensor_info, err = contact_sensor_disco.update_all_services_for_sensor( + if parent_bridge and api_instance then + sensor_info, err = contact_sensor_disco.update_state_for_all_device_services( + driver, api_instance, hue_device_id, parent_bridge.device_network_id, @@ -108,22 +109,14 @@ function ContactLifecycleHandlers.init(driver, device) end end end - sensor_info = sensor_info - local svc_rids_for_device = driver.services_for_device_rid[hue_device_id] or {} - if sensor_info and not svc_rids_for_device[sensor_info.id] then - svc_rids_for_device[sensor_info.id] = HueDeviceTypes.CONTACT - end - if sensor_info and not svc_rids_for_device[sensor_info.power_id] then - svc_rids_for_device[sensor_info.power_id] = HueDeviceTypes.DEVICE_POWER - end - if sensor_info and not svc_rids_for_device[sensor_info.tamper_id] then - svc_rids_for_device[sensor_info.tamper_id] = HueDeviceTypes.TAMPER - end - driver.services_for_device_rid[hue_device_id] = svc_rids_for_device - for rid, _ in pairs(driver.services_for_device_rid[hue_device_id]) do - driver.hue_identifier_to_device_record[rid] = device + if not sensor_info then + log.warn(string.format("Contact Sensor %s parent bridge not ready, queuing refresh", device and device.label)) + driver._devices_pending_refresh[device.id] = device + else + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, sensor_info, HueDeviceTypes.CONTACT + ) end - device:set_field(Fields._INIT, true, { persist = false }) if device:get_field(Fields._REFRESH_AFTER_INIT) then refresh_handler(driver, device) diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/init.lua similarity index 79% rename from drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers.lua rename to drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/init.lua index 1110c6469f..a6d272b7b5 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/init.lua @@ -9,6 +9,31 @@ local StrayDeviceHelper = require "stray_device_helper" local utils = require "utils" +local function check_parent_assigned_child_key(device) + local device_type = utils.determine_device_type(device) + local device_rid = utils.get_hue_rid(device) + + if type(device_type) == "string" and type(device_rid) == "string" then + local expected_parent_assigned_child_key = string.format("%s:%s", device_type, device_rid) + if expected_parent_assigned_child_key ~= device.parent_assigned_child_key then + log.debug( + string.format( + "\n\nDevice [%s] had parent-assigned child key of %s but expected %s, requesting metadata update\n\n", + (device and device.label) or "unknown device", + device.parent_assigned_child_key, + expected_parent_assigned_child_key + ) + ) + -- updating parent_assigned_child_key in metadata isn't supported + -- on Lua Libs API versions before 11. + if require("version").api <= 10 then + return + end + device:try_update_metadata({ parent_assigned_child_key = expected_parent_assigned_child_key }) + end + end +end + -- Lazy-load the lifecycle handlers so we only load the code we need local inner_handlers = utils.lazy_handler_loader("handlers.lifecycle_handlers") @@ -53,11 +78,15 @@ function LifecycleHandlers.device_added(driver, device, ...) local resource_state_known = (Discovery.device_state_disco_cache[resource_id] ~= nil) log.info( string.format("Querying device info for parent of %s", (device.label or device.id or "unknown device"))) - local parent_bridge = driver:get_device_info(device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)) + + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) local key = parent_bridge and parent_bridge:get_field(HueApi.APPLICATION_KEY_HEADER) local bridge_ip = parent_bridge and parent_bridge:get_field(Fields.IPV4) local bridge_id = parent_bridge and parent_bridge:get_field(Fields.BRIDGE_ID) + if not (bridge_ip and bridge_id and resource_state_known and (Discovery.api_keys[bridge_id or {}] or key)) then log.warn(true, "Found \"stray\" bulb without associated Hue Bridge. Waiting to see if a bridge becomes available.") @@ -122,8 +151,14 @@ function LifecycleHandlers.initialize_device(driver, device, event, _args, ...) driver.datastore.dni_to_device_id[device.device_network_id] = device.id end + if device:get_field(Fields.RETRY_MIGRATION) then + LifecycleHandlers.migrate_device(driver, device, ...) + return + end + log.info( string.format("_initialize handling event %s for device %s", event, (device.label or device.id or "unknown device"))) + if not device:get_field(Fields._ADDED) then log.debug( string.format( @@ -138,6 +173,9 @@ function LifecycleHandlers.initialize_device(driver, device, event, _args, ...) "_INIT for device %s not set while _initialize is handling %s, performing device init lifecycle operations", (device.label or device.id or "unknown device"), event)) LifecycleHandlers.device_init(driver, device, ...) + if not utils.is_bridge(driver, device) then + check_parent_assigned_child_key(device) + end end end 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 7c660dda29..f69ef08f36 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua @@ -36,8 +36,7 @@ function LightLifecycleHandlers.added(driver, device, parent_device_id, resource if not light_info_known then log.info( string.format("Querying device info for parent of %s", (device.label or device.id or "unknown device"))) - local parent_bridge = driver:get_device_info(parent_device_id or device.parent_device_id or - device:get_field(Fields.PARENT_DEVICE_ID)) + local parent_bridge = utils.get_hue_bridge_for_device(driver, device, parent_device_id) if not parent_bridge then log.error_with({ hub_logs = true }, string.format( "Device %s added with parent UUID of %s but could not find a device with that UUID in the driver", @@ -107,7 +106,7 @@ function LightLifecycleHandlers.added(driver, device, parent_device_id, resource dimming = light.dimming, color_temperature = light.color_temperature, mode = light.mode, - parent_device_id = parent_bridge.id, + parent_device_id = parent_device_id or device.parent_device_id, hue_device_id = light.owner.rid, hue_device_data = { product_data = { @@ -190,6 +189,7 @@ function LightLifecycleHandlers.init(driver, device) refresh_handler(driver, device) device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) end + driver:check_waiting_grandchildren_for_device(device) end return LightLifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua index d278ae6bb7..3c04253da2 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua @@ -1,4 +1,4 @@ -local log = require "log" +local log = require "logjam" local st_utils = require "st.utils" local refresh_handler = require("handlers.commands").refresh_handler @@ -9,6 +9,7 @@ local HueDeviceTypes = require "hue_device_types" local StrayDeviceHelper = require "stray_device_helper" local motion_sensor_disco = require "disco.motion" +local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" local utils = require "utils" ---@class MotionLifecycleHandlers @@ -67,7 +68,7 @@ end function MotionLifecycleHandlers.init(driver, device) log.info( string.format("Init Motion Sensor for device %s", (device and device.label or device.id or "unknown sensor"))) - + device:set_field(Fields.IS_MULTI_SERVICE, true, { persist = true }) local device_sensor_resource_id = utils.get_hue_rid(device) or device.device_network_id @@ -82,15 +83,15 @@ function MotionLifecycleHandlers.init(driver, device) sensor_info = Discovery.device_state_disco_cache[device_sensor_resource_id] if not sensor_info then log.debug("no sensor info") - local parent_bridge = driver:get_device_info(device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)) - local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) or Discovery.api_keys[(parent_bridge and parent_bridge.device_network_id) or ""] + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) + local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) - if not (parent_bridge and api_instance) then - log.warn(string.format("Motion Sensor %s parent bridge not ready, queuing refresh", device and device.label)) - driver._devices_pending_refresh[device.id] = device - else + if parent_bridge and api_instance then log.debug("--------------------- update all start") - sensor_info, err = motion_sensor_disco.update_all_services_for_sensor( + sensor_info, err = motion_sensor_disco.update_state_for_all_device_services( + driver, api_instance, hue_device_id, parent_bridge.device_network_id, @@ -111,25 +112,14 @@ function MotionLifecycleHandlers.init(driver, device) end end end - sensor_info = sensor_info - local svc_rids_for_device = driver.services_for_device_rid[hue_device_id] or {} - if sensor_info and not svc_rids_for_device[sensor_info.id] then - svc_rids_for_device[sensor_info.id] = HueDeviceTypes.MOTION - end - if sensor_info and not svc_rids_for_device[sensor_info.power_id] then - svc_rids_for_device[sensor_info.power_id] = HueDeviceTypes.DEVICE_POWER - end - if sensor_info and not svc_rids_for_device[sensor_info.temperature_id] then - svc_rids_for_device[sensor_info.temperature_id] = HueDeviceTypes.TEMPERATURE - end - if sensor_info and not svc_rids_for_device[sensor_info.light_level_id] then - svc_rids_for_device[sensor_info.light_level_id] = HueDeviceTypes.LIGHT_LEVEL - end - driver.services_for_device_rid[hue_device_id] = svc_rids_for_device - for rid, _ in pairs(driver.services_for_device_rid[hue_device_id]) do - driver.hue_identifier_to_device_record[rid] = device + if not sensor_info then + log.warn(string.format("Motion Sensor %s parent bridge not ready, queuing refresh", device and device.label)) + driver._devices_pending_refresh[device.id] = device + else + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, sensor_info, HueDeviceTypes.CONTACT + ) end - log.debug(st_utils.stringify_table(driver.hue_identifier_to_device_record, "hue_ids_map", true)) device:set_field(Fields._INIT, true, { persist = false }) if device:get_field(Fields._REFRESH_AFTER_INIT) then refresh_handler(driver, device) diff --git a/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua b/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua index 4581ff4203..9b66522445 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua @@ -29,7 +29,7 @@ function LightMigrationHandler.migrate(driver, device, lifecycle_handlers, paren local bridge_device = nil if parent_device_id ~= nil then - bridge_device = driver:get_device_info(parent_device_id, false) + bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_device_id) end if not bridge_device then @@ -68,6 +68,7 @@ function LightMigrationHandler.migrate(driver, device, lifecycle_handlers, paren driver.joined_bridges[bridge_id], bridge_dni )) + device:set_field(Fields.RETRY_MIGRATION, true, { persist = false }) driver.stray_device_tx:send({ type = StrayDeviceHelper.MessageTypes.NewStrayDevice, driver = driver, @@ -139,6 +140,7 @@ function LightMigrationHandler.migrate(driver, device, lifecycle_handlers, paren vendor_provided_label = light_resource.hue_device_data.product_data.product_name, } device:try_update_metadata(new_metadata) + device:set_field(Fields.RETRY_MIGRATION, false, { persist = false }) log.info(string.format( "Migration to CLIPV2 for %s complete, going through onboarding flow again", diff --git a/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua b/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua index 93ed60585c..cfa6a721d6 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua @@ -4,7 +4,7 @@ local st_utils = require "st.utils" local Fields = require "fields" local HueDeviceTypes = require "hue_device_types" -local SensorUtils = require "utils.hue_motion_sensor_utils" +local MultiServiceDeviceUtils = require "utils.hue_multi_service_device_utils" local attribute_emitters = require "handlers.attribute_emitters" local utils = require "utils" @@ -85,7 +85,7 @@ function RefreshHandlers.do_refresh_all_for_bridge(driver, bridge_device) local child_devices = bridge_device:get_child_list() if not bridge_device:get_field(Fields._INIT) then - log.warn("Bridge for lights not yet initialized, can't refresh yet.") + log.warn("Bridge for devices not yet initialized, can't refresh yet.") return end @@ -150,20 +150,62 @@ function RefreshHandlers.do_refresh_all_for_bridge(driver, bridge_device) (bridge_device and bridge_device.label) or "Unknown Bridge") ) end +-- TODO: [Rule of three](https://en.wikipedia.org/wiki/Rule_of_three_(computer_programming)), this can be generalized. +-- At this point I'm pretty confident that we can actually just have a single generic +-- "refresh device" function and a "refresh all devices" function. +---@param driver HueDriver +---@param button_device HueChildDevice +---@param _ any +---@param skip_zigbee boolean +---@return table? +function RefreshHandlers.do_refresh_button(driver, button_device, _, skip_zigbee) + local hue_device_id = button_device:get_field(Fields.HUE_DEVICE_ID) + local bridge_id = button_device.parent_device_id or button_device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, button_device, bridge_id) + + if not bridge_device then + log.warn("Couldn't get Hue bridge for button device " .. (button_device.label or button_device.id or "unknown device")) + return + end + + if not bridge_device:get_field(Fields._INIT) then + log.warn("Bridge for button device not yet initialized, can't refresh yet.") + driver._devices_pending_refresh[button_device.id] = button_device + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if skip_zigbee ~= true then + _refresh_zigbee(button_device, hue_api) + end + + local sensor_info, err = MultiServiceDeviceUtils.get_all_service_states(driver, HueDeviceTypes.BUTTON, hue_api, hue_device_id, bridge_device.device_network_id) + if err then + log.error(string.format("Error refreshing motion sensor %s: %s", (button_device and button_device.label), err)) + end + + attribute_emitters.emitter_for_device_type(HueDeviceTypes.BUTTON)(button_device, sensor_info) + return sensor_info +end -- TODO: Refresh handlers need to be optimized/generalized for devices with multiple services +---@param driver HueDriver +---@param sensor_device HueChildDevice +---@param _ any +---@param skip_zigbee boolean +---@return table? function RefreshHandlers.do_refresh_motion_sensor(driver, sensor_device, _, skip_zigbee) local hue_device_id = sensor_device:get_field(Fields.HUE_DEVICE_ID) local bridge_id = sensor_device.parent_device_id or sensor_device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(bridge_id) + local bridge_device = utils.get_hue_bridge_for_device(driver, sensor_device, bridge_id) if not bridge_device then - log.warn("Couldn't get Hue bridge for light " .. (sensor_device.label or sensor_device.id or "unknown device")) + log.warn("Couldn't get Hue bridge for motion_sensor " .. (sensor_device.label or sensor_device.id or "unknown device")) return end if not bridge_device:get_field(Fields._INIT) then - log.warn("Bridge for light not yet initialized, can't refresh yet.") + log.warn("Bridge for motion_sensor not yet initialized, can't refresh yet.") driver._devices_pending_refresh[sensor_device.id] = sensor_device return end @@ -173,26 +215,32 @@ function RefreshHandlers.do_refresh_motion_sensor(driver, sensor_device, _, skip _refresh_zigbee(sensor_device, hue_api) end - local sensor_info, err = SensorUtils.get_all_service_states(HueDeviceTypes.MOTION, hue_api, hue_device_id, bridge_id) + local sensor_info, err = MultiServiceDeviceUtils.get_all_service_states(driver, HueDeviceTypes.MOTION, hue_api, hue_device_id, bridge_device.device_network_id) if err then log.error(string.format("Error refreshing motion sensor %s: %s", (sensor_device and sensor_device.label), err)) end attribute_emitters.emitter_for_device_type(HueDeviceTypes.MOTION)(sensor_device, sensor_info) + return sensor_info end +---@param driver HueDriver +---@param sensor_device HueChildDevice +---@param _ any +---@param skip_zigbee boolean +---@return table? function RefreshHandlers.do_refresh_contact_sensor(driver, sensor_device, _, skip_zigbee) local hue_device_id = sensor_device:get_field(Fields.HUE_DEVICE_ID) local bridge_id = sensor_device.parent_device_id or sensor_device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(bridge_id) + local bridge_device = utils.get_hue_bridge_for_device(driver, sensor_device, bridge_id) if not bridge_device then - log.warn("Couldn't get Hue bridge for light " .. (sensor_device.label or sensor_device.id or "unknown device")) + log.warn("Couldn't get Hue bridge for contact sensor " .. (sensor_device.label or sensor_device.id or "unknown device")) return end if not bridge_device:get_field(Fields._INIT) then - log.warn("Bridge for light not yet initialized, can't refresh yet.") + log.warn("Bridge for contact sensor not yet initialized, can't refresh yet.") driver._devices_pending_refresh[sensor_device.id] = sensor_device return end @@ -202,18 +250,20 @@ function RefreshHandlers.do_refresh_contact_sensor(driver, sensor_device, _, ski _refresh_zigbee(sensor_device, hue_api) end - local sensor_info, err = SensorUtils.get_all_service_states(HueDeviceTypes.CONTACT, hue_api, hue_device_id, bridge_id) + local sensor_info, err = MultiServiceDeviceUtils.get_all_service_states(driver, HueDeviceTypes.CONTACT, hue_api, hue_device_id, bridge_device.device_network_id) if err then log.error(string.format("Error refreshing contact sensor %s: %s", (sensor_device and sensor_device.label), err)) end attribute_emitters.emitter_for_device_type(HueDeviceTypes.CONTACT)(sensor_device, sensor_info) + return sensor_info end ---@param driver HueDriver ---@param light_device HueChildDevice ---@param light_status_cache table|nil ---@param skip_zigbee boolean? +---@return HueLightInfo? light_info function RefreshHandlers.do_refresh_light(driver, light_device, light_status_cache, skip_zigbee) local light_resource_id = utils.get_hue_rid(light_device) local hue_device_id = light_device:get_field(Fields.HUE_DEVICE_ID) @@ -243,7 +293,7 @@ function RefreshHandlers.do_refresh_light(driver, light_device, light_status_cac end local bridge_id = light_device.parent_device_id or light_device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(bridge_id) + local bridge_device = utils.get_hue_bridge_for_device(driver, light_device, bridge_id) if not bridge_device then log.warn("Couldn't get Hue bridge for light " .. (light_device.label or light_device.id or "unknown device")) @@ -295,7 +345,7 @@ function RefreshHandlers.do_refresh_light(driver, light_device, light_status_cac light_device:set_field(Fields.GAMUT, light_info.color.gamut_type, { persist = true }) end attribute_emitters.emit_light_attribute_events(light_device, light_info) - success = true + return light_info end end end @@ -304,7 +354,7 @@ function RefreshHandlers.do_refresh_light(driver, light_device, light_status_cac if not success then cosock.socket.sleep(backoff_generator()) end - until success or count >= num_attempts + until count >= num_attempts end local function noop_refresh_handler(driver, device, ...) @@ -319,7 +369,9 @@ end -- TODO: Generalize this like the other handlers, and maybe even separate out non-primary services device_type_refresh_handlers_map[HueDeviceTypes.BRIDGE] = RefreshHandlers.do_refresh_all_for_bridge +device_type_refresh_handlers_map[HueDeviceTypes.BUTTON] = RefreshHandlers.do_refresh_button +device_type_refresh_handlers_map[HueDeviceTypes.CONTACT] = RefreshHandlers.do_refresh_contact_sensor device_type_refresh_handlers_map[HueDeviceTypes.LIGHT] = RefreshHandlers.do_refresh_light device_type_refresh_handlers_map[HueDeviceTypes.MOTION] = RefreshHandlers.do_refresh_motion_sensor -device_type_refresh_handlers_map[HueDeviceTypes.CONTACT] = RefreshHandlers.do_refresh_contact_sensor + return RefreshHandlers diff --git a/drivers/SmartThings/philips-hue/src/hue/api.lua b/drivers/SmartThings/philips-hue/src/hue/api.lua index de63c874f7..0afb9fbb47 100644 --- a/drivers/SmartThings/philips-hue/src/hue/api.lua +++ b/drivers/SmartThings/philips-hue/src/hue/api.lua @@ -317,8 +317,6 @@ 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 -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 @@ -340,51 +338,53 @@ function PhilipsHueApi:get_zigbee_connectivity_by_id(zigbee_resource_id) return self:get_rtype_by_rid("zigbee_connectivity", zigbee_resource_id) end +---@param button_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_button_by_id(button_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.BUTTON, button_resource_id) +end + ---@param contact_resource_id string ---@return HueResourceResponse? ---@return string? err nil on success function PhilipsHueApi:get_contact_by_id(contact_resource_id) - return self:get_rtype_by_rid("contact", contact_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.CONTACT, contact_resource_id) end ---@param motion_resource_id string ---@return HueResourceResponse? ---@return string? err nil on success function PhilipsHueApi:get_motion_by_id(motion_resource_id) - return self:get_rtype_by_rid("motion", motion_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.MOTION, motion_resource_id) end ---@param device_power_resource_id string ---@return HueResourceResponse? ---@return string? err nil on success function PhilipsHueApi:get_device_power_by_id(device_power_resource_id) - return self:get_rtype_by_rid("device_power", device_power_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.DEVICE_POWER, device_power_resource_id) end ---@param tamper_resource_id string ---@return HueResourceResponse? ---@return string? err nil on success function PhilipsHueApi:get_tamper_by_id(tamper_resource_id) - return self:get_rtype_by_rid("tamper", tamper_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.TAMPER, tamper_resource_id) end ---@param temperature_resource_id string ---@return HueResourceResponse? ---@return string? err nil on success function PhilipsHueApi:get_temperature_by_id(temperature_resource_id) - return self:get_rtype_by_rid("temperature", temperature_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.TEMPERATURE, temperature_resource_id) end ---@param light_level_resource_id string ---@return HueResourceResponse? ---@return string? err nil on success function PhilipsHueApi:get_light_level_by_id(light_level_resource_id) - return self:get_rtype_by_rid("light_level", light_level_resource_id) -end - - -function PhilipsHueApi:get_room_by_id(id) - return self:get_rtype_by_rid("room", id) + return self:get_rtype_by_rid(HueDeviceTypes.LIGHT_LEVEL, light_level_resource_id) end ---@param id string @@ -392,7 +392,7 @@ 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/light/%s", id) + local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, id) if type(on) ~= "boolean" then if on then @@ -413,7 +413,7 @@ end ---@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) if type(level) == "number" then - local url = string.format("/clip/v2/resource/light/%s", id) + local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, id) local payload_table = { dimming = { brightness = level } } return do_put(self, url, json.encode(payload_table)) @@ -432,7 +432,7 @@ function PhilipsHueApi:set_light_color_xy(id, xy_table) 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/light/%s", id) + local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, id) local payload = json.encode { color = { xy = xy_table }, on = { on = true } } return do_put(self, url, payload) else @@ -447,7 +447,7 @@ end ---@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) if type(mirek) == "number" then - local url = string.format("/clip/v2/resource/light/%s", id) + local url = string.format("/clip/v2/resource/%s/%s", HueDeviceTypes.LIGHT, 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_debug/init.lua b/drivers/SmartThings/philips-hue/src/hue_debug/init.lua index 3d4075213e..875c94076c 100644 --- a/drivers/SmartThings/philips-hue/src/hue_debug/init.lua +++ b/drivers/SmartThings/philips-hue/src/hue_debug/init.lua @@ -1,6 +1,6 @@ local st_utils = require "st.utils" -local log = require "logjam" +local log = require "log" local utils = require "utils" local Discovery = require "disco" diff --git a/drivers/SmartThings/philips-hue/src/hue_device_types.lua b/drivers/SmartThings/philips-hue/src/hue_device_types.lua index 0fdbd57c26..f0c4dec0c7 100644 --- a/drivers/SmartThings/philips-hue/src/hue_device_types.lua +++ b/drivers/SmartThings/philips-hue/src/hue_device_types.lua @@ -1,6 +1,7 @@ ---@enum HueDeviceTypes local HueDeviceTypes = { BRIDGE = "bridge", + BUTTON = "button", CONTACT = "contact", DEVICE_POWER = "device_power", LIGHT = "light", @@ -11,7 +12,14 @@ local HueDeviceTypes = { ZIGBEE_CONNECTIVITY = "zigbee_connectivity" } +local SupportedNumberOfButtons = { + [1] = true, -- For Philips Hue Smart Button or single switch In-Wall Switch module which contains only 1 button + [2] = true, -- For double switch In-Wall Switch module + [4] = true, -- For Philips Hue Dimmer Remote and Tap Dial, which contains 4 buttons +} + local PrimaryDeviceTypes = { + [HueDeviceTypes.BUTTON] = true, [HueDeviceTypes.CONTACT] = true, [HueDeviceTypes.LIGHT] = true, [HueDeviceTypes.MOTION] = true @@ -32,4 +40,34 @@ function HueDeviceTypes.is_valid_device_type(device_type_str) return bimap[device_type_str] ~= nil end +function HueDeviceTypes.supports_button_configuration(button_description) + return SupportedNumberOfButtons[button_description.num_buttons] +end + +---@param device_info HueDeviceInfo +---@param primary_services table +---@return HueDeviceTypes? svc_rtype the main service rtype for the device +function HueDeviceTypes.determine_main_service_rtype(device_info, primary_services) + -- If the id_v1 is present, it'll be of the form '//' + local service_from_v1_id = string.match((device_info.id_v1 or ""), "/([%a]+)/[%d]+") or "" + -- Lights show up as `light` here, but buttons and sensors both show up as `sensors` + if PrimaryDeviceTypes[service_from_v1_id] then + return service_from_v1_id + end + + local has_service = {} + for rtype, _ in pairs(primary_services) do + has_service[rtype] = PrimaryDeviceTypes[rtype] + end + + -- At this point we'll make our best guess by establishing an order of precedence as a heuristic; + -- lights first, then the actual sensors, then buttons. + if has_service[HueDeviceTypes.LIGHT] then return HueDeviceTypes.LIGHT end + if has_service[HueDeviceTypes.CONTACT] then return HueDeviceTypes.CONTACT end + if has_service[HueDeviceTypes.MOTION] then return HueDeviceTypes.MOTION end + if has_service[HueDeviceTypes.BUTTON] then return HueDeviceTypes.BUTTON end + + return nil +end + return HueDeviceTypes diff --git a/drivers/SmartThings/philips-hue/src/hue_driver_template.lua b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua index a93f300fd5..3f7f7d03c9 100644 --- a/drivers/SmartThings/philips-hue/src/hue_driver_template.lua +++ b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua @@ -1,7 +1,8 @@ local Driver = require "st.driver" local capabilities = require "st.capabilities" -local log = require "logjam" +local cosock = require "cosock" +local log = require "log" local Discovery = require "disco" local Fields = require "fields" @@ -65,6 +66,7 @@ local set_color_temp_handler = utils.safe_wrap_handler(command_handlers.set_colo --- @field public joined_bridges table --- @field public hue_identifier_to_device_record table --- @field public services_for_device_rid table> Map the device resource ID to another map that goes from service rid to service rtype +--- @field public waiting_grandchildren table? --- @field public stray_device_tx table cosock channel --- @field public datastore HueDriverDatastore persistent store --- @field public api_key_to_bridge_id table @@ -133,16 +135,44 @@ function HueDriver:run() Driver.run(self) end ----@param bridge_id string +---@param grandchild_devices { waiting_resource_info: HueResourceInfo, join_callback: fun(driver: HueDriver, waiting_resource_info: HueResourceInfo, parent_device: HueChildDevice)}[] +---@param waiting_for string +function HueDriver:queue_grandchild_device_for_join(grandchild_devices, waiting_for) + self.waiting_grandchildren = self.waiting_grandchildren or {} + + for _, waiting_info in ipairs(grandchild_devices) do + self.waiting_grandchildren[waiting_for] = self.waiting_grandchildren[waiting_for] or {} + table.insert(self.waiting_grandchildren[waiting_for], waiting_info) + end +end + +---@param new_device HueChildDevice +function HueDriver:check_waiting_grandchildren_for_device(new_device) + if not self.waiting_grandchildren then + return + end + local rid = utils.get_hue_rid(new_device) + for _, waiting in pairs(self.waiting_grandchildren[rid or ""] or {}) do + local waiting_info = waiting.waiting_resource_info + local join_callback = waiting.join_callback + if type(join_callback) == "function" then + cosock.spawn(function() + join_callback(self, waiting_info, new_device) + end) + end + end +end + +---@param bridge_network_id string ---@param bridge_info HueBridgeInfo -function HueDriver:update_bridge_netinfo(bridge_id, bridge_info) - if self.joined_bridges[bridge_id] then - local bridge_device = self:get_device_by_dni(bridge_id) --[[@as HueBridgeDevice]] +function HueDriver:update_bridge_netinfo(bridge_network_id, bridge_info) + if self.joined_bridges[bridge_network_id] then + local bridge_device = self:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] if not bridge_device then log.warn_with({ hub_logs = true }, string.format( "Couldn't locate bridge device for joined bridge with DNI %s", - bridge_id + bridge_network_id ) ) return @@ -151,7 +181,7 @@ function HueDriver:update_bridge_netinfo(bridge_id, bridge_info) if bridge_info.ip ~= bridge_device:get_field(Fields.IPV4) then bridge_utils.update_bridge_fields_from_info(self, bridge_info, bridge_device) local maybe_api_client = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] - local maybe_api_key = bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) or Discovery.api_keys[bridge_id] + local maybe_api_key = bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) or Discovery.api_keys[bridge_network_id] local maybe_event_source = bridge_device:get_field(Fields.EVENT_SOURCE) local bridge_url = "https://" .. bridge_info.ip diff --git a/drivers/SmartThings/philips-hue/src/init.lua b/drivers/SmartThings/philips-hue/src/init.lua index 8f20a40706..a0a0e21b17 100644 --- a/drivers/SmartThings/philips-hue/src/init.lua +++ b/drivers/SmartThings/philips-hue/src/init.lua @@ -19,7 +19,7 @@ -- =============================================================================================== local Driver = require "st.driver" -local log = require "logjam" +local log = require "log" local st_utils = require "st.utils" -- trick to fix the VS Code Lua Language Server typechecking ---@type fun(val: table, name: string?, multi_line: boolean?): string @@ -29,15 +29,7 @@ local Discovery = require "disco" local HueDriverTemplate = require "hue_driver_template" --- @type HueDriver -local hue = Driver("hue", HueDriverTemplate.new_driver_template( - { - -- enable_debug = true, - -- delay_bridges = true, - -- force_stray_for_device_type = { - -- "light" - -- } - } -)) +local hue = Driver("hue", HueDriverTemplate.new_driver_template()) if hue.datastore["bridge_netinfo"] == nil then hue.datastore["bridge_netinfo"] = {} diff --git a/drivers/SmartThings/philips-hue/src/stray_device_helper.lua b/drivers/SmartThings/philips-hue/src/stray_device_helper.lua index 23cceb8c36..721a56b8de 100644 --- a/drivers/SmartThings/philips-hue/src/stray_device_helper.lua +++ b/drivers/SmartThings/philips-hue/src/stray_device_helper.lua @@ -1,5 +1,5 @@ local cosock = require "cosock" -local log = require "log" +local log = require "logjam" local st_utils = require "st.utils" local Discovery = require "disco" @@ -9,8 +9,12 @@ local HueDeviceTypes = require "hue_device_types" local utils = require "utils" +---@type { lifecycle_handlers: LifecycleHandlers } local lazy_handlers = utils.lazy_handler_loader("handlers") +---@type { [string]: DiscoveredChildDeviceHandler } +local lazy_disco_handlers = utils.lazy_handler_loader("disco") + ---@class StrayDeviceHelper local StrayDeviceHelper = {} @@ -21,6 +25,44 @@ local MessageTypes = { } StrayDeviceHelper.MessageTypes = MessageTypes +local function check_strays_for_match(hue_driver, api_instance, stray_devices, bridge_device_uuid, device_data, svc_info) + for _, stray_device in pairs(stray_devices) do + local matching_v1_id = stray_device.data and stray_device.data.bulbId and + stray_device.data.bulbId == device_data.id_v1:gsub("/lights/", "") + local matching_uuid = utils.get_hue_rid(stray_device) == svc_info.rid or + stray_device.device_network_id == svc_info.rid + + if matching_v1_id or matching_uuid then + stray_device:set_field(Fields.RESOURCE_ID, svc_info.rid, { persist = true }) + local api_key_extracted = api_instance.headers["hue-application-key"] + log.info_with({ hub_logs = true }, " ", (stray_device.label or stray_device.id or "unknown device"), + ", re-adding") + log.info_with({ hub_logs = true }, string.format( + 'Found Bridge for stray device %s, retrying onboarding flow.\n' .. + '\tMatching v1 id? %s\n' .. + '\tMatching uuid? %s\n' .. + '\tdevice DNI: %s\n' .. + '\tdevice Parent Assigned Key: %s\n' .. + '\tdevice parent device id: %s\n' .. + '\tProvided bridge_device_id: %s\n' .. + '\tAPI key cached for given bridge_device_id? %s\n' .. + '\tCached bridge device for given API key: %s\n' + , + stray_device.label, + matching_v1_id, + matching_uuid, + stray_device.device_network_id, + stray_device.parent_assigned_child_key, + stray_device.parent_device_id, + bridge_device_uuid, + (Discovery.api_keys[hue_driver:get_device_info(bridge_device_uuid).device_network_id] ~= nil), + hue_driver.api_key_to_bridge_id[api_key_extracted] + )) + break + end + end +end + ---@param driver HueDriver ---@param strays table ---@param bridge_device_uuid string @@ -36,7 +78,9 @@ function StrayDeviceHelper.process_strays(driver, strays, bridge_device_uuid) local cached_device_description = Discovery.device_state_disco_cache[device_rid] if cached_device_description then table.insert(dnis_to_remove, device.device_network_id) - lazy_handlers.lifecycle_handlers.migrate_device(driver, device, bridge_device_uuid, cached_device_description, {force_migrate_type = HueDeviceTypes.LIGHT}) + lazy_handlers.lifecycle_handlers.initialize_device( + driver, device, "added", nil, bridge_device_uuid, cached_device_description + ) end ::continue:: end @@ -46,6 +90,66 @@ function StrayDeviceHelper.process_strays(driver, strays, bridge_device_uuid) end end +---@param hue_driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services HueServiceInfo +---@param device_data HueDeviceInfo +---@param msg_device HueDevice +---@param bridge_device_uuid string +---@param stray_devices table +function StrayDeviceHelper.discovery_callback( + hue_driver, bridge_network_id, api_instance, primary_services, + device_data, msg_device, bridge_device_uuid, stray_devices +) + for _, svc_info in pairs(primary_services) do + if not (HueDeviceTypes.can_join_device_for_service(svc_info.rtype)) then return end + local service_resource, rest_err, _ = api_instance:get_rtype_by_rid(svc_info.rtype, svc_info.rid) + if rest_err ~= nil or not service_resource then + log.error_with({ hub_logs = true }, string.format( + "Error getting device info info while processing new bridge %s", + (msg_device.label or msg_device.id or "unknown device"), rest_err + )) + return + end + + if service_resource.errors and #service_resource.errors > 0 then + log.error_with({ hub_logs = true }, "Errors found in API response:") + for idx, resource_err in ipairs(service_resource.errors) do + log.error_with({ hub_logs = true }, string.format( + "Error Number %s in get_rtype_by_rid response while onboarding bridge %s: %s", + idx, + (msg_device.label or msg_device.id or "unknown device"), + st_utils.stringify_table(resource_err) + )) + end + return + end + + if service_resource.data and #service_resource.data > 0 then + lazy_disco_handlers[svc_info.rtype].handle_discovered_device( + hue_driver, + bridge_network_id, + api_instance, + primary_services, + device_data, + Discovery.device_state_disco_cache, + nil + ) + + check_strays_for_match( + hue_driver, + api_instance, + stray_devices, + bridge_device_uuid, + device_data, + svc_info + ) + end + end + +end + --- Spawn the stray device resolution task, returning a handle to the tx side of the --- channel for controlling it. function StrayDeviceHelper.spawn() @@ -103,120 +207,41 @@ function StrayDeviceHelper.spawn() found_bridges[msg_device.id] = msg.device local bridge_device_uuid = msg_device.id - -- TODO: We can optimize around this by keeping track of whether or not this bridge - -- needs to be scanned (maybe skip scanning if there are no stray devices?) - -- - -- @doug.stephen@smartthings.com - log.info( - string.format( - "Stray devices handler notified of new bridge %s, scanning bridge", + if next(stray_devices) ~= nil then + log.info( + string.format( + "Stray devices handler notified of new bridge %s, scanning bridge", + (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, + msg_device:get_field(Fields.BRIDGE_ID), + api_instance, + function(driver, bridge_network_id, primary_services, device_data) + StrayDeviceHelper.discovery_callback( + driver, + bridge_network_id, + api_instance, + primary_services, + device_data, + msg_device, + bridge_device_uuid, + stray_devices + ) + end, + "[process_strays]" + ) + log.info(string.format( + "Finished querying bridge %s for devices from stray devices handler", (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, 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 - - local device_light_resource_id = svc_info.rid - local light_resource, rest_err, _ = api_instance:get_light_by_id(device_light_resource_id) - if rest_err ~= nil or not light_resource then - log.error_with({ hub_logs = true }, string.format( - "Error getting light info while processing new bridge %s", - (msg_device.label or msg_device.id or "unknown device"), rest_err - )) - return - end - - if light_resource.errors and #light_resource.errors > 0 then - log.error_with({ hub_logs = true }, "Errors found in API response:") - for idx, resource_err in ipairs(light_resource.errors) do - log.error_with({ hub_logs = true }, string.format( - "Error Number %s in get_light_by_id response while onboarding bridge %s: %s", - idx, - (msg_device.label or msg_device.id or "unknown device"), - st_utils.stringify_table(resource_err) - )) - end - return - end - - if light_resource.data and #light_resource.data > 0 then - for _, light in ipairs(light_resource.data) do - ---@type HueLightInfo - local light_resource_description = { - hue_provided_name = device_data.metadata.name, - id = light.id, - on = light.on, - color = light.color, - dimming = light.dimming, - color_temperature = light.color_temperature, - mode = light.mode, - parent_device_id = bridge_device_uuid, - hue_device_id = light.owner.rid, - hue_device_data = device_data - } - if not Discovery.device_state_disco_cache[light.id] then - log.info(string.format("Caching previously unknown service description for %s", - device_data.metadata.name)) - Discovery.device_state_disco_cache[light.id] = light_resource_description - if device_data.id_v1 then - Discovery.device_state_disco_cache[device_data.id_v1] = light_resource_description - end - end - end - end - - for _, stray_device in pairs(stray_devices) do - local matching_v1_id = stray_device.data and stray_device.data.bulbId and - stray_device.data.bulbId == device_data.id_v1:gsub("/lights/", "") - local matching_uuid = utils.get_hue_rid(stray_device) == svc_info.rid or - stray_device.device_network_id == svc_info.rid - - if matching_v1_id or matching_uuid then - stray_device:set_field(Fields.RESOURCE_ID, svc_info.rid, { persist = true }) - local api_key_extracted = api_instance.headers["hue-application-key"] - log.info_with({ hub_logs = true }, " ", (stray_device.label or stray_device.id or "unknown device"), - ", re-adding") - log.info_with({ hub_logs = true }, string.format( - 'Found Bridge for stray device %s, retrying onboarding flow.\n' .. - '\tMatching v1 id? %s\n' .. - '\tMatching uuid? %s\n' .. - '\tdevice DNI: %s\n' .. - '\tdevice Parent Assigned Key: %s\n' .. - '\tdevice parent device id: %s\n' .. - '\tProvided bridge_device_id: %s\n' .. - '\tAPI key cached for given bridge_device_id? %s\n' .. - '\tCached bridge device for given API key: %s\n' - , - stray_device.label, - matching_v1_id, - matching_uuid, - stray_device.device_network_id, - stray_device.parent_assigned_child_key, - stray_device.parent_device_id, - bridge_device_uuid, - (Discovery.api_keys[hue_driver:get_device_info(bridge_device_uuid).device_network_id] ~= nil), - hue_driver.api_key_to_bridge_id[api_key_extracted] - )) - break - end - end - end, - "[process_strays]" - ) - log.info(string.format( - "Finished querying bridge %s for devices from stray devices handler", - (msg.device.label or msg.device.device_network_id or msg.device.id or "unknown bridge") - ) - ) - StrayDeviceHelper.process_strays(thread_local_driver, stray_devices, msg_device.id) + ) + StrayDeviceHelper.process_strays(thread_local_driver, stray_devices, msg_device.id) + end elseif msg.type == StrayDeviceHelper.MessageTypes.NewStrayDevice then stray_devices[msg_device.device_network_id] = msg_device - local maybe_bridge_id = - msg_device.parent_device_id or msg_device:get_field(Fields.PARENT_DEVICE_ID) - local maybe_bridge = found_bridges[maybe_bridge_id] + local maybe_bridge = utils.get_hue_bridge_for_device(thread_local_driver, msg_device) if maybe_bridge ~= nil then local bridge_ip = maybe_bridge:get_field(Fields.IPV4) diff --git a/drivers/SmartThings/philips-hue/src/hue/types.lua b/drivers/SmartThings/philips-hue/src/types.lua similarity index 93% rename from drivers/SmartThings/philips-hue/src/hue/types.lua rename to drivers/SmartThings/philips-hue/src/types.lua index 1adfd4349c..d01868ab5c 100644 --- a/drivers/SmartThings/philips-hue/src/hue/types.lua +++ b/drivers/SmartThings/philips-hue/src/types.lua @@ -13,6 +13,7 @@ ---@field public metadata { name: string, [string]: any} ---@field public id string ---@field public id_v1 string? +---@field public service_id? integer ---@field public type HueDeviceTypes ---@field public owner HueServiceInfo? ---@field public hue_provided_name string @@ -58,6 +59,10 @@ ---@field public enabled boolean ---@field public light { light_level: number, light_level_valid: boolean, light_level_report: { light_level: number, changed: string } } +---@class HueButtonInfo: HueResourceInfo +---@field public metadata { control_id: integer } +---@field public button { repeat_interval: integer, last_event: string?, button_report: { updated: string, event: string }?, event_values: string[] } + ---@class HueContactInfo: HueResourceInfo ---@field public enabled boolean ---@field public contact_report { changed: string, state: string } diff --git a/drivers/SmartThings/philips-hue/src/hue/cie_utils.lua b/drivers/SmartThings/philips-hue/src/utils/cie_utils.lua similarity index 100% rename from drivers/SmartThings/philips-hue/src/hue/cie_utils.lua rename to drivers/SmartThings/philips-hue/src/utils/cie_utils.lua 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 930ad2ca38..040e8c88f0 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -7,12 +7,14 @@ local Discovery = require "disco" local EventSource = require "lunchbox.sse.eventsource" local Fields = require "fields" local HueApi = require "hue.api" +local HueDeviceTypes = require "hue_device_types" local StrayDeviceHelper = require "stray_device_helper" local attribute_emitters = require "handlers.attribute_emitters" local command_handlers = require "handlers.commands" 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" @@ -147,6 +149,9 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u end for _, event in ipairs(events) do + -- TODO as mentioned in a paired TODO in utils.lua, we could try to add special-case handling + -- here to detect batched button releases. This would let us support multi-press capability + -- events. if event.type == "update" then for _, update_data in ipairs(event.data) do log.debug(true, "Received update event with type " .. update_data.type) @@ -159,8 +164,8 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u end end else - --- for a regular message from a light doing something normal, - --- you get the resource id of the light service for that device in + --- for a regular message from a device doing something normal, + --- you get the resource id of the device service for that device in --- the data field table.insert(resource_ids, update_data.id) end @@ -181,13 +186,13 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u end elseif event.type == "delete" then for _, delete_data in ipairs(event.data) do - if delete_data.type == "light" then + if HueDeviceTypes.can_join_device_for_service(delete_data.type) then local resource_id = delete_data.id local child_device = driver.hue_identifier_to_device_record[resource_id] if child_device ~= nil and child_device.id ~= nil then log.info( string.format( - "Light device \"%s\" was deleted from hue bridge %s", + "Device \"%s\" was deleted from hue bridge %s", (child_device.label or child_device.id or "unknown device"), (bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge") ) @@ -199,122 +204,24 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u end elseif event.type == "add" then for _, add_data in ipairs(event.data) do - if add_data.type == "light" and add_data.owner and add_data.owner.rtype == "device" then - ---@cast add_data HueLightInfo + if add_data.type == "device" then log.info( string.format( - "New light added to Hue Bridge \"%s\", light properties: \"%s\"", + "New device added to Hue Bridge \"%s\", device properties: \"%s\"", bridge_device.label, json.encode(add_data) ) ) - + --- Move handling the add off the SSE thread cosock.spawn(function() - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] - if hue_api == nil then - local _log = bridge_device.log or log - _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 - ) + ---@cast add_data HueDeviceInfo + Discovery.process_device_service( + driver, + bridge_device.device_network_id, + add_data, + Discovery.handle_discovered_child_device, + "New Device Event", + bridge_device ) - - 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 = string.format("%s:%s", add_data.type, add_data.id) - } - - Discovery.device_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 @@ -328,10 +235,18 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u bridge_device:set_field(Fields._INIT, true, { persist = false }) local ids_to_remove = {} for id, device in ipairs(driver._devices_pending_refresh) do - local bridge_id = device.parent_device_id or bridge_device:get_field(Fields.PARENT_DEVICE_ID) + local parent_bridge = utils.get_hue_bridge_for_device(driver, device) + local bridge_id = parent_bridge and parent_bridge.id if bridge_id == bridge_device.id then table.insert(ids_to_remove, id) - command_handlers.refresh_handler(driver, device) + local refresh_info = command_handlers.refresh_handler(driver, device) + if refresh_info and device:get_field(Fields.IS_MULTI_SERVICE) then + local hue_device_type = utils.determine_device_type(device) + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, refresh_info, hue_device_type + ) + end end end for _, id in ipairs(ids_to_remove) do diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_motion_sensor_utils.lua b/drivers/SmartThings/philips-hue/src/utils/hue_motion_sensor_utils.lua deleted file mode 100644 index 16d7d0a7af..0000000000 --- a/drivers/SmartThings/philips-hue/src/utils/hue_motion_sensor_utils.lua +++ /dev/null @@ -1,17 +0,0 @@ -local utils = require "utils" - -local lazy_disco_handlers = utils.lazy_handler_loader("disco") ----@class SensorUtils -local motion_sensor_utils = {} - ----@param sensor_device_type HueDeviceTypes ----@param api_instance PhilipsHueApi ----@param device_service_id string ----@param bridge_id string ----@return table? nil on error ----@return string? err nil on success -function motion_sensor_utils.get_all_service_states(sensor_device_type, api_instance, device_service_id, bridge_id) - return lazy_disco_handlers[sensor_device_type].update_all_services_for_sensor(api_instance, device_service_id, bridge_id) -end - -return motion_sensor_utils diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/init.lua b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/init.lua new file mode 100644 index 0000000000..ccb0153510 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/init.lua @@ -0,0 +1,45 @@ +local log = require "log" +local utils = require "utils" + +local HueDeviceTypes = require "hue_device_types" + +local lazy_disco_handlers = utils.lazy_handler_loader("disco") +local lazy_map_helpers = utils.lazy_handler_loader("utils.hue_multi_service_device_utils") + +local lookup_transforms = { + [HueDeviceTypes.MOTION] = "sensor", + [HueDeviceTypes.CONTACT] = "sensor", + [HueDeviceTypes.BUTTON] = "sensor" +} + +---@class MultiServiceDeviceUtils +local multi_service_device_utils = {} + +-- TODO refactor this to be generalized for all sensors, similar to the multi service map update. +---@param driver HueDriver +---@param sensor_device_type HueDeviceTypes +---@param api_instance PhilipsHueApi +---@param device_service_id string +---@param bridge_network_id string +---@return table? nil on error +---@return string? err nil on success +function multi_service_device_utils.get_all_service_states(driver, sensor_device_type, api_instance, device_service_id, bridge_network_id) + return lazy_disco_handlers[sensor_device_type].update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id) +end + +function multi_service_device_utils.update_multi_service_device_maps(driver, device, hue_device_id, device_info, device_type) + device_type = device_type or utils.determine_device_type(device) + device_type = lookup_transforms[device_type] or device_type + if not lazy_map_helpers[device_type] then + log.warn( + string.format( + "No multi-service device mapping helper for device %s with type %s", + (device and device.label) or "unknown device", + device_type or "unknown type" + ) + ) + end + return lazy_map_helpers[device_type].update_multi_service_device_maps(driver, device, hue_device_id, device_info) +end + +return multi_service_device_utils diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/sensor.lua b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/sensor.lua new file mode 100644 index 0000000000..a8df7520d6 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/sensor.lua @@ -0,0 +1,23 @@ +local SensorMultiServiceHelper = {} +function SensorMultiServiceHelper.update_multi_service_device_maps(driver, device, hue_device_id, sensor_info) + local svc_rids_for_device = driver.services_for_device_rid[hue_device_id] or {} + + if type(sensor_info.sensor_list) == "table" then + for id_key, sensor_type in pairs(sensor_info.sensor_list) do + if + sensor_info and + sensor_info[id_key] and + not svc_rids_for_device[sensor_info[id_key]] + then + svc_rids_for_device[sensor_info[id_key]] = sensor_type + end + end + end + + driver.services_for_device_rid[hue_device_id] = svc_rids_for_device + for rid, _ in pairs(driver.services_for_device_rid[hue_device_id]) do + driver.hue_identifier_to_device_record[rid] = device + end +end + +return SensorMultiServiceHelper diff --git a/drivers/SmartThings/philips-hue/src/utils.lua b/drivers/SmartThings/philips-hue/src/utils/init.lua similarity index 82% rename from drivers/SmartThings/philips-hue/src/utils.lua rename to drivers/SmartThings/philips-hue/src/utils/init.lua index fa82c77afd..ab269c3e33 100644 --- a/drivers/SmartThings/philips-hue/src/utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/init.lua @@ -44,12 +44,30 @@ function utils.safe_wrap_handler(handler) end local success, result = pcall(handler, driver, device, ...) if not success then - log.error_with({ hub_logs = true }, string.format("Failed to invoke capability command handler. Reason: %s", result)) + log.error_with({ hub_logs = true }, + string.format("Failed to invoke capability command handler. Reason: %s", result)) end return result end end +-- TODO: The Hue API itself doesn't have events for multipresses, however, it will +-- emit batched "short release" eventsource on the SSE stream if they're close together. +-- Right now the SSE stream handling is a relatively dumb pass-through that doesn't inspect +-- the data as it unpacks it; it just dispatches each event in the batch as it encounters it. +-- We could implement 2x-6x press if we add some smarts to the SSE stream handling. +function utils.get_supported_button_values(event_values) + local values = { "pushed" } + for _, event_value in ipairs(event_values) do + if event_value == "long_press" then + table.insert(values, "held") + break + end + end + + return values +end + function utils.kelvin_to_mirek(kelvin) return 1000000 / kelvin end function utils.mirek_to_kelvin(mirek) return 1000000 / mirek end @@ -195,7 +213,8 @@ function utils.get_hue_rid(device) end if utils.is_dth_light(device) then - return nil, string.format("Can't get the Hue RID of migrated DTH light %s that hasn't completed migration", device.label) + return nil, + string.format("Can't get the Hue RID of migrated DTH light %s that hasn't completed migration", device.label) end local success, rid, _ = utils.parse_parent_assigned_key(device) @@ -204,11 +223,11 @@ function utils.get_hue_rid(device) end return - nil, - string.format( - "error establishing Hue RID from parent assigned key [%s]", - (device and device.parent_assigned_child_key) or "Parent Assigned Key Not Available" - ) + nil, + string.format( + "error establishing Hue RID from parent assigned key [%s]", + (device and device.parent_assigned_child_key) or "Parent Assigned Key Not Available" + ) end --- Get the HueDeviceType value for a device. If available on the device field, then it @@ -249,11 +268,11 @@ function utils.determine_device_type(device) end return - nil, - string.format( - "Couldn't determine device type for device %s", - (device and device.label) or "Unknown Device" - ) + nil, + string.format( + "Couldn't determine device type for device %s", + (device and device.label) or "Unknown Device" + ) end --- Attempts an exhaustive check of all the ways a device @@ -263,9 +282,9 @@ end ---@return boolean is_bridge true if the device record represents a Hue Bridge function utils.is_bridge(driver, device) return (device:get_field(Fields.DEVICE_TYPE) == "bridge") - or (driver.datastore.bridge_netinfo[device.device_network_id] ~= nil) - or utils.is_edge_bridge(device) or utils.is_dth_light(device) - or (device.parent_assigned_child_key == nil) + or (driver.datastore.bridge_netinfo[device.device_network_id] ~= nil) + or utils.is_edge_bridge(device) or utils.is_dth_light(device) + or (device.parent_assigned_child_key == nil) end --- Only checked during `added` callback, or as a later @@ -276,22 +295,9 @@ end ---@return boolean function utils.is_edge_bridge(device) return - device.device_network_id and - utils.is_valid_mac_addr_string(device.device_network_id) and - not (device.data and device.data.username) -end - ---- Only checked during `added` callback, or as a later ---- fallback check in the chain of booleans used in `is_bridge`. ---- ----@see utils.is_bridge ----@param device HueDevice ----@return boolean -function utils.is_edge_light(device) - return - device.parent_assigned_child_key ~= nil and - not utils.is_valid_mac_addr_string(device.device_network_id) and - not (device.data and device.data.username and device.data.bulbId) + device.device_network_id and + utils.is_valid_mac_addr_string(device.device_network_id) and + not (device.data and device.data.username) end --- Only checked during `added` callback @@ -316,6 +322,35 @@ function utils.is_dth_light(device) and device.data.username ~= nil end +--- Get the Hue Bridge for a Device; useful when you don't know if you have a child +--- or a grandchild device and you need to walk up the family tree. Will return the +--- passed argument if the argument is a bridge. +---@param driver HueDriver +---@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)) + if utils.is_bridge(driver, device) then + log.trace(string.format("------------------------- %s is a bridge", device.label)) + return device --[[ @as HueBridgeDevice ]] + end + + 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)) + return nil + end + + 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) +end + --- build a exponential backoff time value generator --- ---@param max number the maximum wait interval (not including `rand factor`)