Repository navigation
Support multi-endpoint window covering #3191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fa58718
87da271
b971722
418e8f4
c3849ad
05fa702
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
| 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) | ||
|
|
@@ -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", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just confirming - will all the child endpoints always have the 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Could you take a look at how the lifecycle is currently set up and let me know what you think?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, child devices do get the 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) | ||
|
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 | ||
|
|
@@ -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, {}) | ||
|
|
@@ -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, {}) | ||
|
|
@@ -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 | ||
| )) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
| )) | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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: