diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index ba03737a73..7101f25261 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -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,16 @@ local TARGET_TILT_PERCENT = { local TARGET_REACH_TOLERANCE = 0.5 +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 + +--- Resolves the device that owns the given endpoint: the child device if one exists, otherwise the device itself. +local function resolve_device_for_endpoint(device, ep_id) + return find_child(device, ep_id) or device +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 @@ -173,11 +189,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", + 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) +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 @@ -187,10 +229,16 @@ local function device_init(driver, device) local preset_position = device.preferences.presetPosition or DEFAULT_PRESET_LEVEL device:emit_event(capabilities.windowShadePreset.position(preset_position, {visibility = {displayed = false}})) end - device:subscribe() + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + device:subscribe() + end 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 +252,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, {}) @@ -239,8 +287,7 @@ local function handle_preset(driver, device, cmd) end local function handle_set_preset(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - device:emit_event_for_endpoint(endpoint_id, capabilities.windowShadePreset.position(cmd.args.position)) + device:emit_event(capabilities.windowShadePreset.position(cmd.args.position)) end local function handle_close(driver, device, cmd) @@ -317,10 +364,10 @@ end -- attribute handlers local function current_position_lift_percent_100ths_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) 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)) - + device:emit_event(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) @@ -328,10 +375,10 @@ local function current_position_lift_percent_100ths_handler(driver, device, ib, end local function current_position_tilt_percent_100ths_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) 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)) - + device:emit_event(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) @@ -339,6 +386,7 @@ local function current_position_tilt_percent_100ths_handler(driver, device, ib, end local function target_position_lift_percent_100ths_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) if type(ib.data.value) == "number" then local target_lift_percent = 100 - math.floor(ib.data.value / 100) local latest_lift_percentage = device:get_latest_state( @@ -356,6 +404,7 @@ local function target_position_lift_percent_100ths_handler(driver, device, ib, r end local function target_position_tilt_percent_100ths_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) if type(ib.data.value) == "number" then local target_tilt_percent = 100 - math.floor(ib.data.value / 100) local latest_tilt_percent = device:get_latest_state( @@ -374,9 +423,9 @@ end -- checks the current position of the shade local function operational_status_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) 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 @@ -390,17 +439,19 @@ local function operational_status_handler(driver, device, ib, response) status_event = windowShade.unknown() end if status_event then - device:emit_event_for_endpoint(ib.endpoint_id, status_event) + device:emit_event(status_event) end end local function battery_percent_remaining_attr_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) if ib.data.value then device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) end end local function battery_charge_level_attr_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then device:emit_event(capabilities.batteryLevel.battery.normal()) elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then @@ -411,6 +462,7 @@ local function battery_charge_level_attr_handler(driver, device, ib, response) end local function power_source_attribute_list_handler(driver, device, ib, response) + device = resolve_device_for_endpoint(device, ib.endpoint_id) for _, attr in ipairs(ib.data.elements or {}) do -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) is present. if attr.value == 0x0C then diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering.lua new file mode 100644 index 0000000000..f5519bdbfb --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering.lua @@ -0,0 +1,1434 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" +local clusters = require "st.matter.clusters" +local version = require "version" +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +local WindowCovering = clusters.WindowCovering + +test.disable_startup_messages() + +local CHILD_DEVICE_MAP = { + {key = 2, ep = 6}, + {key = 3, ep = 7}, + {key = 4, ep = 8}, + {key = 5, ep = 9}, + {key = 6, ep = 10}, + {key = 7, ep = 11} +} + +local mock_parent_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {software = 1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 5, + clusters = { + { + cluster_id = WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + { + endpoint_id = CHILD_DEVICE_MAP[1].ep, + clusters = { + { + cluster_id = WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + { + endpoint_id = CHILD_DEVICE_MAP[2].ep, + clusters = { + { + cluster_id = WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + { + endpoint_id = CHILD_DEVICE_MAP[3].ep, + clusters = { + { + cluster_id = WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + { + endpoint_id = CHILD_DEVICE_MAP[4].ep, + clusters = { + { + cluster_id = WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + { + endpoint_id = CHILD_DEVICE_MAP[5].ep, + clusters = { + { + cluster_id = WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + { + endpoint_id = CHILD_DEVICE_MAP[6].ep, + clusters = { + { + cluster_id = WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + }, + } +) + +local mock_child_device = { + test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", CHILD_DEVICE_MAP[1].key), + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + device_network_id = string.format("%s:%02X", mock_parent_device.id, CHILD_DEVICE_MAP[1].key), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_DEVICE_MAP[1].ep) + }), + test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", CHILD_DEVICE_MAP[2].key), + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + device_network_id = string.format("%s:%02X", mock_parent_device.id, CHILD_DEVICE_MAP[2].key), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_DEVICE_MAP[2].ep) + }), + test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", CHILD_DEVICE_MAP[3].key), + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + device_network_id = string.format("%s:%02X", mock_parent_device.id, CHILD_DEVICE_MAP[3].key), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_DEVICE_MAP[3].ep) + }), + test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", CHILD_DEVICE_MAP[4].key), + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + device_network_id = string.format("%s:%02X", mock_parent_device.id, CHILD_DEVICE_MAP[4].key), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_DEVICE_MAP[4].ep) + }), + test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", CHILD_DEVICE_MAP[5].key), + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + device_network_id = string.format("%s:%02X", mock_parent_device.id, CHILD_DEVICE_MAP[5].key), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_DEVICE_MAP[5].ep) + }), + test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", CHILD_DEVICE_MAP[6].key), + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + device_network_id = string.format("%s:%02X", mock_parent_device.id, CHILD_DEVICE_MAP[6].key), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_DEVICE_MAP[6].ep) + }) +} + +local CLUSTER_SUBSCRIBE_LIST = { + clusters.LevelControl.server.attributes.CurrentLevel, + WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, + WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, + WindowCovering.server.attributes.TargetPositionLiftPercent100ths, + WindowCovering.server.attributes.TargetPositionTiltPercent100ths, + WindowCovering.server.attributes.OperationalStatus, + clusters.PowerSource.server.attributes.BatPercentRemaining +} + +local function set_preset(device) + test.socket.capability:__expect_send( + device:generate_test_message( + "main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + device:generate_test_message( + "main", capabilities.windowShadePreset.position(50, {visibility = {displayed = false}}) + ) + ) +end + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + for i = 1, #mock_child_device do + test.mock_device.add_test_device(mock_child_device[i]) + end + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "added" }) + test.socket.capability:__expect_send( + mock_parent_device:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "init" }) + set_preset(mock_parent_device) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_parent_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_parent_device)) end + end + test.socket.matter:__expect_send({mock_parent_device.id, subscribe_request}) + for _, child in ipairs(CHILD_DEVICE_MAP) do + mock_parent_device:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", child.key), + profile = "window-covering", + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", child.ep) + }) + end + + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "doConfigure" }) + mock_parent_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({mock_parent_device.id, clusters.PowerSource.attributes.AttributeList:read()}) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Idle WindowCovering OperationalStatus state triggers 'open' capability emission if lift position is 100", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Idle WindowCovering OperationalStatus state triggers 'closed' capability emission if lift position is 0", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if lift position is 50", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if tilt position is between 1-100", + function() + for i = 1, #mock_child_device do + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 15) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Unknown WindowCovering OperationalStatus state triggers 'unknown' capability emission", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 99), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.unknown() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Opening WindowCovering OperationalStatus state triggers 'closing' capability emission", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 2), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.closing() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Closing WindowCovering OperationalStatus state triggers 'opening' capability emission", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 1), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.opening() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering CurrentPositionLiftPercent100ths triggers appropriate capability emission", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering CurrentPositionTiltPercent100ths triggers appropriate capability emission", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 15) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 65) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering TargetPositionLiftPercent100ths sets field appropriately", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.TargetPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.wait_for_events() + assert(mock_child_device[i]:get_field("__target_lift_percent") == 50) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering TargetPositionTiltPercent100ths sets field appropriately", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.TargetPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.wait_for_events() + assert(mock_child_device[i]:get_field("__target_tilt_percent") == 50) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering TargetPositionLiftPercent100ths triggers operational status update when appropriate", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 65) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(65) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering TargetPositionTiltPercent100ths triggers operational status update when appropriate", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 65) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Open capability command triggers appropriate Matter command", + function() + for i = 1, #mock_child_device do + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.UpOrOpen(mock_parent_device, CHILD_DEVICE_MAP[i].ep)} + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Close capability command triggers appropriate Matter command", + function() + for i = 1, #mock_child_device do + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "windowShade", component = "main", command = "close", args = {}}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.DownOrClose(mock_parent_device, CHILD_DEVICE_MAP[i].ep)} + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Pause capability command triggers appropriate Matter command", + function() + for i = 1, #mock_child_device do + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "windowShade", component = "main", command = "pause", args = {}}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.StopMotion(mock_parent_device, CHILD_DEVICE_MAP[i].ep)} + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cached target lift/tilt position timeouts trigger one operational status update", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 50 * 100 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + } + ) + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.UpOrOpen(mock_parent_device, CHILD_DEVICE_MAP[i].ep)} + ) + + test.wait_for_events() + test.mock_time.advance_time(120) + + test.socket.capability:__expect_send({ + mock_child_device[i].id, { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } }, + } + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cached target lift timeout triggers one operational status update if tilt target and idle state are first reached appropriately", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + } + ) + -- two timers are made when open is called + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.UpOrOpen(mock_parent_device, CHILD_DEVICE_MAP[i].ep)} + ) + + -- ensure target state is cached before queueing responses + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ), + } + ) + + -- advance time to trigger the single remaining timer + test.wait_for_events() + test.mock_time.advance_time(120) + test.socket.capability:__expect_send({ + mock_child_device[i].id, { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } }, + } + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test("SetShadeLevel capability command triggers appropriate Matter command", +function() + for i = 1, #mock_child_device do + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 20 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 8000)} + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test("SetShadeTiltLevel capability command triggers appropriate Matter command", +function() + for i = 1, #mock_child_device do + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 60 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 4000)} + ) + end + end, + { + min_api_version = 17 + } +) + +--test battery +test.register_coroutine_test( + "Battery percent reports should generate correct messages", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 150 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.battery.battery(math.floor(150/2.0+0.5)) + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preset commands", + function() + for i = 1, #mock_child_device do + local PRESET_LEVEL = 30 + test.socket.capability:__queue_receive({ + mock_child_device[i].id, + {capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = { PRESET_LEVEL }}, + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadePreset.position(PRESET_LEVEL) + ) + ) + test.socket.capability:__queue_receive({ + mock_child_device[i].id, + {capability = "windowShadePreset", component = "main", command = "presetPosition", args = {}}, + }) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, (100 - PRESET_LEVEL) * 100)} + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change to window-covering-battery when battery percent remaining attribute (attribute ID 12) is available", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, {uint32(12)}) + } + ) + mock_child_device[i]:expect_metadata_update({ profile = "window-covering-tilt-battery" }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test that profile is not changed to window-covering-battery when battery percent remaining attribute (attribute ID 12) is not available", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, {uint32(10)}) + } + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to reverse polarity after being set to true and that the shade lift operates as expected when opening and closing", + function() + for i = 1, #mock_child_device do + test.socket.device_lifecycle():__queue_receive(mock_child_device[i]:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 100 * 100 + ) + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ) + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ) + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ) + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_child_device[i].id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 }}, + }) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 1500)} + ) + test.socket.capability:__queue_receive({ + mock_child_device[i].id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 }}, + }) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 0)} + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to reverse polarity after being set to true and that the shade tilt operates as expected when opening and closing", + function() + for i = 1, #mock_child_device do + test.socket.device_lifecycle():__queue_receive(mock_child_device[i]:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 100 * 100 + ) + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ) + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ) + }) + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ) + }) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_child_device[i].id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 15 }}, + }) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 8500)} + ) + test.socket.capability:__queue_receive({ + mock_child_device[i].id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 0 }}, + }) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 10000)} + ) + end + end, + { + min_api_version = 17 + } +) + +-- -- stepShadeLevel tests + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step up", + function() + for i = 1, #mock_child_device do + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 9000)} + ) + end + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step down", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 7000)} + ) + end + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - continuous step with target tracking", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 7000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(30) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 6000)} + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 5000)} + ) + end + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - target reached clears target marker", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 4000)} + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 4000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(60) + ) + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - step up to maximum (100)", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 500 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(95) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 0)} + ) + end + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - step down to minimum (0)", + function() + for i = 1, #mock_child_device do + test.socket.matter:__queue_receive( + { + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 9500 + ), + } + ) + test.socket.capability:__expect_send( + mock_child_device[i]:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(5) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_child_device[i].id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_parent_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_parent_device, CHILD_DEVICE_MAP[i].ep, 10000)} + ) + end + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Cached targets are isolated between the parent and each child device", + function() + local parent_ep = 5 + local child_1, child_2 = mock_child_device[1], mock_child_device[2] + local ep_1, ep_2 = CHILD_DEVICE_MAP[1].ep, CHILD_DEVICE_MAP[2].ep + + -- each device gets a different target, all reported to the parent + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.TargetPositionLiftPercent100ths:build_test_report_data(mock_parent_device, parent_ep, 8000) + }) + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.TargetPositionLiftPercent100ths:build_test_report_data(mock_parent_device, ep_1, 5000) + }) + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.TargetPositionLiftPercent100ths:build_test_report_data(mock_parent_device, ep_2, 3000) + }) + test.wait_for_events() + assert(mock_parent_device:get_field("__target_lift_percent") == 20) + assert(child_1:get_field("__target_lift_percent") == 50) + assert(child_2:get_field("__target_lift_percent") == 70) + + -- child 1 reaches its target: only child 1's cache is cleared and only child 1 emits + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(mock_parent_device, ep_1, 5000) + }) + test.socket.capability:__expect_send( + child_1:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + ) + test.socket.capability:__expect_send( + child_1:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + assert(child_1:get_field("__target_lift_percent") == nil) + assert(child_2:get_field("__target_lift_percent") == 70) + assert(mock_parent_device:get_field("__target_lift_percent") == 20) + + -- child 2's position moves but has not reached its target: its cache is kept, no status is emitted + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(mock_parent_device, ep_2, 5000) + }) + test.socket.capability:__expect_send( + child_2:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + ) + test.wait_for_events() + assert(child_2:get_field("__target_lift_percent") == 70) + assert(mock_parent_device:get_field("__target_lift_percent") == 20) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering_battery.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering_battery.lua new file mode 100644 index 0000000000..b83e27b2be --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering_battery.lua @@ -0,0 +1,200 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" +local clusters = require "st.matter.clusters" +local version = require "version" +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +local matter_child = require "st.matter.child" +local matter_device = require "st.matter.device" +matter_child.MatterChildDevice.endpoint_to_component = matter_device.MatterDevice.endpoint_to_component + +local WindowCovering = clusters.WindowCovering + +test.disable_startup_messages() + +-- A device with a single battery on its own endpoint, shared by two window coverings. +-- The battery belongs to the parent device only; the child must not get a battery. +local ROOT_EP = 0 +local POWER_SOURCE_EP = 1 +local PARENT_WC_EP = 2 +local CHILD_WC_EP = 3 + +local mock_parent_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("window-covering-battery.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {software = 1}, + endpoints = { + { + endpoint_id = ROOT_EP, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = POWER_SOURCE_EP, + clusters = { + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + }, + { + endpoint_id = PARENT_WC_EP, + clusters = { + {cluster_id = WindowCovering.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 1}, + }, + }, + { + endpoint_id = CHILD_WC_EP, + clusters = { + {cluster_id = WindowCovering.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 1}, + }, + }, + }, + } +) + +local mock_child_device = test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + label = "Window Covering 2", + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:%02X", mock_parent_device.id, 2), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_WC_EP) +}) + +local CLUSTER_SUBSCRIBE_LIST = { + WindowCovering.server.attributes.OperationalStatus, + WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, + WindowCovering.server.attributes.TargetPositionLiftPercent100ths, + clusters.PowerSource.server.attributes.BatPercentRemaining +} + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "added" }) + test.socket.capability:__expect_send( + mock_parent_device:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "init" }) + test.socket.capability:__expect_send( + mock_parent_device:generate_test_message( + "main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_parent_device:generate_test_message( + "main", capabilities.windowShadePreset.position(50, {visibility = {displayed = false}}) + ) + ) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_parent_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_parent_device)) end + end + test.socket.matter:__expect_send({mock_parent_device.id, subscribe_request}) + + mock_parent_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Window Covering 2", + profile = "window-covering", + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", CHILD_WC_EP) + }) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "doConfigure" }) + mock_parent_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({mock_parent_device.id, clusters.PowerSource.attributes.AttributeList:read()}) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Battery percent report on the power source endpoint is emitted on the parent only", + function() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_parent_device, POWER_SOURCE_EP, 150 + ) + }) + test.socket.capability:__expect_send( + mock_parent_device:generate_test_message("main", capabilities.battery.battery(75)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Only the parent is re-profiled with battery percentage when BatPercentRemaining is supported", + function() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_parent_device, POWER_SOURCE_EP, {uint32(12)} + ) + }) + mock_parent_device:expect_metadata_update({ profile = "window-covering-battery" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Only the parent is re-profiled with battery level when BatChargeLevel is supported", + function() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_parent_device, POWER_SOURCE_EP, {uint32(14)} + ) + }) + mock_parent_device:expect_metadata_update({ profile = "window-covering-batteryLevel" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Window covering reports still route to the child and the parent with a separate battery endpoint", + function() + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_parent_device, CHILD_WC_EP, 2000 + ) + }) + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(80)) + ) + test.socket.matter:__queue_receive({ + mock_parent_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_parent_device, PARENT_WC_EP, 4000 + ) + }) + test.socket.capability:__expect_send( + mock_parent_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(60)) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests()