Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 101 additions & 44 deletions drivers/SmartThings/matter-window-covering/src/init.lua
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
-- Copyright 2022 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0


--Note: Currently only support for window shades with the PositionallyAware Feature
--Note: No support for setting device into calibration mode, it must be done manually
local capabilities = require "st.capabilities"
local im = require "st.matter.interaction_model"
local clusters = require "st.matter.clusters"
local device_lib = require "st.device"
local MatterDriver = require "st.matter.driver"
local utils = require "st.utils"

local IS_PARENT_CHILD_DEVICE = "__is_parent_child_device"
--- If the ASSIGNED_CHILD_KEY field is populated for an endpoint, it should be
--- used as the key in the get_child_by_parent_assigned_key() function. This allows
--- multiple endpoints to associate with the same child device, though right now child
local ASSIGNED_CHILD_KEY = "__assigned_child_key"

local battery_support = {
NO_BATTERY = "NO_BATTERY",
BATTERY_LEVEL = "BATTERY_LEVEL",
Expand All @@ -36,6 +42,27 @@ local TARGET_TILT_PERCENT = {

local TARGET_REACH_TOLERANCE = 0.5

local function get_field_for_endpoint(device, field, endpoint)
local field_device = device
if device.network_type == device_lib.NETWORK_TYPE_CHILD then
field_device = device:get_parent_device()
end
return field_device:get_field(string.format("%s_%d", field, endpoint))
end

local function set_field_for_endpoint(device, field, endpoint, value, additional_params)
local field_device = device
if device.network_type == device_lib.NETWORK_TYPE_CHILD then
field_device = device:get_parent_device()
end
field_device:set_field(string.format("%s_%d", field, endpoint), value, additional_params)
end

local function find_child(parent_device, ep_id)
local assigned_key = parent_device:get_field(string.format("%s_%d", ASSIGNED_CHILD_KEY, ep_id)) or ep_id
return parent_device:get_child_by_parent_assigned_key(string.format("%d", assigned_key))
end

--- Gets the current window shade status based on the lift and tilt positions.
---@param device any a Matter device object
---@return any status the current window shade status
Expand Down Expand Up @@ -92,40 +119,42 @@ end
--- Clears the cached target percentage and cancels any associated timeout.
--- @param device any a Matter device object
--- @param op_fields table a table containing operational field constants used for caching target percentages and timeouts.
local function clear_cached_data(device, op_fields)
local current_timer = device:get_field(op_fields.TIMEOUT)
local function clear_cached_data(device, op_fields, ep)
local current_timer = get_field_for_endpoint(device, op_fields.TIMEOUT, ep)
if current_timer then
device.thread:cancel_timer(current_timer)
end
device:set_field(op_fields.STATE, nil)
device:set_field(op_fields.TIMEOUT, nil)
set_field_for_endpoint(device, op_fields.STATE, ep, nil)
set_field_for_endpoint(device, op_fields.TIMEOUT, ep, nil)
end

--- Tries to emit the idle window shade status if no target percentages are set.
--- @param device any a Matter device object
local function try_emit_idle_window_shade_status(device)
if not device:get_field(TARGET_LIFT_PERCENT.STATE) and
not device:get_field(TARGET_TILT_PERCENT.STATE) then
device:emit_event(get_idle_window_shade_status(device))
local function try_emit_idle_window_shade_status(device, ep)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed in the test video that you shared that the window shade state sometimes appears to be wrong when idle (i.e. after the window is done closing, it might show "Open" in the app). This because we are always reading the get_idle_window_shade_status() from the parent device rather than using the child device when updating a child endpoint.

This could be fixed by adding something like this below:

if not device:get_field(TARGET_LIFT_PERCENT.STATE) and
   not device:get_field(TARGET_TILT_PERCENT.STATE) then
    --- gets child device if one exists for this endpoint
    local status_device = ep and find_child(device, ep) or device
    status_device:emit_event(get_idle_window_shade_status(status_device))
  end

if not get_field_for_endpoint(device, TARGET_LIFT_PERCENT.STATE, ep) and
not get_field_for_endpoint(device, TARGET_TILT_PERCENT.STATE, ep) then
--- gets child device if one exists for this endpoint
local status_device = find_child(device, ep) or device
status_device:emit_event(get_idle_window_shade_status(status_device))
end
end

--- Caches the target percentage for a device and sets a timeout to clear it.
--- @param device any a Matter device object
--- @param data number the target percentage to cache
--- @param op_fields table a table containing operational field constants used for caching target percentages and timeouts.
local function cache_data_with_timeout(device, data, op_fields)
device:set_field(op_fields.STATE, data)
local previous_timer = device:get_field(op_fields.TIMEOUT)
local function cache_data_with_timeout(device, data, op_fields, ep)
set_field_for_endpoint(device, op_fields.STATE, ep, data)
local previous_timer = get_field_for_endpoint(device, op_fields.TIMEOUT, ep)
if previous_timer then
device.thread:cancel_timer(previous_timer)
end
local new_timer = device.thread:call_with_delay(op_fields.TIMEOUT_DELAY_S, function()
device:set_field(op_fields.STATE, nil)
device:set_field(op_fields.TIMEOUT, nil)
try_emit_idle_window_shade_status(device)
set_field_for_endpoint(device, op_fields.STATE, ep, nil)
set_field_for_endpoint(device, op_fields.TIMEOUT, ep, nil)
try_emit_idle_window_shade_status(device, ep)
end)
device:set_field(op_fields.TIMEOUT, new_timer)
set_field_for_endpoint(device, op_fields.TIMEOUT, ep, new_timer)
end

local function is_target_value_reached(current_value, target_value)
Expand Down Expand Up @@ -173,11 +202,37 @@ local function match_profile(device, battery_supported)
device:try_update_metadata({profile = profile_name})
end

local function create_or_update_multi_devices(driver, device, wc_cluster_eps)
local default_endpoint_id = find_default_endpoint(device, clusters.WindowCovering.ID)
table.sort(wc_cluster_eps)
for device_num, ep_id in ipairs(wc_cluster_eps) do
if ep_id ~= default_endpoint_id then -- don't create a child device that maps to the main endpoint
local label_and_name = string.format("%s %d", device.label or "Window Covering", device_num)
local existing_child_device = device:get_field(IS_PARENT_CHILD_DEVICE) and find_child(device, ep_id)
if not existing_child_device then
driver:try_create_device({
type = "EDGE_CHILD",
label = label_and_name,
profile = "window-covering",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming - will all the child endpoints always have the window-covering profile? For example, none of them support tilt, right?

In the future we might want to make this dynamic similar to how we handle child devices in the matter-switch driver, but if this is the only multi-endpoint device we know of for window coverings, we can leave it as is for now and plan to make this dynamic in a follow up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how the child lifecycle works, but from the logs it looks like do_configure gets called for each child once it’s created as an EDGE_CHILD. So it seems we don’t need to check the feature map here.

Could you take a look at how the lifecycle is currently set up and let me know what you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, child devices do get the do_configure event. And currently it looks like all child devices would go through the same match_profile as the parent device because of this.

When I look at the testing you provided in the linked confluence page, it looks like all these child devices do have tilt support.

parent_device_id = device.id,
parent_assigned_child_key = string.format("%d", ep_id),
vendor_provided_label = label_and_name
})
end
end
end
-- Persist so that the find_child function is always set on each driver init.
device:set_field(IS_PARENT_CHILD_DEVICE, true, {persist = true})
device:set_find_child(find_child)
Comment thread
ctowns marked this conversation as resolved.
end

local function device_init(driver, device)
if device:get_field(IS_PARENT_CHILD_DEVICE) then
device:set_find_child(find_child)
end
-- set unused fields to nil. These field settings can be removed after a single driver update.
device:set_field("__preset_level_key", nil)
device:set_field("__reverse_polarity", nil)

device:set_component_to_endpoint_fn(component_to_endpoint)
if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and
device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then
Expand All @@ -191,6 +246,10 @@ local function device_init(driver, device)
end

local function do_configure(driver, device)
local wc_cluster_eps = device:get_endpoints(clusters.WindowCovering.ID)
if #wc_cluster_eps > 1 then
create_or_update_multi_devices(driver, device, wc_cluster_eps)
end
local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY})
if #battery_feature_eps > 0 then
local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {})
Expand All @@ -204,7 +263,7 @@ end
local function info_changed(driver, device, event, args)
if device.profile.id ~= args.old_st_store.profile.id then
device:subscribe()
elseif device.matter_version.software ~= args.old_st_store.matter_version.software then
elseif args.old_st_store.matter_version and device.matter_version.software ~= args.old_st_store.matter_version.software then
local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY})
if #battery_feature_eps > 0 then
local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {})
Expand All @@ -224,15 +283,15 @@ end

-- capability handlers
local function handle_preset(driver, device, cmd)
local endpoint_id = device:component_to_endpoint(cmd.component)
local preset_target_percent = device:get_latest_state(
"main",
capabilities.windowShadePreset.ID,
capabilities.windowShadePreset.position.NAME,
DEFAULT_PRESET_LEVEL
)
cache_data_with_timeout(device, preset_target_percent, TARGET_LIFT_PERCENT)
cache_data_with_timeout(device, preset_target_percent, TARGET_LIFT_PERCENT, endpoint_id)
local hundredths_lift_percent = (100 - preset_target_percent) * 100
local endpoint_id = device:component_to_endpoint(cmd.component)
device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage(
device, endpoint_id, hundredths_lift_percent
))
Expand All @@ -246,10 +305,10 @@ end
local function handle_close(driver, device, cmd)
local endpoint_id = device:component_to_endpoint(cmd.component)
if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) > 0 then
cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_LIFT_PERCENT)
cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_LIFT_PERCENT, endpoint_id)
end
if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then
cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_TILT_PERCENT)
cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_TILT_PERCENT, endpoint_id)
end
device:send(device.preferences.reverse and
clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) or
Expand All @@ -260,10 +319,10 @@ end
local function handle_open(driver, device, cmd)
local endpoint_id = device:component_to_endpoint(cmd.component)
if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) > 0 then
cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_LIFT_PERCENT)
cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_LIFT_PERCENT, endpoint_id)
end
if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then
cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_TILT_PERCENT)
cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_TILT_PERCENT, endpoint_id)
end
device:send(device.preferences.reverse and
clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) or
Expand All @@ -280,23 +339,24 @@ end
local function handle_shade_level(driver, device, cmd)
local endpoint_id = device:component_to_endpoint(cmd.component)
local hundredths_lift_percentage = (100 - cmd.args.shadeLevel) * 100
cache_data_with_timeout(device, cmd.args.shadeLevel, TARGET_LIFT_PERCENT)
cache_data_with_timeout(device, cmd.args.shadeLevel, TARGET_LIFT_PERCENT, endpoint_id)
device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage(
device, endpoint_id, hundredths_lift_percentage
))
end

local function handle_step_shade_level(driver, device, cmd)
local endpoint_id = device:component_to_endpoint(cmd.component)
local step = cmd.args.stepSize
local latest_lift_percentage = device:get_latest_state(
"main",
capabilities.windowShadeLevel.ID,
capabilities.windowShadeLevel.shadeLevel.NAME,
0
)
local target_lift_percent = device:get_field(TARGET_LIFT_PERCENT.STATE) or latest_lift_percentage
local target_lift_percent = get_field_for_endpoint(device, TARGET_LIFT_PERCENT.STATE, endpoint_id) or latest_lift_percentage
local updated_target_lift = utils.clamp_value(target_lift_percent + step, 0, 100)
cache_data_with_timeout(device, updated_target_lift, TARGET_LIFT_PERCENT)
cache_data_with_timeout(device, updated_target_lift, TARGET_LIFT_PERCENT, endpoint_id)
driver:inject_capability_command(device, {
capability = capabilities.windowShadeLevel.ID,
component = cmd.component,
Expand All @@ -309,7 +369,7 @@ end
local function handle_shade_tilt_level(driver, device, cmd)
local endpoint_id = device:component_to_endpoint(cmd.component)
local hundredths_tilt_percentage = (100 - cmd.args.level) * 100
cache_data_with_timeout(device, cmd.args.level, TARGET_TILT_PERCENT)
cache_data_with_timeout(device, cmd.args.level, TARGET_TILT_PERCENT, endpoint_id)
device:send(clusters.WindowCovering.server.commands.GoToTiltPercentage(
device, endpoint_id, hundredths_tilt_percentage
))
Expand All @@ -320,21 +380,19 @@ local function current_position_lift_percent_100ths_handler(driver, device, ib,
if not ib.data.value then return end
local lift_percent = 100 - math.floor(ib.data.value / 100)
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(lift_percent))

if is_target_value_reached(lift_percent, device:get_field(TARGET_LIFT_PERCENT.STATE)) then
clear_cached_data(device, TARGET_LIFT_PERCENT)
try_emit_idle_window_shade_status(device)
if is_target_value_reached(lift_percent, get_field_for_endpoint(device, TARGET_LIFT_PERCENT.STATE, ib.endpoint_id)) then
clear_cached_data(device, TARGET_LIFT_PERCENT, ib.endpoint_id)
try_emit_idle_window_shade_status(device, ib.endpoint_id)
end
end

local function current_position_tilt_percent_100ths_handler(driver, device, ib, response)
if not ib.data.value then return end
local tilt_percent = 100 - math.floor(ib.data.value / 100)
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeTiltLevel.shadeTiltLevel(tilt_percent))

if is_target_value_reached(tilt_percent, device:get_field(TARGET_TILT_PERCENT.STATE)) then
clear_cached_data(device, TARGET_TILT_PERCENT)
try_emit_idle_window_shade_status(device)
if is_target_value_reached(tilt_percent, get_field_for_endpoint(device, TARGET_TILT_PERCENT.STATE, ib.endpoint_id)) then
clear_cached_data(device, TARGET_TILT_PERCENT, ib.endpoint_id)
try_emit_idle_window_shade_status(device, ib.endpoint_id)
end
end

Expand All @@ -347,10 +405,10 @@ local function target_position_lift_percent_100ths_handler(driver, device, ib, r
capabilities.windowShadeLevel.shadeLevel.NAME
)
if latest_lift_percentage and is_target_value_reached(latest_lift_percentage, target_lift_percent) then
clear_cached_data(device, TARGET_LIFT_PERCENT)
try_emit_idle_window_shade_status(device)
clear_cached_data(device, TARGET_LIFT_PERCENT, ib.endpoint_id)
try_emit_idle_window_shade_status(device, ib.endpoint_id)
else
cache_data_with_timeout(device, target_lift_percent, TARGET_LIFT_PERCENT)
cache_data_with_timeout(device, target_lift_percent, TARGET_LIFT_PERCENT, ib.endpoint_id)
end
end
end
Expand All @@ -364,10 +422,10 @@ local function target_position_tilt_percent_100ths_handler(driver, device, ib, r
capabilities.windowShadeTiltLevel.shadeTiltLevel.NAME
)
if latest_tilt_percent and is_target_value_reached(latest_tilt_percent, target_tilt_percent) then
clear_cached_data(device, TARGET_TILT_PERCENT)
try_emit_idle_window_shade_status(device)
clear_cached_data(device, TARGET_TILT_PERCENT, ib.endpoint_id)
try_emit_idle_window_shade_status(device, ib.endpoint_id)
else
cache_data_with_timeout(device, target_tilt_percent, TARGET_TILT_PERCENT)
cache_data_with_timeout(device, target_tilt_percent, TARGET_TILT_PERCENT, ib.endpoint_id)
end
end
end
Expand All @@ -376,12 +434,11 @@ end
local function operational_status_handler(driver, device, ib, response)
if not ib.data.value then return end
local global_op_status = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL

local reverse = device.preferences.reverse
local windowShade = capabilities.windowShade.windowShade
local status_event
if global_op_status == OP_STATUS_BITMAP.idle then
try_emit_idle_window_shade_status(device)
try_emit_idle_window_shade_status(device, ib.endpoint_id)
elseif global_op_status == OP_STATUS_BITMAP.opening then
status_event = reverse and windowShade.closing() or windowShade.opening()
elseif global_op_status == OP_STATUS_BITMAP.closing then
Expand Down
Loading
Loading