From fa587187ba61cda4b4da63eed24b5e9b28bf9cde Mon Sep 17 00:00:00 2001 From: Hunsup Jung Date: Fri, 21 Aug 2026 16:05:45 +0900 Subject: [PATCH 1/5] Support multi-endpoint window covering Signed-off-by: Hunsup Jung --- .../matter-window-covering/src/init.lua | 121 +++++++++++++++--- 1 file changed, 104 insertions(+), 17 deletions(-) diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index ba03737a73..98f2f9e0e7 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -1,7 +1,6 @@ -- 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" @@ -10,6 +9,12 @@ local clusters = require "st.matter.clusters" 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", @@ -103,10 +108,14 @@ 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) +local function try_emit_idle_window_shade_status(device, ep) 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)) + if ep then + device:emit_event_for_endpoint(ep, get_idle_window_shade_status(device)) + else + device:emit_event(get_idle_window_shade_status(device)) + end end end @@ -123,7 +132,7 @@ local function cache_data_with_timeout(device, data, op_fields) 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) + try_emit_idle_window_shade_status(device, nil) end) device:set_field(op_fields.TIMEOUT, new_timer) end @@ -154,6 +163,80 @@ local function component_to_endpoint(device, component_name) return find_default_endpoint(device, clusters.WindowCovering.ID) end +local function create_feature_table(lift_eps, tilt_eps) + local result = {} + local i, j = 1, 1 + while i <= #lift_eps or j <= #tilt_eps do + local lift_ep, tilt_ep = lift_eps[i], tilt_eps[j] + if tilt_ep == nil or (lift_ep ~= nil and lift_ep < tilt_ep) then + result[#result + 1] = { endpoint = lift_ep, has_lift = true, has_tilt = false } + i = i + 1 + elseif lift_ep == nil or tilt_ep < lift_ep then + result[#result + 1] = { endpoint = tilt_ep, has_lift = false, has_tilt = true } + j = j + 1 + else -- lift_ep == tilt_ep + result[#result + 1] = { endpoint = lift_ep, has_lift = true, has_tilt = true } + i, j = i + 1, j + 1 + end + end + return result +end + +local function update_profile_table(device) + local lift_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) + local tilt_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) + table.sort(lift_eps) + table.sort(tilt_eps) + local feature_table = create_feature_table(lift_eps, tilt_eps) + local profile_table = {} + + for _, entry in pairs(feature_table) do + profile_table[entry.endpoint] = "window-covering" + if entry.has_tilt then + profile_table[entry.endpoint] = "window-covering-tilt" + if not entry.has_lift then + profile_table[entry.endpoint] = "window-covering-tilt-only" + end + end + end + return profile_table +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 + +local function create_or_update_multi_devices(driver, device, wc_cluster_eps) + local profile_table = update_profile_table(device) + local default_endpoint_id = find_default_endpoint(device) + 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, device_num) + local child_profile = profile_table[ep_id] or "window-covering" + 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 = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%d", ep_id), + vendor_provided_label = label_and_name + }) + else + existing_child_device:try_update_metadata({profile = child_profile}) + 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) + -- Update parent device's profile + device:try_update_metadata({profile = profile_table[default_endpoint_id] or "window-covering"}) +end + local function match_profile(device, battery_supported) local lift_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) local tilt_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) @@ -191,20 +274,25 @@ local function device_init(driver, device) end local function do_configure(driver, device) - 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, {}) - attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read()) - device:send(attribute_list_read) + 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) else - match_profile(device, battery_support.NO_BATTERY) + 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, {}) + attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(attribute_list_read) + else + match_profile(device, battery_support.NO_BATTERY) + end end 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, {}) @@ -323,7 +411,7 @@ local function current_position_lift_percent_100ths_handler(driver, device, ib, 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) + try_emit_idle_window_shade_status(device, ib.endpoint_id) end end @@ -334,7 +422,7 @@ local function current_position_tilt_percent_100ths_handler(driver, device, ib, 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) + try_emit_idle_window_shade_status(device, ib.endpoint_id) end end @@ -348,7 +436,7 @@ local function target_position_lift_percent_100ths_handler(driver, device, ib, r ) 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) + try_emit_idle_window_shade_status(device, ib.endpoint_id) else cache_data_with_timeout(device, target_lift_percent, TARGET_LIFT_PERCENT) end @@ -365,7 +453,7 @@ local function target_position_tilt_percent_100ths_handler(driver, device, ib, r ) 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) + try_emit_idle_window_shade_status(device, ib.endpoint_id) else cache_data_with_timeout(device, target_tilt_percent, TARGET_TILT_PERCENT) end @@ -376,12 +464,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 From 87da2713913172df1d8a8974d20e028ee2d7e594 Mon Sep 17 00:00:00 2001 From: Hunsup Jung Date: Mon, 19 Oct 2026 14:18:55 +0900 Subject: [PATCH 2/5] Remove unnecessary code Signed-off-by: Hunsup Jung --- .../matter-window-covering/src/init.lua | 88 +++++-------------- 1 file changed, 21 insertions(+), 67 deletions(-) diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 98f2f9e0e7..3d5e8dd163 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -163,43 +163,23 @@ local function component_to_endpoint(device, component_name) return find_default_endpoint(device, clusters.WindowCovering.ID) end -local function create_feature_table(lift_eps, tilt_eps) - local result = {} - local i, j = 1, 1 - while i <= #lift_eps or j <= #tilt_eps do - local lift_ep, tilt_ep = lift_eps[i], tilt_eps[j] - if tilt_ep == nil or (lift_ep ~= nil and lift_ep < tilt_ep) then - result[#result + 1] = { endpoint = lift_ep, has_lift = true, has_tilt = false } - i = i + 1 - elseif lift_ep == nil or tilt_ep < lift_ep then - result[#result + 1] = { endpoint = tilt_ep, has_lift = false, has_tilt = true } - j = j + 1 - else -- lift_ep == tilt_ep - result[#result + 1] = { endpoint = lift_ep, has_lift = true, has_tilt = true } - i, j = i + 1, j + 1 - end - end - return result -end - -local function update_profile_table(device) +local function match_profile(device, battery_supported) local lift_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) local tilt_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) - table.sort(lift_eps) - table.sort(tilt_eps) - local feature_table = create_feature_table(lift_eps, tilt_eps) - local profile_table = {} - - for _, entry in pairs(feature_table) do - profile_table[entry.endpoint] = "window-covering" - if entry.has_tilt then - profile_table[entry.endpoint] = "window-covering-tilt" - if not entry.has_lift then - profile_table[entry.endpoint] = "window-covering-tilt-only" - end + local profile_name = "window-covering" + if #tilt_eps > 0 then + profile_name = profile_name .. "-tilt" + if #lift_eps == 0 then + profile_name = profile_name .. "-only" end end - return profile_table + + if battery_supported == battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + elseif battery_supported == battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + end + device:try_update_metadata({profile = profile_name}) end local function find_child(parent_device, ep_id) @@ -208,52 +188,27 @@ local function find_child(parent_device, ep_id) end local function create_or_update_multi_devices(driver, device, wc_cluster_eps) - local profile_table = update_profile_table(device) local default_endpoint_id = find_default_endpoint(device) 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, device_num) - local child_profile = profile_table[ep_id] or "window-covering" 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 = child_profile, + profile = "window-covering", parent_device_id = device.id, parent_assigned_child_key = string.format("%d", ep_id), vendor_provided_label = label_and_name }) - else - existing_child_device:try_update_metadata({profile = child_profile}) 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) - -- Update parent device's profile - device:try_update_metadata({profile = profile_table[default_endpoint_id] or "window-covering"}) -end - -local function match_profile(device, battery_supported) - local lift_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) - local tilt_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) - local profile_name = "window-covering" - if #tilt_eps > 0 then - profile_name = profile_name .. "-tilt" - if #lift_eps == 0 then - profile_name = profile_name .. "-only" - end - end - - if battery_supported == battery_support.BATTERY_PERCENTAGE then - profile_name = profile_name .. "-battery" - elseif battery_supported == battery_support.BATTERY_LEVEL then - profile_name = profile_name .. "-batteryLevel" - end - device:try_update_metadata({profile = profile_name}) end local function device_init(driver, device) @@ -277,15 +232,14 @@ 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, {}) + attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(attribute_list_read) else - 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, {}) - attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read()) - device:send(attribute_list_read) - else - match_profile(device, battery_support.NO_BATTERY) - end + match_profile(device, battery_support.NO_BATTERY) end end From b971722e800aaa56fe3cecfb1591d9cbb2f37697 Mon Sep 17 00:00:00 2001 From: Hunsup Jung Date: Tue, 20 Oct 2026 15:52:37 +0900 Subject: [PATCH 3/5] Add test units Signed-off-by: Hunsup Jung --- .../matter-window-covering/src/init.lua | 6 +- .../test_matter_multi_window_covering.lua | 496 ++++++++++++++++++ 2 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering.lua diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 3d5e8dd163..27857231c8 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -192,7 +192,7 @@ local function create_or_update_multi_devices(driver, device, wc_cluster_eps) 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, device_num) + 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({ @@ -212,10 +212,12 @@ local function create_or_update_multi_devices(driver, device, wc_cluster_eps) 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 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..b4aae11e58 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_multi_window_covering.lua @@ -0,0 +1,496 @@ +-- Copyright 2022 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 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 ENDPOINT_MAP = {5, 6, 7, 8, 9, 10, 11} +local mock_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 = ENDPOINT_MAP[1], + 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 = ENDPOINT_MAP[2], + 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 = ENDPOINT_MAP[3], + 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 = ENDPOINT_MAP[4], + 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 = ENDPOINT_MAP[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 = ENDPOINT_MAP[6], + 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 = ENDPOINT_MAP[7], + 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 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_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + set_preset(mock_device) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + for i, ep in ipairs(ENDPOINT_MAP) do + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("%s %d", "Window Covering", i), + profile = "window-covering", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", ENDPOINT_MAP[i]) + }) + if i == 1 then + test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, ep, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, ep, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, ep, ((100 - 15) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 99), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 2), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 1), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, ep, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, ep, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device: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 _, ep in ipairs(ENDPOINT_MAP) do + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, ep, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, ep, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, ep, ((100 - 15) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, ep, ((100 - 65) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() From 418e8f4080ecfe7147d786d55ea5f46e35c8f63f Mon Sep 17 00:00:00 2001 From: Hunsup Jung Date: Thu, 22 Oct 2026 13:50:46 +0900 Subject: [PATCH 4/5] Fix wrong status issue and modify test units Signed-off-by: Hunsup Jung --- .github/scripts/validate_fingerprints.py | 361 ++++++ .github/workflows/validate-fingerprints.yml | 24 + CLAUDE.md | 1 + Jenkinsfile | 2 +- drivers/SmartThings/hub/fingerprints.yml | 2 +- drivers/SmartThings/matter-lock/src/init.lua | 7 +- .../matter-sensor/fingerprints.yml | 5 + .../matter-sensor/profiles/aqara-fp400.yml | 56 + .../sensor_handlers/attribute_handlers.lua | 4 +- .../sensor_utils/update_metadata_request.lua | 45 + .../matter-sensor/src/sensor_utils/utils.lua | 44 + .../device_configuration.lua | 44 +- .../air_quality_sensor_utils/utils.lua | 44 - .../sub_drivers/air_quality_sensor/init.lua | 3 +- .../src/sub_drivers/aqara_fp400/init.lua | 52 +- .../src/test/test_matter_aqara_fp400.lua | 192 ++- .../matter-switch/fingerprints.yml | 143 ++- .../camera_handlers/attribute_handlers.lua | 52 +- .../camera_handlers/capability_handlers.lua | 48 +- .../camera_utils/device_configuration.lua | 80 +- .../camera/camera_utils/fields.lua | 1 + .../sub_drivers/camera/camera_utils/utils.lua | 22 +- .../src/sub_drivers/eve_energy/can_handle.lua | 7 +- .../src/sub_drivers/hager/init.lua | 80 +- .../switch_handlers/attribute_handlers.lua | 16 +- .../matter-switch/src/switch_utils/fields.lua | 10 + .../matter-switch/src/switch_utils/utils.lua | 11 +- .../src/test/test_aqara_light_switch_h2.lua | 11 +- .../src/test/test_electrical_sensor_set.lua | 99 ++ .../src/test/test_hager_waasys.lua | 554 ++++++++- .../src/test/test_matter_camera.lua | 658 +++++++++- .../test_matter_multi_button_switch_mcd.lua | 22 +- .../matter-thermostat/fingerprints.yml | 15 + .../matter-window-covering/fingerprints.yml | 6 + .../matter-window-covering/src/init.lua | 20 +- .../test_matter_multi_window_covering.lua | 1084 +++++++++++++++-- .../sonos/src/api/sonos_connection.lua | 11 +- .../sonos/src/api/sonos_websocket_router.lua | 4 +- .../zigbee-button/fingerprints.yml | 2 +- .../zigbee-humidity-sensor/fingerprints.yml | 5 + .../zigbee-smoke-detector/fingerprints.yml | 2 +- .../zigbee-switch/fingerprints.yml | 28 +- .../zigbee-thermostat/fingerprints.yml | 10 + .../profiles/thermostat-sonoff-trv-zbt.yml | 38 + .../profiles/thermostat-sonoff.yml | 248 ++++ .../src/sonoff/can_handle.lua | 12 + .../zigbee-thermostat/src/sonoff/init.lua | 321 +++++ .../src/sonoff/tp_wgzba/can_handle.lua | 12 + .../src/sonoff/tp_wgzba/fingerprints.lua | 6 + .../src/sonoff/tp_wgzba/init.lua | 206 ++++ .../zigbee-thermostat/src/sub_drivers.lua | 2 + .../test/test_sonoff_tp_wgzba_thermostat.lua | 224 ++++ .../src/test/test_sonoff_trv_zbt.lua | 429 +++++++ .../SmartThings/zigbee-valve/fingerprints.yml | 22 +- .../zigbee-valve/profiles/valve-battery.yml | 14 + drivers/SmartThings/zigbee-valve/src/init.lua | 1 - .../zigbee-valve/src/sonoff/can_handle.lua | 20 + .../zigbee-valve/src/sonoff/init.lua | 53 + .../zigbee-valve/src/sub_drivers.lua | 1 + .../src/test/test_sonoff_valve.lua | 251 ++++ .../zigbee-water-leak-sensor/fingerprints.yml | 2 +- ..._aeotec_home_energy_meter_gen8_1_phase.lua | 3 +- ..._aeotec_home_energy_meter_gen8_2_phase.lua | 3 +- ..._aeotec_home_energy_meter_gen8_3_phase.lua | 3 +- .../zwave-garage-door-opener/fingerprints.yml | 2 +- .../SmartThings/zwave-switch/fingerprints.yml | 8 +- .../test/test_zwave_dual_switch_migration.lua | 12 +- .../fingerprints.yml | 1 - .../zwave-window-treatment/fingerprints.yml | 2 +- 69 files changed, 5387 insertions(+), 396 deletions(-) create mode 100644 .github/scripts/validate_fingerprints.py create mode 100644 .github/workflows/validate-fingerprints.yml create mode 100644 CLAUDE.md create mode 100644 drivers/SmartThings/matter-sensor/src/sensor_utils/update_metadata_request.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml create mode 100644 drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua create mode 100644 drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml create mode 100644 drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/sonoff/init.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua diff --git a/.github/scripts/validate_fingerprints.py b/.github/scripts/validate_fingerprints.py new file mode 100644 index 0000000000..4b7dbc12b2 --- /dev/null +++ b/.github/scripts/validate_fingerprints.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +Validate fingerprints.yml files changed in a PR. + +Required fields per manufacturer section: + matterManufacturer → id, deviceLabel, vendorId, productId, deviceProfileName + zigbeeManufacturer → id, deviceLabel, manufacturer, model, deviceProfileName + zwaveManufacturer → id, deviceLabel, manufacturerId, deviceProfileName + + at least one of: productId, productType + +Hex fields (vendorId, productId, productType, manufacturerId) must use 0xNNNN notation. +String fields must not be empty or have leading/trailing whitespace. +id values that contain YAML-special characters must be quoted. + +Indentation rules (spaces only, no tabs): + Section key: col 0 e.g. "matterManufacturer:" + Entry opening (- id: ...): 2-space e.g. " - id: ..." + All other entry fields: 4-space e.g. " vendorId: 0x115F" + +No trailing whitespace on any line. +No duplicate id values within a file. + +Generic sections (zigbeeGeneric, zwaveGeneric, matterGeneric, etc.) are skipped. + +Usage: + python3 tools/validate_fingerprints.py # auto-detect via git diff + python3 tools/validate_fingerprints.py path/fingerprints.yml ... +""" + +import os +import re +import sys +import subprocess +from pathlib import Path + +try: + import yaml +except ImportError: + print("Error: pyyaml is required. pip install pyyaml", file=sys.stderr) + sys.exit(2) + +# ── Section configuration ───────────────────────────────────────────────────── + +MANUFACTURER_SECTIONS = {'matterManufacturer', 'zigbeeManufacturer', 'zwaveManufacturer'} + +# All fields that must be present in every entry for each section. +# Z-Wave also needs productId OR productType (checked separately). +REQUIRED_FIELDS = { + 'matterManufacturer': ['id', 'deviceLabel', 'vendorId', 'productId', 'deviceProfileName'], + # manufacturer and model are checked together below: at least one must be present + 'zigbeeManufacturer': ['id', 'deviceLabel', 'deviceProfileName'], + 'zwaveManufacturer': ['id', 'deviceLabel', 'manufacturerId', 'deviceProfileName'], +} + +# These fields must be formatted as hex literals (0xNNNN). +HEX_FIELDS = {'vendorId', 'productId', 'productType', 'manufacturerId'} + +# YAML characters that force quoting when present in an unquoted scalar. +_YAML_SPECIAL_RE = re.compile(r'[:{}\[\],&#*?|<>=!%@`]') + +# Matches a valid hex literal. +_HEX_RE = re.compile(r'^0x[0-9A-Fa-f]+$') + +# Matches a field line: (indent)(key): (value) +_FIELD_RE = re.compile(r'^( *)([\w]+): *(.*?) *$') + + +# ── Raw-text analysis ───────────────────────────────────────────────────────── + +def analyse_lines(lines, filepath): + """ + Single-pass raw-line analysis. Returns a list of errors and a dict: + section_entry_lines[section] = list of (lineno, field, raw_value) + for structured cross-checking later. + """ + errors = [] + section = None # current top-level key + entry_indent = None # indent of the " - id:" line for current entry + in_manufacturer_section = False + + for lineno, raw in enumerate(lines, 1): + line = raw.rstrip('\n') + + # ── No tabs ─────────────────────────────────────────────────────────── + if '\t' in line: + errors.append(f"{filepath}:{lineno}: tab character found (use spaces)") + + # ── Trailing whitespace ─────────────────────────────────────────────── + if line != line.rstrip(): + errors.append(f"{filepath}:{lineno}: trailing whitespace") + + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + + indent = len(line) - len(line.lstrip()) + + # ── Top-level section key detection ─────────────────────────────────── + if indent == 0 and line.endswith(':') and not line.startswith(' '): + section = line[:-1].strip() + in_manufacturer_section = section in MANUFACTURER_SECTIONS + entry_indent = None + continue + + if not in_manufacturer_section: + continue + + # ── Entry opening line: " - id: ..." ─────────────────────────────── + if stripped.startswith('- '): + if indent != 2: + errors.append( + f"{filepath}:{lineno}: [{section}] entry list item must be indented " + f"2 spaces, found {indent}" + ) + entry_indent = indent + + # Check that this is the id field + rest = stripped[2:] # strip "- " + m = _FIELD_RE.match(' ' + rest) # re-prefix spaces for consistent match + if m: + field = m.group(2) + raw_val = m.group(3) + if field == 'id': + _check_id_quoting(raw_val, lineno, section, filepath, errors) + continue + + # ── Subsequent fields of an entry ───────────────────────────────────── + if entry_indent is not None: + expected_indent = entry_indent + 2 # 2 + 2 = 4 + if indent != expected_indent: + errors.append( + f"{filepath}:{lineno}: [{section}] field must be indented " + f"{expected_indent} spaces, found {indent}" + ) + + m = _FIELD_RE.match(line) + if not m: + continue + field = m.group(2) + raw_val = m.group(3).split('#')[0].strip() # strip inline comment + + # ── Hex field format ────────────────────────────────────────────── + if field in HEX_FIELDS and raw_val: + if not _HEX_RE.match(raw_val): + errors.append( + f"{filepath}:{lineno}: [{section}] field '{field}' " + f"value {raw_val!r} must be hex notation (e.g. 0x115F)" + ) + + # ── String field whitespace ─────────────────────────────────────── + display_val = _strip_quotes(raw_val) + if field not in HEX_FIELDS and raw_val: + if display_val != display_val.strip(): + errors.append( + f"{filepath}:{lineno}: [{section}] field '{field}' " + f"value has leading/trailing whitespace: {raw_val!r}" + ) + + return errors + + +def _strip_yaml_inline_comment(raw_val): + """ + Strip an inline YAML comment from a raw scalar value. + + Handles: + "quoted value" # comment → "quoted value" + 'quoted value' # comment → 'quoted value' + bare value # comment → bare value + """ + if raw_val and raw_val[0] in ('"', "'"): + q = raw_val[0] + i = 1 + while i < len(raw_val): + ch = raw_val[i] + if q == '"' and ch == '\\': + i += 2 # skip escaped character + continue + if q == "'" and ch == "'" and i + 1 < len(raw_val) and raw_val[i + 1] == "'": + i += 2 # escaped single-quote inside single-quoted string + continue + if ch == q: + return raw_val[:i + 1] # return up to and including closing quote + i += 1 + return raw_val # unclosed quote — return as-is + # Unquoted: strip from ' #' (space + hash = inline comment marker) + idx = raw_val.find(' #') + if idx != -1: + return raw_val[:idx].rstrip() + return raw_val + + +def _check_id_quoting(raw_val, lineno, section, filepath, errors): + """Require quoting when the id value contains YAML-special characters.""" + raw_val = _strip_yaml_inline_comment(raw_val) + is_quoted = ( + len(raw_val) >= 2 + and raw_val[0] in ('"', "'") + and raw_val[-1] == raw_val[0] + ) + inner = raw_val[1:-1] if is_quoted else raw_val + if not is_quoted and _YAML_SPECIAL_RE.search(inner): + errors.append( + f"{filepath}:{lineno}: [{section}] id value {raw_val!r} contains " + f"special characters and must be quoted" + ) + + +def _strip_quotes(val): + if len(val) >= 2 and val[0] in ('"', "'") and val[-1] == val[0]: + return val[1:-1] + return val + + +# ── YAML structural checks ──────────────────────────────────────────────────── + +def check_structure(data, filepath): + errors = [] + + for section, entries in data.items(): + if section not in MANUFACTURER_SECTIONS: + continue + + if not isinstance(entries, list): + errors.append(f"{filepath}: [{section}] expected a list of entries, got {type(entries).__name__}") + continue + + seen_ids = {} + for entry in entries: + if not isinstance(entry, dict): + errors.append(f"{filepath}: [{section}] entry is not a mapping: {entry!r}") + continue + + entry_id = entry.get('id', '') + + # ── Duplicate id ────────────────────────────────────────────────── + if entry_id in seen_ids: + errors.append(f"{filepath}: [{section}] duplicate id {entry_id!r}") + seen_ids[entry_id] = True + + # ── Required fields ─────────────────────────────────────────────── + # manufacturer and model may legitimately be "" (device reports no value). + ALLOW_EMPTY = {'manufacturer', 'model'} + for field in REQUIRED_FIELDS[section]: + if field not in entry: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + f"missing required field '{field}'" + ) + elif entry[field] is None: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + f"field '{field}' has a null value" + ) + elif field not in ALLOW_EMPTY and isinstance(entry[field], str) and entry[field].strip() == '': + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + f"field '{field}' is empty" + ) + + # ── Zigbee: manufacturer or model ───────────────────────────────── + if section == 'zigbeeManufacturer': + mfr = entry.get('manufacturer') + mdl = entry.get('model') + mfr_blank = mfr is None or (isinstance(mfr, str) and mfr.strip() == '') + mdl_blank = mdl is None or (isinstance(mdl, str) and mdl.strip() == '') + if mfr_blank and mdl_blank: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + "at least one of 'manufacturer' or 'model' must be provided" + ) + + # ── Z-Wave: productId or productType ────────────────────────────── + if section == 'zwaveManufacturer': + if 'productId' not in entry and 'productType' not in entry: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + "missing 'productId' or 'productType' (at least one required)" + ) + + return errors + + +# ── File validator ──────────────────────────────────────────────────────────── + +def validate_file(filepath: Path) -> list: + try: + raw = filepath.read_text(encoding='utf-8') + except OSError as exc: + return [f"{filepath}: cannot read file — {exc}"] + + lines = raw.splitlines(keepends=True) + + # Raw-text pass (indentation, whitespace, hex format, id quoting) + errors = analyse_lines(lines, str(filepath)) + + # YAML structural pass (required fields, duplicates, null/empty values) + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as exc: + return errors + [f"{filepath}: YAML parse error — {exc}"] + + if not isinstance(data, dict): + return errors + [f"{filepath}: unexpected top-level YAML structure"] + + errors.extend(check_structure(data, str(filepath))) + return errors + + +# ── Git helper ──────────────────────────────────────────────────────────────── + +def get_changed_files() -> list: + base = os.environ.get('GITHUB_BASE_REF', 'main') + for ref in (f'origin/{base}', base, 'HEAD~1'): + try: + result = subprocess.run( + ['git', 'diff', '--name-only', '--diff-filter=AM', f'{ref}...HEAD'], + capture_output=True, text=True, check=True + ) + return [ + Path(f) for f in result.stdout.splitlines() + if f.endswith('fingerprints.yml') and Path(f).exists() + ] + except subprocess.CalledProcessError: + continue + return [] + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + if len(sys.argv) > 1: + files = [Path(f) for f in sys.argv[1:]] + else: + files = get_changed_files() + if not files: + print("No changed fingerprints.yml files detected.") + sys.exit(0) + + all_errors = [] + for f in files: + if not f.exists(): + print(f"Warning: {f} does not exist, skipping", file=sys.stderr) + continue + print(f"Checking {f} ...") + errs = validate_file(f) + all_errors.extend(errs) + + if all_errors: + print() + for err in all_errors: + print(err) + print(f"\n✗ {len(all_errors)} error(s) found.") + sys.exit(1) + else: + print(f"\n✓ {len(files)} file(s) passed validation.") + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/validate-fingerprints.yml b/.github/workflows/validate-fingerprints.yml new file mode 100644 index 0000000000..6fc32268bd --- /dev/null +++ b/.github/workflows/validate-fingerprints.yml @@ -0,0 +1,24 @@ +name: Validate fingerprints + +on: + pull_request: + types: [opened, synchronize] + paths: + - 'drivers/**/fingerprints.yml' + +jobs: + validate-fingerprints: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install pyyaml + run: pip install pyyaml --quiet + + - name: Validate changed fingerprints.yml files + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: python .github/scripts/validate_fingerprints.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..eef4bd20cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 752d3d358c..cedacdf64d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -39,7 +39,7 @@ def getDockerCredentialId() { def nodeLabel = params.NODE_LABEL ?: 'production' def region = get_region() if (nodeLabel == 'production' && region == 'cn') { - return 'artifactory-credentials' + return 'artifactory-cn' } else { return '' diff --git a/drivers/SmartThings/hub/fingerprints.yml b/drivers/SmartThings/hub/fingerprints.yml index 317df13f8b..887f1a4455 100644 --- a/drivers/SmartThings/hub/fingerprints.yml +++ b/drivers/SmartThings/hub/fingerprints.yml @@ -17,7 +17,7 @@ hub: deviceProfileName: washer-dryer-hub - id: "cooktop-hub" deviceLabel: SmartThings Hub - hardwareType: SAMSUNG_COOKTOP_TIZEN_OPEN + hardwareType: SAMSUNG_COOKTOP_TIZEN_OPEN deviceProfileName: cooktop-hub - id: "v4-hub" deviceLabel: SmartThings Hub diff --git a/drivers/SmartThings/matter-lock/src/init.lua b/drivers/SmartThings/matter-lock/src/init.lua index f94702c496..72bfaae425 100755 --- a/drivers/SmartThings/matter-lock/src/init.lua +++ b/drivers/SmartThings/matter-lock/src/init.lua @@ -1313,7 +1313,10 @@ local function get_user_response_handler(driver, device, ib, response) end local ep = find_default_endpoint(device, DoorLock.ID) - local status = elements.user_status.value + local status = nil + if elements.user_status ~= nil then + status = elements.user_status.value + end local maxUser = device:get_latest_state( "main", capabilities.lockUsers.ID, @@ -1624,11 +1627,11 @@ local function set_pin_response_handler(driver, device, ib, response) -- If user is added also, update User table if userIdx == nil then + userIdx = elements.user_index.value add_user_to_table(device, elements.user_index.value, nil, userType) end -- Update Credential table - userIdx = elements.user_index.value if cmdName == "addCredential" then add_credential_to_table(device, userIdx, credIdx, "pin") end diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index 45b21dd386..dc42ad4b07 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -89,6 +89,11 @@ matterManufacturer: vendorId: 0x120B productId: 0x1060 deviceProfileName: temperature-humidity-battery + - id: "4619/4129" + deviceLabel: Photoelectric Smoke Alarm + vendorId: 0x120B + productId: 0x1021 + deviceProfileName: smoke-battery # Ikea - id: "4476/32773" deviceLabel: TIMMERFLOTTE Temperature/Humidity Sensor diff --git a/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml index c2d5b7b037..d4c9fde930 100644 --- a/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml +++ b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml @@ -12,3 +12,59 @@ components: version: 1 categories: - name: PresenceSensor +- id: sensor1 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor2 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor3 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor4 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor5 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor6 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor7 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor8 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor diff --git a/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua index 1867d65e0b..514bb622cf 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua @@ -185,9 +185,9 @@ end function AttributeHandlers.occupancy_measured_value_handler(driver, device, ib, response) if device:supports_capability(capabilities.motionSensor) then - device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) + device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) else - device:emit_event(ib.data.value == 0x01 and capabilities.presenceSensor.presence("present") or capabilities.presenceSensor.presence("not present")) + device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.presenceSensor.presence("present") or capabilities.presenceSensor.presence("not present")) end end diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/update_metadata_request.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/update_metadata_request.lua new file mode 100644 index 0000000000..abeb04e548 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/update_metadata_request.lua @@ -0,0 +1,45 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local UpdateMetadataRequest = {} +UpdateMetadataRequest.__index = UpdateMetadataRequest + +function UpdateMetadataRequest.init() + local update_metadata_request = setmetatable({}, UpdateMetadataRequest) + update_metadata_request.profile = nil + update_metadata_request.enabled_components = {} + return update_metadata_request +end + +function UpdateMetadataRequest:add_capabilities_to_component(component_id, capability_ids) + if not self.enabled_components[component_id] then + self.enabled_components[component_id] = {} + end + for _, capability_id in ipairs(capability_ids) do + table.insert(self.enabled_components[component_id], capability_id) + end + return self +end + +function UpdateMetadataRequest:add_profile(profile) + self.profile = profile + return self +end + +function UpdateMetadataRequest:formatted_enabled_components() + local formatted_enabled_components = {} + for component_id, capability_ids in pairs(self.enabled_components) do + table.insert(formatted_enabled_components, { component_id, capability_ids }) + end + return formatted_enabled_components +end + +function UpdateMetadataRequest:format_request() + local formatted_request = { + profile = self.profile, + optional_component_capabilities = self:formatted_enabled_components() + } + return formatted_request +end + +return UpdateMetadataRequest diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua index 513c9f4602..75aa284aab 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua @@ -36,4 +36,48 @@ function utils.tbl_contains(array, value) return false end +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function utils.deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not utils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return utils.deep_equals(mt_a, mt_b, opts, seen) +end + return utils diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua index a30eaed0f8..9a8215feff 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua @@ -30,53 +30,37 @@ function DeviceConfiguration.supported_level_measurements(device) return measurement_caps, level_caps end --- Match Modular Profile function DeviceConfiguration.match_profile(device) + local update_metadata_request = require "sensor_utils.update_metadata_request" + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID) local humidity_eps = embedded_cluster_utils.get_endpoints(device, clusters.RelativeHumidityMeasurement.ID) - local optional_supported_component_capabilities = {} - local main_component_capabilities = {} - local profile_name - local MAIN_COMPONENT_IDX = 1 - local CAPABILITIES_LIST_IDX = 2 + local updated_metadata = update_metadata_request.init() + local preference_tags = "" if #temp_eps > 0 then - table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + updated_metadata:add_capabilities_to_component("main", { capabilities.temperatureMeasurement.ID }) + preference_tags = preference_tags .. "-temp" end if #humidity_eps > 0 then - table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + updated_metadata:add_capabilities_to_component("main", { capabilities.relativeHumidityMeasurement.ID }) + preference_tags = preference_tags .. "-humidity" end local measurement_caps, level_caps = DeviceConfiguration.supported_level_measurements(device) + updated_metadata:add_capabilities_to_component("main", measurement_caps) + updated_metadata:add_capabilities_to_component("main", level_caps) - for _, cap_id in ipairs(measurement_caps) do - table.insert(main_component_capabilities, cap_id) - end - - for _, cap_id in ipairs(level_caps) do - table.insert(main_component_capabilities, cap_id) - end - - table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) - - if #temp_eps > 0 and #humidity_eps > 0 then - profile_name = "aqs-modular-temp-humidity" - elseif #temp_eps > 0 then - profile_name = "aqs-modular-temp" - elseif #humidity_eps > 0 then - profile_name = "aqs-modular-humidity" - else - profile_name = "aqs-modular" - end - - device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + device:try_update_metadata(updated_metadata:add_profile("aqs-modular" .. preference_tags):format_request()) -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. if version.api < 15 or version.rpc < 9 then + local MAIN_COMPONENT_IDX = 1 + local CAPABILITIES_LIST_IDX = 2 -- add mandatory capabilities for subscription - local total_supported_capabilities = optional_supported_component_capabilities + local total_supported_capabilities = updated_metadata:formatted_enabled_components() table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.airQualityHealthConcern.ID) table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua index 2e8d9e487b..a24149cce3 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua @@ -90,48 +90,4 @@ function AirQualitySensorUtils.convert_value_to_unit(value, from_unit, to_unit, return conversion_function(value, fields.molecular_weights[capability_name]) end ---- Deeply compare two values. ---- Handles metatables. Can optionally ignore cycle checking and/or function differences. ---- ---- @param a any ---- @param b any ---- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } ---- @param seen table|nil ---- @return boolean -function AirQualitySensorUtils.deep_equals(a, b, opts, seen) - if a == b then return true end -- same object - if type(a) ~= type(b) then return false end -- different type - if type(a) == "function" and opts and opts.ignore_functions then return true end - if type(a) ~= "table" then return false end -- same type but not table, thus was already compared - - -- check for cycles in table references and preserve reference topology. - if not (opts and opts.ignore_cycles) then - seen = seen or {} - seen[a] = seen[a] or {} - if seen[a][b] then - return seen[a][b] - end - seen[a][b] = true - end - - -- Compare keys/values from a - for k, v in pairs(a) do - if not AirQualitySensorUtils.deep_equals(v, b[k], opts, seen) then - return false - end - end - - -- Ensure b doesn't have extra keys - for k in pairs(b) do - if a[k] == nil then - return false - end - end - - -- Compare metatables - local mt_a = getmetatable(a) - local mt_b = getmetatable(b) - return AirQualitySensorUtils.deep_equals(mt_a, mt_b, opts, seen) -end - return AirQualitySensorUtils diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua index 383af643a6..1ce1cc2176 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua @@ -4,6 +4,7 @@ local version = require "version" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" +local sensor_utils = require "sensor_utils.utils" local aqs_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils" local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" local attribute_handlers = require "sub_drivers.air_quality_sensor.air_quality_sensor_handlers.attribute_handlers" @@ -67,7 +68,7 @@ function AirQualitySensorLifecycleHandlers.device_init(driver, device) end function AirQualitySensorLifecycleHandlers.info_changed(driver, device, event, args) - if not aqs_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then + if not sensor_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then --re-up subscription with new capabilities using the modular supports_capability override device:extend_device("supports_capability_by_id", aqs_utils.supports_capability_by_id_modular) diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua index 5f47c86047..d9e9ad6378 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua @@ -1,21 +1,67 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local capabilities = require "st.capabilities" +local sensor_utils = require "sensor_utils.utils" + +local COMPONENT_TO_ENDPOINT_MAP = "__COMPONENT_TO_ENDPOINT_MAP" + +local function endpoint_to_component(device, endpoint_id) + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} + for component_id, ep_id in pairs(map) do + if ep_id == endpoint_id then return component_id end + end + return "main" +end + +local function configure_profile(device) + local update_metadata_request = require "sensor_utils.update_metadata_request" + + local updated_component_to_endpoint_map = {} + local updated_metadata = update_metadata_request.init() + for _, ep in ipairs(device.endpoints) do + -- >2 since EP0 is the root node, EP1 is a permanent Presence Sensor, and EP2 is a permanent Light Sensor + if ep.endpoint_id > 2 then + local component_id = string.format("sensor%d", ep.endpoint_id - 2) -- ex. sensor1, sensor2, etc. for EP3, EP4, etc. + updated_component_to_endpoint_map[component_id] = ep.endpoint_id + updated_metadata:add_capabilities_to_component(component_id, { capabilities.presenceSensor.ID }) + end + end + device:set_field(COMPONENT_TO_ENDPOINT_MAP, updated_component_to_endpoint_map, { persist = true }) + device:try_update_metadata(updated_metadata:add_profile("aqara-fp400"):format_request()) +end + + local Fp400LifecycleHandlers = {} --- overwrite to avoid unnecessary metadata update calls -function Fp400LifecycleHandlers.do_configure() end +function Fp400LifecycleHandlers.do_configure(driver, device) + configure_profile(device) +end --- overwrite to avoid unnecessary metadata update calls function Fp400LifecycleHandlers.driver_switched(driver, device) + configure_profile(device) device:try_update_metadata({provisioning_state = "PROVISIONED"}) end +function Fp400LifecycleHandlers.info_changed(driver, device, event, args) + if not sensor_utils.deep_equals(args.old_st_store.endpoints, device.endpoints) then + configure_profile(device) + device:subscribe() + end +end + +function Fp400LifecycleHandlers.device_init(driver, device) + device:set_endpoint_to_component_fn(endpoint_to_component) + device:subscribe() +end + local aqara_fp400_handler = { NAME = "aqara-fp400", lifecycle_handlers = { doConfigure = Fp400LifecycleHandlers.do_configure, driverSwitched = Fp400LifecycleHandlers.driver_switched, + infoChanged = Fp400LifecycleHandlers.info_changed, + init = Fp400LifecycleHandlers.device_init, }, can_handle = require("sub_drivers.aqara_fp400.can_handle"), } diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua index ba4f2fb296..6ce8abf91d 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua @@ -2,9 +2,11 @@ -- Licensed under the Apache License, Version 2.0 local test = require "integration_test" +local st_utils = require "st.utils" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local t_utils = require "integration_test.utils" +local sensor_utils = require "sensor_utils.utils" local matter_endpoints = { { @@ -20,16 +22,49 @@ local matter_endpoints = { endpoint_id = 1, clusters = { {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + }, + { + endpoint_id = 2, + clusters = { {cluster_id = clusters.IlluminanceMeasurement.ID, cluster_type = "SERVER"}, }, + device_types = { + {device_type_id = 0x0106, device_type_revision = 1} -- Light Sensor + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, device_types = { {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor } - } + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + }, +} + +local enabled_optional_component_capability_pairs = { + { "sensor1", { capabilities.presenceSensor.ID } }, + { "sensor3", { capabilities.presenceSensor.ID } } } local mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("aqara-fp400.yml"), + profile = t_utils.get_profile_definition("aqara-fp400.yml", + { enabled_optional_capabilities = enabled_optional_component_capability_pairs } + ), manufacturer_info = { vendor_id = 0x115F, product_id = 0x2009, @@ -37,31 +72,34 @@ local mock_device = test.mock_device.build_test_matter_device({ endpoints = matter_endpoints }) -local function subscribe_on_init(dev) +local function do_subscribe(dev) local subscribe_request = clusters.OccupancySensing.attributes.Occupancy:subscribe(dev) subscribe_request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(dev)) return subscribe_request end local function test_init() - test.socket.matter:__set_channel_ordering("relaxed") - local subscribe_request = subscribe_on_init(mock_device) + local subscribe_request = do_subscribe(mock_device) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.mock_device.add_test_device(mock_device) end test.set_test_init_function(test_init) test.register_coroutine_test( - "Test no profile change on doConfigure for FP400", + "Test profile change on doConfigure for FP400", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - -- The FP400 sub-driver overrides doConfigure to be a no-op -- When doConfigure completes successfully, the framework automatically provisions the device + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = enabled_optional_component_capability_pairs }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) test.wait_for_events() + assert(sensor_utils.deep_equals(st_utils.deep_copy(mock_device:get_field("__COMPONENT_TO_ENDPOINT_MAP")), { + sensor1 = 3, + sensor3 = 5 + }), "Component to endpoint map should be set correctly on doConfigure") end, { - min_api_version = 14 + min_api_version = 21 } ) @@ -70,20 +108,21 @@ test.register_coroutine_test( function() local current_profile = mock_device.profile.id test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) - -- The FP400 sub-driver overrides driverSwitched to only update provisioning state + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = enabled_optional_component_capability_pairs }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -- Ensure profile has not changed test.wait_for_events() assert(mock_device.profile.id == current_profile, "Profile should not change on driverSwitched") end, { - min_api_version = 14 + min_api_version = 21 } ) test.register_message_test( "Occupancy reports should generate correct presence messages", { + -- from EP1 { channel = "matter", direction = "receive", @@ -109,10 +148,73 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) - } + }, + -- from EP3 + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor1", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor1", capabilities.presenceSensor.presence("not present")) + }, + -- from EP5 + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 5, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor3", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 5, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor3", capabilities.presenceSensor.presence("not present")) + }, }, { - min_api_version = 14 + test_init = function () + local subscribe_request = do_subscribe(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + -- we can assume this will be set elsewhere-- see other tests. + mock_device:set_field("__COMPONENT_TO_ENDPOINT_MAP", + { sensor1 = 3, sensor3 = 5 }, { persist = true } + ) + end, + min_api_version = 14 } ) @@ -124,7 +226,7 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 21370) + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device, 2, 21370) } }, { @@ -138,4 +240,68 @@ test.register_message_test( } ) +test.register_coroutine_test( + "Test profile change on endpoints increment in infoChanged for FP400", + function() + local current_profile_id = mock_device.profile.id + local incremented_matter_endpoints = matter_endpoints + table.insert(incremented_matter_endpoints, { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + }) + local incremented_enabled_optional_component_capability_pairs = { + { "sensor1", { capabilities.presenceSensor.ID } }, + { "sensor3", { capabilities.presenceSensor.ID } }, + { "sensor2", { capabilities.presenceSensor.ID } }, + } + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ endpoints = incremented_matter_endpoints })) + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = incremented_enabled_optional_component_capability_pairs }) + local subscribe_request = do_subscribe(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + -- Ensure profile has not changed + test.wait_for_events() + assert(mock_device.profile.id == current_profile_id, "Profile should not change on infoChanged") + assert(sensor_utils.deep_equals(st_utils.deep_copy(mock_device:get_field("__COMPONENT_TO_ENDPOINT_MAP")), { + sensor1 = 3, + sensor3 = 5, + sensor2 = 4 + }), "Component to endpoint map should be set correctly on doConfigure") + end, + { + min_api_version = 21 + } +) + +test.register_coroutine_test( + "Test profile change on endpoints decrement in infoChanged for FP400", + function() + local current_profile_id = mock_device.profile.id + local decremented_matter_endpoints = matter_endpoints + table.remove(decremented_matter_endpoints, 4) -- remove EP3 + local decremented_enabled_optional_component_capability_pairs = { + { "sensor3", { capabilities.presenceSensor.ID } }, + { "sensor2", { capabilities.presenceSensor.ID } }, + } + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ endpoints = decremented_matter_endpoints })) + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = decremented_enabled_optional_component_capability_pairs }) + local subscribe_request = do_subscribe(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + -- Ensure profile has not changed + test.wait_for_events() + assert(mock_device.profile.id == current_profile_id, "Profile should not change on infoChanged") + assert(sensor_utils.deep_equals(st_utils.deep_copy(mock_device:get_field("__COMPONENT_TO_ENDPOINT_MAP")), { + sensor3 = 5, + sensor2 = 4 + }), "Component to endpoint map should be set correctly on doConfigure") + end, + { + min_api_version = 21 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 29c85f6c79..935024d61c 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -231,6 +231,51 @@ matterManufacturer: vendorId: 0x1396 productId: 0x103E deviceProfileName: fan-modular + - id: "5014/4576" + deviceLabel: Linkind Light Stick T19 + vendorId: 0x1396 + productId: 0x11E0 + deviceProfileName: light-color-level + - id: "5014/4574" + deviceLabel: Linkind Light Stick T19 + vendorId: 0x1396 + productId: 0x11DE + deviceProfileName: light-color-level + - id: "5014/4224" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1080 + deviceProfileName: light-color-level + - id: "5014/4223" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x107F + deviceProfileName: light-color-level + - id: "5014/4227" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1083 + deviceProfileName: light-color-level + - id: "5014/4218" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x107A + deviceProfileName: light-color-level + - id: "5014/4220" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x107C + deviceProfileName: light-color-level + - id: "5014/4221" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x107D + deviceProfileName: light-color-level + - id: "5014/4713" + deviceLabel: Linkind Smart Outdoor Plug + vendorId: 0x1396 + productId: 0x1269 + deviceProfileName: plug-binary #Bosch Smart Home - id: "4617/12310" deviceLabel: Plug Compact [M] @@ -809,7 +854,7 @@ matterManufacturer: productId: 0x6870 deviceProfileName: light-color-level - id: "4999/26272" - deviceLabel: Govee TV Backlight 3 Pro 55-65 + deviceLabel: Govee TV Backlight 3 PRO / 3S PRO vendorId: 0x1387 productId: 0x66A0 deviceProfileName: light-color-level @@ -898,6 +943,61 @@ matterManufacturer: vendorId: 0x1387 productId: 0x60B3 deviceProfileName: light-color-level + - id: "4999/10240" + deviceLabel: Govee Monitor Light Bar + vendorId: 0x1387 + productId: 0x2800 + deviceProfileName: light-color-level + - id: "4999/6833" + deviceLabel: Govee COB Strip Light 2 9.8ft/3m + vendorId: 0x1387 + productId: 0x1AB1 + deviceProfileName: light-color-level + - id: "4999/6834" + deviceLabel: Govee COB Strip Light 2 16.4ft/5m + vendorId: 0x1387 + productId: 0x1AB2 + deviceProfileName: light-color-level + - id: "4999/6835" + deviceLabel: Govee COB Strip Light 2 32.8ft/10m + vendorId: 0x1387 + productId: 0x1AB3 + deviceProfileName: light-color-level + - id: "4999/6818" + deviceLabel: Govee COB Strip Light 2 Pro 6.5ft/2m + vendorId: 0x1387 + productId: 0x1AA2 + deviceProfileName: light-color-level + - id: "4999/6821" + deviceLabel: Govee COB Strip Light 2 Pro 16.4ft/5m + vendorId: 0x1387 + productId: 0x1AA5 + deviceProfileName: light-color-level + - id: "4999/6723" + deviceLabel: Govee Strip Light 2 + vendorId: 0x1387 + productId: 0x1A43 + deviceProfileName: light-color-level + - id: "4999/6001" + deviceLabel: Govee Wallwash Table Lamp + vendorId: 0x1387 + productId: 0x1771 + deviceProfileName: light-color-level + - id: "4999/6161" + deviceLabel: Govee Bubble Projector Light + vendorId: 0x1387 + productId: 0x1811 + deviceProfileName: light-color-level + - id: "4999/10816" + deviceLabel: Govee TV Backlight 3 + vendorId: 0x1387 + productId: 0x2A40 + deviceProfileName: light-color-level + - id: "4999/25168" + deviceLabel: Govee Christmas 2-in-1 String Lights + vendorId: 0x1387 + productId: 0x6250 + deviceProfileName: light-color-level # Hager - id: "4741/8" deviceLabel: Hager matter 2 buttons (battery) @@ -929,6 +1029,11 @@ matterManufacturer: vendorId: 0x1285 productId: 0x000A deviceProfileName: matter-bridge + - id: "4741/12" + deviceLabel: Hager Rotary Dimmer + vendorId: 0x1285 + productId: 0x000C + deviceProfileName: light-level # HAOJAI - id: "5530/4113" deviceLabel: HAOJAI Smart Switch 3-key @@ -1639,6 +1744,11 @@ matterManufacturer: vendorId: 0x143D productId: 0x1001 deviceProfileName: plug-binary + - id: "5181/4098" + deviceLabel: Onvis Outlet T20 + vendorId: 0x143D + productId: 0x1002 + deviceProfileName: plug-power-energy-powerConsumption #Osram - id: "4489/2564" deviceLabel: OSRAM MATTER PLUG UK @@ -2325,11 +2435,6 @@ matterManufacturer: vendorId: 0x100b productId: 0x21B3 deviceProfileName: light-color-level-2200K-6500K - - id: "4107/8627" - deviceLabel: WiZ Downlight - vendorId: 0x100b - productId: 0x21B3 - deviceProfileName: light-color-level-2200K-6500K - id: "4107/8796" deviceLabel: WiZ Downlight vendorId: 0x100b @@ -4188,6 +4293,16 @@ matterManufacturer: vendorId: 0x1397 productId: 0x07E7 deviceProfileName: matter-bridge + - id: "5015/2040" + deviceLabel: SwitchBot Color Lighting + vendorId: 0x1397 + productId: 0x07F8 + deviceProfileName: light-color-level + - id: "5015/2039" + deviceLabel: SwitchBot Floor Lamp + vendorId: 0x1397 + productId: 0x07F7 + deviceProfileName: light-color-level - id: "Nature/Bridge" deviceLabel: Matter Bridge vendorId: 0x138A @@ -4220,6 +4335,22 @@ matterManufacturer: vendorId: 0x164B productId: 0x0001 deviceProfileName: light-color-level +#Utilitech + - id: "5732/2" + deviceLabel: Lowes - Utilitech A19 Smart Bulb + vendorId: 0x1664 + productId: 0x0002 + deviceProfileName: light-color-level + - id: "5732/1536" + deviceLabel: Utilitech 17 Smart Flush Mount - Full Color + vendorId: 0x1664 + productId: 0x0600 + deviceProfileName: light-color-level + - id: "5732/768" + deviceLabel: Utilitech 1x4 Smart Panel - Full Color + vendorId: 0x1664 + productId: 0x0300 + deviceProfileName: light-color-level matterGeneric: diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua index 53b35712e7..0b08bf27b0 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua @@ -18,8 +18,6 @@ CameraAttributeHandlers.enabled_state_factory = function(attribute) camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageFlipHorizontal") elseif attribute == capabilities.imageControl.imageFlipVertical then camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageFlipVertical") - elseif attribute == capabilities.cameraPrivacyMode.hardPrivacyMode then - camera_utils.update_supported_attributes(device, ib, capabilities.cameraPrivacyMode, "hardPrivacyMode") end end end @@ -59,10 +57,14 @@ end function CameraAttributeHandlers.volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MIN - -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) or camera_fields.ABS_VOL_MIN local limited_range = max_volume - min_volume + if limited_range <= 0 then + device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(0)) + return + end + -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability local normalized_volume = utils.round((ib.data.value - min_volume) * 100.0 / limited_range) device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(normalized_volume)) end @@ -70,23 +72,33 @@ end function CameraAttributeHandlers.max_volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) local max_volume = ib.data.value - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) - if max_volume > camera_fields.ABS_VOL_MAX or (min_volume and max_volume <= min_volume) then + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) + -- min == max is a valid (non-adjustable) range per the Matter spec, so only max < min is malformed; + -- an out-of-bounds value is clamped, but a malformed relationship is only logged and left as-is, + -- since volume_level_handler already guards against a non-positive range. + if max_volume > camera_fields.ABS_VOL_MAX then device.log.warn(string.format("Device reported invalid maximum (%d) %s volume level range value", ib.data.value, component)) max_volume = camera_fields.ABS_VOL_MAX + elseif min_volume and max_volume < min_volume then + device.log.warn(string.format("Device reported invalid maximum (%d) %s volume level range value", ib.data.value, component)) end - device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component, max_volume) + camera_utils.set_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component, max_volume) + camera_cfg.reconcile_profile_and_capabilities(device) end function CameraAttributeHandlers.min_volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) local min_volume = ib.data.value - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) - if min_volume < camera_fields.ABS_VOL_MIN or (max_volume and min_volume >= max_volume) then + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) + -- See max_volume_level_handler: min == max is valid, only min > max is malformed and only logged. + if min_volume < camera_fields.ABS_VOL_MIN then device.log.warn(string.format("Device reported invalid minimum (%d) %s volume level range value", ib.data.value, component)) min_volume = camera_fields.ABS_VOL_MIN + elseif max_volume and min_volume > max_volume then + device.log.warn(string.format("Device reported invalid minimum (%d) %s volume level range value", ib.data.value, component)) end - device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component, min_volume) + camera_utils.set_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component, min_volume) + camera_cfg.reconcile_profile_and_capabilities(device) end function CameraAttributeHandlers.status_light_enabled_handler(driver, device, ib, response) @@ -292,6 +304,7 @@ function CameraAttributeHandlers.dptz_streams_handler(driver, device, ib, respon end function CameraAttributeHandlers.ptz_position_handler(driver, device, ib, response) + if not ib.data.elements then return end local ptz_map = camera_utils.get_ptz_map(device) local emit_event = function(idx, value) if value ~= ptz_map[idx].current then @@ -300,13 +313,13 @@ function CameraAttributeHandlers.ptz_position_handler(driver, device, ib, respon )) end end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + if ib.data.elements.pan and camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) then emit_event(camera_fields.PAN_IDX, ib.data.elements.pan.value) end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + if ib.data.elements.tilt and camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) then emit_event(camera_fields.TILT_IDX, ib.data.elements.tilt.value) end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + if ib.data.elements.zoom and camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then emit_event(camera_fields.ZOOM_IDX, ib.data.elements.zoom.value) end end @@ -317,13 +330,13 @@ function CameraAttributeHandlers.ptz_presets_handler(driver, device, ib, respons for _, v in ipairs(ib.data.elements) do local preset = v.elements local pan, tilt, zoom = 0, 0, 1 - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) then pan = preset.settings.elements.pan.value end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) then tilt = preset.settings.elements.tilt.value end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then zoom = preset.settings.elements.zoom.value end table.insert(presets, { id = preset.preset_id.value, label = preset.name.value, pan = pan, tilt = tilt, zoom = zoom }) @@ -449,7 +462,7 @@ end function CameraAttributeHandlers.camera_av_stream_management_attribute_list_handler(driver, device, ib, response) if not ib.data.elements then return end - local status_light_enabled_present, status_light_brightness_present = false, false + local status_light_enabled_present, status_light_brightness_present, hard_privacy_mode_present = false, false, false local attribute_ids = {} for _, attr in ipairs(ib.data.elements) do if attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID then @@ -458,6 +471,8 @@ function CameraAttributeHandlers.camera_av_stream_management_attribute_list_hand elseif attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID then status_light_brightness_present = true table.insert(attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + elseif attr.value == clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID then + hard_privacy_mode_present = true end end local component_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} @@ -468,6 +483,7 @@ function CameraAttributeHandlers.camera_av_stream_management_attribute_list_hand } device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist=true}) camera_cfg.update_status_light_attribute_presence(device, status_light_enabled_present, status_light_brightness_present) + camera_cfg.update_hard_privacy_mode_attribute_presence(device, hard_privacy_mode_present) camera_cfg.reconcile_profile_and_capabilities(device) end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua index a26afd0ca7..ebf0411f99 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua @@ -59,8 +59,8 @@ end function CameraCapabilityHandlers.handle_set_volume(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute local volume_range = max_volume - min_volume local volume = utils.round(cmd.args.volume * volume_range / 100.0 + min_volume) @@ -75,8 +75,8 @@ end function CameraCapabilityHandlers.handle_volume_up(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) if not volume or volume >= max_volume then return end -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute @@ -91,8 +91,8 @@ end function CameraCapabilityHandlers.handle_volume_down(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) if not volume or volume <= min_volume then return end -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute @@ -139,9 +139,11 @@ end CameraCapabilityHandlers.ptz_relative_move_factory = function(index) return function (driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local pan_delta = index == camera_fields.PAN_IDX and cmd.args.delta or 0 - local tilt_delta = index == camera_fields.TILT_IDX and cmd.args.delta or 0 - local zoom_delta = index == camera_fields.ZOOM_IDX and cmd.args.delta or 0 + -- Only the axis being moved should be included; the other two must be omitted (nil) rather + -- than sent as 0, since each delta field's conformance is tied to its own feature bit. + local pan_delta = index == camera_fields.PAN_IDX and cmd.args.delta or nil + local tilt_delta = index == camera_fields.TILT_IDX and cmd.args.delta or nil + local zoom_delta = index == camera_fields.ZOOM_IDX and cmd.args.delta or nil device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove( device, endpoint_id, pan_delta, tilt_delta, zoom_delta )) @@ -151,23 +153,29 @@ end CameraCapabilityHandlers.ptz_set_position_factory = function(command) return function (driver, device, cmd) local ptz_map = camera_utils.get_ptz_map(device) + -- MPTZSetPosition coordinates are optional; + -- a single-axis command should only set (and clamp) the axis it targets. + local pan, tilt, zoom if command == capabilities.mechanicalPanTiltZoom.commands.setPanTiltZoom then - ptz_map[camera_fields.PAN_IDX].current = cmd.args.pan - ptz_map[camera_fields.TILT_IDX].current = cmd.args.tilt - ptz_map[camera_fields.ZOOM_IDX].current = cmd.args.zoom + if cmd.args.pan ~= nil then + pan = utils.clamp_value(cmd.args.pan, ptz_map[camera_fields.PAN_IDX].range.minimum, ptz_map[camera_fields.PAN_IDX].range.maximum) + end + if cmd.args.tilt ~= nil then + tilt = utils.clamp_value(cmd.args.tilt, ptz_map[camera_fields.TILT_IDX].range.minimum, ptz_map[camera_fields.TILT_IDX].range.maximum) + end + if cmd.args.zoom ~= nil then + zoom = utils.clamp_value(cmd.args.zoom, ptz_map[camera_fields.ZOOM_IDX].range.minimum, ptz_map[camera_fields.ZOOM_IDX].range.maximum) + end elseif command == capabilities.mechanicalPanTiltZoom.commands.setPan then - ptz_map[camera_fields.PAN_IDX].current = cmd.args.pan + pan = utils.clamp_value(cmd.args.pan, ptz_map[camera_fields.PAN_IDX].range.minimum, ptz_map[camera_fields.PAN_IDX].range.maximum) elseif command == capabilities.mechanicalPanTiltZoom.commands.setTilt then - ptz_map[camera_fields.TILT_IDX].current = cmd.args.tilt + tilt = utils.clamp_value(cmd.args.tilt, ptz_map[camera_fields.TILT_IDX].range.minimum, ptz_map[camera_fields.TILT_IDX].range.maximum) else - ptz_map[camera_fields.ZOOM_IDX].current = cmd.args.zoom - end - for _, v in pairs(ptz_map) do - v.current = utils.clamp_value(v.current, v.range.minimum, v.range.maximum) + zoom = utils.clamp_value(cmd.args.zoom, ptz_map[camera_fields.ZOOM_IDX].range.minimum, ptz_map[camera_fields.ZOOM_IDX].range.maximum) end local endpoint_id = device:component_to_endpoint(cmd.component) - device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(device, endpoint_id, - ptz_map[camera_fields.PAN_IDX].current, ptz_map[camera_fields.TILT_IDX].current, ptz_map[camera_fields.ZOOM_IDX].current + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition( + device, endpoint_id, pan, tilt, zoom )) end end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua index 0619194926..8a5db7c5b5 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua @@ -33,6 +33,27 @@ local function set_status_light_presence(device, status_light_enabled_present, s device:set_field(camera_fields.STATUS_LIGHT_BRIGHTNESS_PRESENT, status_light_brightness_present == true, { persist = true }) end +local function get_hard_privacy_mode_presence(device) + return device:get_field(camera_fields.HARD_PRIVACY_MODE_PRESENT) +end + +local function set_hard_privacy_mode_presence(device, hard_privacy_mode_present) + device:set_field(camera_fields.HARD_PRIVACY_MODE_PRESENT, hard_privacy_mode_present == true, { persist = true }) +end + +--- Leaves audioVolume as-is if the min/max levels aren't both known yet instead of guessing; +--- otherwise requires max > min to include it. +--- @param component string a profile component name +--- @return should_include boolean whether audioVolume belongs on the component argument. +local function should_include_volume_capability(device, component) + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) + if min_volume == nil or max_volume == nil then + return device:supports_capability(capabilities.audioVolume, component) + end + return max_volume > min_volume +end + local function build_webrtc_supported_features() return { bundle = true, @@ -46,19 +67,19 @@ end local function build_ptz_supported_attributes(device) local supported_attributes = {} - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) then table.insert(supported_attributes, "pan") table.insert(supported_attributes, "panRange") end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) then table.insert(supported_attributes, "tilt") table.insert(supported_attributes, "tiltRange") end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then table.insert(supported_attributes, "zoom") table.insert(supported_attributes, "zoomRange") end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPRESETS) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS) then table.insert(supported_attributes, "presets") table.insert(supported_attributes, "maxPresets") end @@ -100,12 +121,32 @@ local function build_video_stream_settings_supported_features(device) return supported_features end -local function build_camera_privacy_supported_attributes() - return { "softRecordingPrivacyMode", "softLivestreamPrivacyMode" } +local function camera_privacy_feature_supported(device) + return camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.PRIVACY) end -local function build_camera_privacy_supported_commands() - return { "setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode" } +-- SoftRecordingPrivacyModeEnabled/SoftLivestreamPrivacyModeEnabled are conditionally mandatory on the PRIV +-- feature, while HardPrivacyModeOn has independent optional conformance. +-- A device can support either, both, or neither, so each is built independently. +local function build_camera_privacy_supported_attributes(device) + local supported_attributes = {} + if camera_privacy_feature_supported(device) then + table.insert(supported_attributes, "softRecordingPrivacyMode") + table.insert(supported_attributes, "softLivestreamPrivacyMode") + end + if get_hard_privacy_mode_presence(device) then + table.insert(supported_attributes, "hardPrivacyMode") + end + return supported_attributes +end + +local function build_camera_privacy_supported_commands(device) + local supported_commands = {} + if camera_privacy_feature_supported(device) then + table.insert(supported_commands, "setSoftRecordingPrivacyMode") + table.insert(supported_commands, "setSoftLivestreamPrivacyMode") + end + return supported_commands end local function capabilities_needing_reinit(device) @@ -155,8 +196,8 @@ local function capabilities_needing_reinit(device) capabilities_to_reinit.video_stream_settings = true end - if should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedAttributes, build_camera_privacy_supported_attributes()) or - should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedCommands, build_camera_privacy_supported_commands()) then + if should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedAttributes, build_camera_privacy_supported_attributes(device)) or + should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedCommands, build_camera_privacy_supported_commands(device)) then capabilities_to_reinit.camera_privacy_mode = true end @@ -194,6 +235,7 @@ end function CameraDeviceConfiguration.match_profile(device) local status_light_enabled_present, status_light_brightness_present = get_status_light_presence(device) + local hard_privacy_mode_present = get_hard_privacy_mode_presence(device) local profile_update_requested = false local optional_supported_component_capabilities = {} local main_component_capabilities = {} @@ -229,17 +271,21 @@ function CameraDeviceConfiguration.match_profile(device) table.insert(main_component_capabilities, capabilities.audioRecording.ID) end table.insert(microphone_component_capabilities, capabilities.audioMute.ID) - table.insert(microphone_component_capabilities, capabilities.audioVolume.ID) + if should_include_volume_capability(device, camera_fields.profile_components.microphone) then + table.insert(microphone_component_capabilities, capabilities.audioVolume.ID) + end end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then table.insert(main_component_capabilities, capabilities.imageCapture.ID) end - if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.PRIVACY) then + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.PRIVACY) or hard_privacy_mode_present then table.insert(main_component_capabilities, capabilities.cameraPrivacyMode.ID) end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SPEAKER) then table.insert(speaker_component_capabilities, capabilities.audioMute.ID) - table.insert(speaker_component_capabilities, capabilities.audioVolume.ID) + if should_include_volume_capability(device, camera_fields.profile_components.speaker) then + table.insert(speaker_component_capabilities, capabilities.audioVolume.ID) + end end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL) then table.insert(main_component_capabilities, capabilities.imageControl.ID) @@ -361,8 +407,8 @@ end local function init_camera_privacy_mode(device) if device:supports_capability(capabilities.cameraPrivacyMode) then local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedAttributes(build_camera_privacy_supported_attributes())) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(build_camera_privacy_supported_commands())) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedAttributes(build_camera_privacy_supported_attributes(device))) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(build_camera_privacy_supported_commands(device))) end end @@ -442,6 +488,10 @@ function CameraDeviceConfiguration.update_status_light_attribute_presence(device set_status_light_presence(device, status_light_enabled_present, status_light_brightness_present) end +function CameraDeviceConfiguration.update_hard_privacy_mode_attribute_presence(device, hard_privacy_mode_present) + set_hard_privacy_mode_presence(device, hard_privacy_mode_present) +end + function CameraDeviceConfiguration.reinitialize_changed_camera_capabilities_and_subscriptions(device, old_profile, new_profile) local changed_capabilities = changed_capabilities_from_profiles(old_profile, new_profile) initialize_selected_camera_capabilities(device, changed_capabilities) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua index c88f177707..edb4deb125 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua @@ -16,6 +16,7 @@ CameraFields.TRIGGERED_ZONES = "__triggered_zones" CameraFields.DPTZ_VIEWPORTS = "__dptz_viewports" CameraFields.STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present" CameraFields.STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present" +CameraFields.HARD_PRIVACY_MODE_PRESENT = "__hard_privacy_mode_present" CameraFields.CameraAVSMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvStreamManagement.ID } CameraFields.CameraAVSULMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvSettingsUserLevelManagement.ID } diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua index 78792b94b3..5cc7dee498 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua @@ -82,6 +82,14 @@ function CameraUtils.feature_supported(device, cluster_id, feature_flag) return #device:get_endpoints(cluster_id, { feature_bitmap = feature_flag }) > 0 end +function CameraUtils.get_field_for_component(device, field, component) + return device:get_field(string.format("%s_%s", field, component)) +end + +function CameraUtils.set_field_for_component(device, field, component, value, additional_params) + device:set_field(string.format("%s_%s", field, component), value, additional_params) +end + function CameraUtils.update_supported_attributes(device, ib, capability, attribute) local attribute_set = device:get_latest_state( camera_fields.profile_components.main, capability.ID, capability.supportedAttributes.NAME @@ -212,18 +220,22 @@ function CameraUtils.subscribe(device) clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin }, + -- SpeakerMaxLevel/MinLevel and MicrophoneMaxLevel/MinLevel are subscribed alongside audioMute + -- (unconditional on the AUDIO/SPEAKER feature) rather than audioVolume, since audioVolume's own + -- presence in the profile depends on first discovering these values -- gating their subscription + -- on audioVolume already being present would make that discovery impossible. [capabilities.audioMute.ID] = { clusters.CameraAvStreamManagement.attributes.SpeakerMuted, - clusters.CameraAvStreamManagement.attributes.MicrophoneMuted - }, - [capabilities.audioVolume.ID] = { - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted, clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel }, + [capabilities.audioVolume.ID] = { + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel + }, [capabilities.mode.ID] = { clusters.CameraAvStreamManagement.attributes.StatusLightBrightness }, diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua index 02bc9e06d2..2bc395ae43 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua @@ -10,9 +10,14 @@ return function(opts, driver, device) -- this sub driver loads for devices that: -- 1. Contain the Eve Private Cluster (0x130AFC01) -- 2. Do NOT have the Standard Electrical Sensor device type + -- 3. Match one of the known Eve Energy vendor/product ID combinations + -- We should still check that the device does not have the Electrical Sensor + -- device type because this sub driver is only needed for cases where devices + -- may be on old FW and do not support the standard Electical Sensor related clusters. if device.network_type == device_lib.NETWORK_TYPE_MATTER and #device:get_endpoints(EVE_PRIVATE_CLUSTER_ID) > 0 and - #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR) == 0 then + #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR) == 0 and + switch_utils.get_product_override_field(device, "needs_eve_energy_subdriver") then return true, require("sub_drivers.eve_energy") end return false diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua index db8cfe67d4..959b6fda10 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -23,6 +23,7 @@ local PRODUCT_ID = { SWITCH_2G = 0x0006, PIR_1_1M = 0x0007, PIR_2_2M = 0x000A, + ROTARY_DIMMER = 0x000C } if version.api < 16 then @@ -33,6 +34,21 @@ local function subscribe (device, endpoint_id, cluster_id, attr_id, event_id) device:send(cluster_base.subscribe(device, endpoint_id, cluster_id, attr_id, event_id)) end +-- The Rotary Dimmer (0x000C) is a non-bridge type Hager Matter device, which +-- additionally supports manual mode transitions that enable/disable +-- functionality via a physical knob. Unlike bridge-type Hager devices, it has +-- no Aggregator device type on its root endpoint. This non-bridge device +-- requires explicit handling to: (1) process endpoint EP1 (normally filtered +-- out for bridge devices), (2) manage its own device type changes without a +-- parent-child hierarchy, endpoint mapping and event routing, (3) support +-- dynamic profile switching triggered by user interaction, as with the knob +-- mode switching from Dimmer to only On/Off. +local function is_non_bridge_type(device) + if switch_utils.get_product_override_field(device, "is_non_bridge_type") then + return true + end +end + local function get_parent (driver, device) return driver:get_device_info(device:get_field(PARENT_ID) or nil) end @@ -51,12 +67,14 @@ local function get_matter_device(device) return nil end -local function extract_reported_endpoints(ib) +local function extract_reported_endpoints(ib, device) local eps = {} if ib.data and ib.data.elements then for _, el in ipairs(ib.data.elements) do local ep_id = el.value - if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 then + -- For bridge architecture HBnet devices: filter out endpoints 1-2 (reserved for hub infrastructure) + -- For simple non-bridge type HBnet devices: accept all endpoints including 1-2 (active application endpoints) + if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 or is_non_bridge_type(device) then table.insert(eps, ep_id) end end @@ -188,11 +206,16 @@ local function info_changed(driver, device, event, args) return end + if is_non_bridge_type(device) then + device:subscribe() + return + end + if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then - local parent = device:get_parent_device() - local matter_device = get_matter_device(parent) - local map = {} - device.thread:call_with_delay(2, function() + device.thread:call_with_delay(3, function() + local parent = device:get_parent_device() + local matter_device = get_matter_device(parent) + local map = {} if device:supports_capability(capabilities.button) then local button_eps = parent:get_field(BUTTON_EPS) local clean_eps = {} @@ -225,7 +248,7 @@ local function info_changed(driver, device, event, args) subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID) subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) end - if device.id == matter_device.id then + if not is_non_bridge_type(device) and device.id == matter_device.id then parent:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) matter_device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) end @@ -234,6 +257,13 @@ local function info_changed(driver, device, event, args) end local function device_init (driver, device) + if is_non_bridge_type(device) then + device:set_find_child(switch_utils.find_child) + device:set_field(PARENT_ID, device.id) + device:set_field(MATTER_DEVICE_ID, device.id) + subscribe(device, nil, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID, nil) + return + end if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then device.thread:call_with_delay(4, function() info_changed(driver, device, nil, { @@ -265,7 +295,6 @@ local function device_init (driver, device) end) end) end - device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) end @@ -274,21 +303,26 @@ local function handle_descriptor_report(driver, device, ib, response) local product_id = device.manufacturer_info and device.manufacturer_info.product_id local parent = get_parent(driver, device) - if not parent or ib.endpoint_id ~= 2 then - return + if is_non_bridge_type(device) then + if ib.endpoint_id ~= 0 then + return + end + else + if ib.endpoint_id ~= 2 or not parent then + return + end end - local matter_device = get_matter_device(device) + local matter_device = get_matter_device(device) or device + local new_eps = extract_reported_endpoints(ib, device) or {} - local new_eps = extract_reported_endpoints(ib) or {} table.sort(new_eps) - local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) + local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) device:set_field(ACTIVE_EPS, new_eps, { persist = true }) for _, ep_id in ipairs(added_eps or {}) do subscribe(parent, ep_id, clusters.Descriptor.ID, clusters.Descriptor.attributes.DeviceTypeList.ID, nil) - if product_id == PRODUCT_ID.SWITCH_1G and ep_id == 3 then matter_device:try_update_metadata({ profile = "light-binary" }) elseif product_id == PRODUCT_ID.SWITCH_2G then @@ -347,6 +381,7 @@ local function device_type_handler (driver, device, ib) end local parent = get_parent(driver, device) + if not parent then return end @@ -356,10 +391,19 @@ local function device_type_handler (driver, device, ib) local new_btn_eps = {} local ep_id = ib.endpoint_id local value = ib.data.elements + local reports_dimmable = false + + for _, element in ipairs(value or {}) do + local device_type_field = element.elements.device_type + if device_type_field and device_type_field.value == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then + reports_dimmable = true + end + end for _, v in ipairs(stored_btn_eps) do table.insert(new_btn_eps, v) end + for _, element in ipairs(value) do local device_type_field = element.elements.device_type local device_type_id = device_type_field and device_type_field.value @@ -396,11 +440,17 @@ local function device_type_handler (driver, device, ib) driver:try_delete_device(ep_id) end return + elseif ep_id == 1 and device.manufacturer_info.product_id == PRODUCT_ID.ROTARY_DIMMER then + if not reports_dimmable then + device:try_update_metadata({ profile = "light-binary" }) + end end create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) elseif device_type_id == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then if ep_id == 4 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then return + elseif ep_id == 1 and device.manufacturer_info.product_id == PRODUCT_ID.ROTARY_DIMMER then + device:try_update_metadata({ profile = "light-level" }) end create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) elseif device_type_id == fields.DEVICE_TYPE_ID.WINDOW_COVERING then @@ -433,7 +483,7 @@ local function do_configure (driver, device) device:try_update_metadata({ profile = "4-button" }) elseif #bt_eps == 2 then device:try_update_metadata({ profile = "2-button" }) - elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G then + elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G or is_non_bridge_type(device) then device:try_update_metadata({ profile = "light-level" }) end device:set_field(BUTTON_EPS, bt_eps, { persist = true }) diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index ae1f219606..46338233a0 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -325,12 +325,12 @@ end --- SET feature, all AvailableEndpoints responses must be handled before profiling. function AttributeHandlers.available_endpoints_handler(driver, device, ib, response) if device:get_field(fields.profiling_data.POWER_TOPOLOGY) ~= nil then - device.log.warn("Received an AvailableEndpoints response after power topology has already been determined. Ignoring this response.") + device.log.warn_with({hub_logs=true},"Received an AvailableEndpoints response after power topology has already been determined. Ignoring this response.") return end local set_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) if set_topology_eps == nil then - device.log.warn("Received an AvailableEndpoints response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with SET feature. Ignoring this response.") + device.log.warn_with({hub_logs=true},"Received an AvailableEndpoints response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with SET feature. Ignoring this response.") return end @@ -349,7 +349,8 @@ function AttributeHandlers.available_endpoints_handler(driver, device, ib, respo break end end - if #set_topology_eps == 0 then -- in other words, all AvailableEndpoints attribute responses have been handled + if switch_utils.is_field_empty(device, fields.ELECTRICAL_SENSOR_EPS) then + device.log.info_with({hub_logs=true}, "All AvailableEndpoints attribute responses for SET Electrical Sensor endpoints have been handled, attempting to match profile") device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY, {persist=true}) device_cfg.match_profile(driver, device) end @@ -360,16 +361,16 @@ end function AttributeHandlers.parts_list_handler(driver, device, ib, response) if device:get_field(fields.profiling_data.POWER_TOPOLOGY) ~= nil then - device.log.warn("Received a PartsList response after power topology has already been determined. Ignoring this response.") + device.log.warn_with({hub_logs=true}, "Received a PartsList response after power topology has already been determined. Ignoring this response.") return end local tree_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) if tree_topology_eps == nil then - device.log.warn("Received a PartsList response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with TREE feature. Ignoring this response.") + device.log.warn_with({hub_logs=true}, "Received a PartsList response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with TREE feature. Ignoring this response.") return end - device.log.debug_with({hub_logs=true}, string.format("Handling PartsList response for endpoint %d with elements: %s", ib.endpoint_id, st_utils.stringify_table(ib.data.elements or {}))) + device.log.info_with({hub_logs=true}, string.format("Handling PartsList response for endpoint %d with elements: %s", ib.endpoint_id, st_utils.stringify_table(ib.data.elements or {}))) for i, tree_ep_info in pairs(tree_topology_eps or {}) do if ib.endpoint_id == tree_ep_info.endpoint_id then -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table @@ -384,7 +385,8 @@ function AttributeHandlers.parts_list_handler(driver, device, ib, response) break end end - if #tree_topology_eps == 0 then -- in other words, all PartsList attribute responses for TREE Electrical Sensor EPs have been handled + if switch_utils.is_field_empty(device, fields.ELECTRICAL_SENSOR_EPS) then + device.log.info_with({hub_logs=true}, "All PartsList attribute responses for TREE Electrical Sensor endpoints have been handled, attempting to match profile") device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, {persist=true}) device_cfg.match_profile(driver, device) end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index 5873cb40b2..ef52e7d65b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -125,6 +125,16 @@ SwitchFields.vendor_overrides = { [0x0006] = { needs_hager_subdriver = true }, -- Hager HBnet 2g switch [0x0007] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 1.1M [0x000A] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 2.2M + [0x000C] = { needs_hager_subdriver = true, is_non_bridge_type = true }, -- Hager Rotary Dimmer + }, + [0x130A] = { -- EVE_MANUFACTURER_ID + [0x0050] = { needs_eve_energy_subdriver = true }, -- Eve Energy EU + [0x0053] = { needs_eve_energy_subdriver = true }, -- Eve Energy US + [0x0054] = { needs_eve_energy_subdriver = true }, -- Eve Energy UK + [0x005E] = { needs_eve_energy_subdriver = true }, -- Eve Energy AU + [0x0069] = { needs_eve_energy_subdriver = true }, -- Eve Energy Outlet US + [0x006A] = { needs_eve_energy_subdriver = true }, -- Eve Energy CH + [0x006B] = { needs_eve_energy_subdriver = true }, -- Eve Energy Outlet EU }, [0x1321] = { -- SONOFF_MANUFACTURER_ID [0x000C] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 1d12f41781..cb95ff770b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -53,11 +53,20 @@ end function utils.remove_field_index(device, field_name, index) local new_table = device:get_field(field_name) if type(new_table) == "table" then - new_table[index] = nil -- remove value associated with index from table + new_table[index] = nil -- remove value associated with index from table without altering table structure device:set_field(field_name, new_table) end end +function utils.is_field_empty(device, field_name) + local field = device:get_field(field_name) + if type(field) == "table" then + return next(field) == nil + else + return field == nil + end +end + function utils.mired_to_kelvin(value, minOrMax) if value == 0 then -- shouldn't happen, but has value = 1 diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua index 08402cf273..a78a2375c0 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua @@ -4,8 +4,6 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" -local utils = require "st.utils" -local dkjson = require "dkjson" local clusters = require "st.matter.clusters" local version = require "version" @@ -205,19 +203,16 @@ local function test_init() parent_assigned_child_key = string.format("%d", aqara_child2_ep) }) - local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) - device_info_copy.profile.id = "4-button" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) - configure_buttons() - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive(aqara_mock_device:generate_info_changed({profile = t_utils.get_profile_definition("4-button.yml")})) end + test.set_test_init_function(test_init) test.register_coroutine_test( "Button/Switch device : button/switch capability should send the appropriate commands", function() + test.wait_for_events() test.socket.matter:__queue_receive( { aqara_mock_device.id, diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua index 32989187fa..18074c9b4e 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua @@ -112,6 +112,59 @@ local mock_device_periodic = test.mock_device.build_test_matter_device({ }, }) +--- Models an outlet of a multi-outlet power strip, which combines the Electrical Sensor and +--- OnOff Plug In Unit device types on a single endpoint. Note that the Electrical Sensor device +--- type is listed first, so it is the endpoint's primary device type and no profile is mapped to it. +local function build_power_strip_outlet_endpoint(endpoint_id) + return { + endpoint_id = endpoint_id, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 1, }, + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", feature_map = 2, }, + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 15, }, + { cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", feature_map = 4, }, -- SET_TOPOLOGY + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 }, -- Electrical Sensor + { device_type_id = 0x010A, device_type_revision = 1 }, -- OnOff Plug In Unit + } + } +end + +--- A 4-outlet power strip that reports its endpoints out of numerical order, as the Tapo +--- P304M does. The order in which the AvailableEndpoints reports are handled must not affect +--- profiling: every Electrical Sensor endpoint has to be accounted for before profiles are matched. +local mock_device_power_strip = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x1392, + product_id = 0x010F, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + build_power_strip_outlet_endpoint(1), + build_power_strip_outlet_endpoint(3), + build_power_strip_outlet_endpoint(4), + build_power_strip_outlet_endpoint(2), + }, +}) + +local subscribed_attributes_power_strip = { + clusters.OnOff.attributes.OnOff, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.PowerTopology.attributes.AvailableEndpoints, +} + local subscribed_attributes_periodic = { clusters.OnOff.attributes.OnOff, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, @@ -201,6 +254,17 @@ local function test_init_periodic() test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) end +local function test_init_power_strip() + test.mock_device.add_test_device(mock_device_power_strip) + local subscribe_request = subscribed_attributes_power_strip[1]:subscribe(mock_device_power_strip) + for i, cluster in ipairs(subscribed_attributes_power_strip) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_power_strip)) + end + end + test.socket.matter:__expect_send({ mock_device_power_strip.id, subscribe_request }) +end + test.register_message_test( "Active power measurement should generate correct messages", { @@ -711,4 +775,39 @@ test.register_message_test( } ) +test.register_coroutine_test( + "Profiling of a power strip must wait for the AvailableEndpoints report of every Electrical Sensor endpoint", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_power_strip.id, "doConfigure" }) + mock_device_power_strip:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + -- the reports arrive in endpoint order, which does not match the order that the endpoints + -- were reported in during the interview. Each outlet is the only endpoint in its own power set. + for _, endpoint_id in ipairs({1, 2, 3, 4}) do + test.socket.matter:__queue_receive({ + mock_device_power_strip.id, + clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data( + mock_device_power_strip, endpoint_id, {uint32(endpoint_id)} + ) + }) + end + -- every outlet supports both power and energy measurement, so none of them should fall back + -- to the generic "switch-binary" profile used for an OnOff endpoint without electrical tags + for _, endpoint_id in ipairs({2, 3, 4}) do + mock_device_power_strip:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("nil %d", endpoint_id), + profile = "plug-power-energy-powerConsumption", + parent_device_id = mock_device_power_strip.id, + parent_assigned_child_key = string.format("%d", endpoint_id) + }) + end + mock_device_power_strip:expect_metadata_update({ profile = "plug-power-energy-powerConsumption" }) + end, + { + test_init = test_init_power_strip, + min_api_version = 14 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua index a85137d0e3..632cfd9fce 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -17,6 +17,8 @@ end local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" local PARENT_ID = "PARENT_ID" local BUTTON_EPS = "__button_eps" +local MAIN_WC_EP = "__main_wc_ep" +local ACTIVE_EPS = "__active_EPS" local function create_parent_device(product_id) return test.mock_device.build_test_matter_device({ @@ -434,6 +436,154 @@ local function create_hager_pir_device(profile_name, parent) }) end +-- Create Hager 1G device with ONLY a WindowCovering endpoint (12) - no button or occupancy endpoints +local function create_hager_1g_window_covering(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 1G Window Covering", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.WindowCovering.types.Feature.LIFT | + clusters.WindowCovering.types.Feature.POSITION_AWARE_LIFT | + clusters.WindowCovering.types.Feature.ABSOLUTE_POSITION, + attributes = { + [clusters.WindowCovering.attributes.OperationalStatus.ID] = 0x00, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = 0x0000, + } + } + }, + device_types = { { device_type_id = 0x0202, device_type_revision = 1 } } + }, + } + }) +end + +-- Create Hager 1G Relay device with endpoint 3 (OnOff cluster) +local function create_hager_1g_relay(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 1G Relay", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + +local function create_hager_rotary_dimmer(profile_name) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Rotary Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x000C, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + } + }) +end + +local function create_hager_rotary_switch(profile_name) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Rotary Switch", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x000C, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + local function add_parent_device(parent) test.mock_device.add_test_device(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) @@ -494,10 +644,13 @@ local function button_supported_values (matter_device) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) end -local function initiate_info_changed(device, profile) +local function initiate_info_changed(device, profile, parent) test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = { id = profile } })) - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.mock_time.advance_time(2) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.mock_time.advance_time(3) + if parent ~= nil then + test.socket.device_lifecycle:__queue_receive(parent:generate_info_changed({})) + end end local function configure_parent(device) @@ -512,6 +665,63 @@ local function configure_parent(device) test.mock_time.advance_time(3) end +-- Create window covering child device on EP12 via PartsList and DeviceTypeList reports +local function announce_window_covering_child(host) + test.socket.matter:__queue_receive({ + host.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(host, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + for _, ep in ipairs({ 8, 9, 12 }) do + test.socket.matter:__expect_send({ + host.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(host, ep) + }) + end + + test.socket.matter:__queue_receive({ + host.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(host, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + host:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = host.id, + parent_assigned_child_key = "12" + }) + + local child_wc = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:12", host.id), + parent_device_id = host.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + return child_wc +end + +local function add_standalone_device(device, expected_profile_change) + test.mock_device.add_test_device(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + device:expect_metadata_update({ profile = expected_profile_change }) + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + test.socket.matter:__expect_send({ + device.id, + cluster_base.subscribe(device, nil, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + local parent = create_parent_device(0x0006) -- 2G button product local parent_1g = create_parent_device(0x0005) -- 1G button product local parent_pir = create_parent_device(0x0007) -- PIR product @@ -593,9 +803,9 @@ test.register_coroutine_test("Test: 4-Button Device Detection - Profile Changes }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press, and Held Events on 4-Button Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -800,9 +1010,9 @@ test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press }) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.held({ state_change = true }))) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 15) and OnOff (Type 256) Device Types with Child Creation", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -896,9 +1106,9 @@ test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 1 clusters.OnOff.attributes.OnOff:subscribe(parent, nil) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-binary and 2-button Based On Endpoint Availability", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1141,9 +1351,9 @@ test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-bin parent_assigned_child_key = "4" }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable Endpoint with Button Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1240,9 +1450,9 @@ test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Update to light-level", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1252,9 +1462,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Updat test.wait_for_events() configure_parent(parent) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1327,9 +1537,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control clusters.LevelControl.server.commands.MoveToLevelWithOnOff(dimmer, 4, 51, nil, 0, 0) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Initialization with Motion and Illuminance Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1340,9 +1550,9 @@ test.register_coroutine_test("Test: PIR Device - Initialization with Motion and configure_parent(parent_pir) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Complete Functionality with Motion, Illuminance, and Dimmer Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1414,6 +1624,7 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot }) test.wait_for_events() initiate_info_changed(pir_device, "motion-illuminance") + test.socket.matter:__expect_send({ parent_pir.id, cluster_base.subscribe(parent_pir, nil, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) @@ -1430,9 +1641,9 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot parent_device_id = parent_pir.id, parent_assigned_child_key = "3" }) - test.mock_device.add_test_device(child_dimmer) - initiate_info_changed(child_dimmer, "light-level") + + initiate_info_changed(child_dimmer, "light-level", parent_pir) test.socket.matter:__expect_send({ parent_pir.id, cluster_base.subscribe(parent_pir, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) @@ -1450,6 +1661,7 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot cluster_base.subscribe(parent_pir, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) }) test.wait_for_events() + --Verify motion detected event test.socket.matter:__queue_receive({ parent_pir.id, @@ -1473,13 +1685,14 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot parent_pir.id, clusters.OnOff.commands.On(parent_pir, 3) }) + -- Verify on state via attribute report test.socket.matter:__queue_receive({ parent_pir.id, clusters.OnOff.attributes.OnOff:build_test_report_data(parent_pir, 3, true) }) - test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) parent_pir.expect_native_attr_handler_registration(parent_pir, "switch", "switch") + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) -- Set dimmer level to 50% test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) @@ -1497,9 +1710,9 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(50))) parent_pir.expect_native_attr_handler_registration(parent_pir, "switchLevel", "level") end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile with Window Covering Child Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1558,7 +1771,7 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile }) test.mock_device.add_test_device(child_wc) - initiate_info_changed(child_wc, "window-covering") + initiate_info_changed(child_wc, "window-covering", parent) test.socket.matter:__expect_send({ parent.id, @@ -1638,9 +1851,9 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Window Covering - Preference Changes for Reverse Polarity and Preset Position", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1725,6 +1938,7 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "false" } })) test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "true" } })) + test.socket.device_lifecycle():__queue_receive(parent:generate_info_changed({ })) test.wait_for_events() local reverse_preference_set = child_wc.preferences.reverse assert(reverse_preference_set == "true", "reverse_preference_set is True") @@ -1806,8 +2020,270 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) }) end, - { - min_api_version = 15 + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: 1G Window Covering - window-covering Profile and No Duplicate Child Device", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_1g) + local wc_1g = create_hager_1g_window_covering("window-covering", parent_1g) + add_matter_device(wc_1g, parent_1g, "window-covering") + test.wait_for_events() + configure_parent(parent_1g) + test.wait_for_events() + + local main_wc_ep = wc_1g:get_field(MAIN_WC_EP) + assert(main_wc_ep == 12, "MAIN_WC_EP is set to 12") + + -- EP12 reported again → no second child created, MAIN_WC_EP already set + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(12), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 12) + }) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: 1G Relay - Profile Changes Between light-binary and 2-button Based On EP3 Availability", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_1g) + local relay_1g = create_hager_1g_relay("light-binary", parent_1g) + add_switch_matter_device(relay_1g, parent_1g) + test.wait_for_events() + configure_parent(parent_1g) + test.wait_for_events() + + -- Scenario 1: EP3 present → light-binary profile + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(3), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 3) + }) + relay_1g:expect_metadata_update({ profile = "light-binary" }) + test.wait_for_events() + + -- EP3 is device type 256 (OnOff) - no child device created for 1G + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + test.wait_for_events() + + -- Scenario 2: EP3 removed, buttons 8 & 9 present → 2-button profile + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 8) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 9) + }) + relay_1g:expect_metadata_update({ profile = "2-button" }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Window Covering - Position Attribute Reports With Reverse Polarity", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + local child_wc = announce_window_covering_child(parent) + test.wait_for_events() + + -- Attribute reports are addressed to the host, so reverse is set on the host + test.socket.device_lifecycle():__queue_receive( + parent:generate_info_changed({ preferences = { reverse = "true" } }) + ) + test.wait_for_events() + + -- Verify 50% position via attribute report - direction independent + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 5000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) + test.wait_for_events() + + -- Verify 100% position via attribute report - with reverse true, this should report closed + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 0) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) + test.wait_for_events() + + -- Verify 0% position via attribute report - with reverse true, this should report open + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 10000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.open())) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Window Covering - Unrecognised OperationalStatus Reports Unknown State", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + local child_wc = announce_window_covering_child(parent) + initiate_info_changed(child_wc, "window-covering", parent) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + test.wait_for_events() + + -- OperationalStatus 0x03 is neither opening (1) nor closing (2) + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x03) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.unknown())) + test.wait_for_events() +end, + { + min_api_version = 15 + }) +test.register_coroutine_test("Test: Rotary Dimmer - Standalone Initialization Subscribes To PartsList And Sets light-level", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_dimmer("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + local button_eps = rotary:get_field(BUTTON_EPS) + assert(#button_eps == 0, "BUTTON_EPS is empty for a rotary dimmer") +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Rotary Dimmer - EP1 Dimmable Device Type Sets light-level And Creates Child", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_dimmer("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(rotary, 0, data_types.Array({ + data_types.Uint16(1), + })) + }) + test.socket.matter:__expect_send({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(rotary, 1) + }) + test.wait_for_events() + + local active_eps = rotary:get_field(ACTIVE_EPS) + assert(active_eps[1] == 1, "ACTIVE_EPS contains endpoint 1") + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(rotary, 1, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } }) + }) + rotary:expect_metadata_update({ profile = "light-level" }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Rotary Dimmer - EP1 OnOff Device Type Sets light-binary And Creates Child", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_switch("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(rotary, 0, data_types.Array({ + data_types.Uint16(1), + })) + }) + test.socket.matter:__expect_send({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(rotary, 1) + }) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(rotary, 1, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + rotary:expect_metadata_update({ profile = "light-binary" }) +end, + { + min_api_version = 15 + }) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua index 3926b63fe9..6f5b1251eb 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -167,6 +167,38 @@ local mock_device_no_per_zone_sensitivity = test.mock_device.build_test_matter_d } }) +local mock_device_pan_tilt_only = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvSettingsUserLevelManagement.ID, + feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + } + } +}) + local mock_device_no_user_defined_zone = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("camera.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, @@ -297,6 +329,31 @@ local function test_init_no_user_defined_zone() mock_device_no_user_defined_zone:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end +-- mock_device_pan_tilt_only has no CameraAvStreamManagement cluster at all, so do_configure's +-- `if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0` branch calls match_profile +-- directly and synchronously during doConfigure, rather than waiting on an AttributeList report +-- like the other reduced fixtures above. That means the profile/capability metadata update and the +-- "PROVISIONED" transition both happen back-to-back off of the same doConfigure lifecycle event. +local subscribe_request_pan_tilt_only + +local function test_init_pan_tilt_only() + test.mock_device.add_test_device(mock_device_pan_tilt_only) + test.socket.device_lifecycle:__queue_receive({ mock_device_pan_tilt_only.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_pan_tilt_only.id, "init" }) + subscribe_request_pan_tilt_only = cluster_base.subscribe( + mock_device_pan_tilt_only, nil, camera_fields.CameraAVSULMFeatureMapAttr.cluster, camera_fields.CameraAVSULMFeatureMapAttr.ID + ) + test.socket.matter:__expect_send({mock_device_pan_tilt_only.id, subscribe_request_pan_tilt_only}) + test.socket.device_lifecycle:__queue_receive({ mock_device_pan_tilt_only.id, "doConfigure" }) + mock_device_pan_tilt_only:expect_metadata_update({ + optional_component_capabilities = { + { "main", { "mechanicalPanTiltZoom" } } + }, + profile = "camera" + }) + mock_device_pan_tilt_only:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + local additional_subscribed_attributes = { clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, clusters.CameraAvStreamManagement.attributes.ImageRotation, @@ -363,6 +420,15 @@ local additional_subscribed_attributes = { clusters.Switch.server.events.MultiPressComplete } +-- audioVolume is only added to the speaker/microphone components once a usable (max > min) volume +-- range has been reported; seed a usable range directly so unrelated tests don't have to simulate it. +local function seed_usable_volume_range(device) + device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.speaker, 0) + device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.speaker, 200) + device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.microphone, 0) + device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.microphone, 200) +end + local expected_metadata = { optional_component_capabilities = { { @@ -416,6 +482,7 @@ local expected_metadata = { } local function update_device_profile() + seed_usable_volume_range(mock_device) test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { @@ -616,11 +683,46 @@ local function update_device_profile_no_user_defined_zone() test.socket.matter:__expect_send({mock_device_no_user_defined_zone.id, subscribe_request_no_user_defined_zone}) end +local additional_subscribed_attributes_pan_tilt_only = { + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin, +} + +-- Unlike the other reduced fixtures, mock_device_pan_tilt_only's profile/capability update already +-- happened synchronously during doConfigure (see test_init_pan_tilt_only), so this only needs to +-- simulate the platform pushing the updated profile back down via infoChanged, then confirm the PTZ +-- capability is initialized with pan/tilt/presets only -- no zoom or zoomRange, since this device's +-- CameraAvSettingsUserLevelManagement feature_map never claimed MECHANICAL_ZOOM support. +local function update_device_profile_pan_tilt_only() + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = { + { "main", { "mechanicalPanTiltZoom" } } + }} + ) + test.socket.device_lifecycle:__queue_receive(mock_device_pan_tilt_only:generate_info_changed({ profile = updated_device_profile })) + test.socket.capability:__expect_send( + mock_device_pan_tilt_only:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "presets", "maxPresets"} + )) + ) + for _, attr in ipairs(additional_subscribed_attributes_pan_tilt_only) do + subscribe_request_pan_tilt_only:merge(attr:subscribe(mock_device_pan_tilt_only)) + end + test.socket.matter:__expect_send({mock_device_pan_tilt_only.id, subscribe_request_pan_tilt_only}) +end + -- Matter Handler UTs test.register_coroutine_test( "Software version change should trigger camera reprofiling when camera endpoint is present", function() + seed_usable_volume_range(mock_device) test.socket.device_lifecycle:__queue_receive( mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } }) ) @@ -709,6 +811,7 @@ test.register_coroutine_test( subscribe = function() subscribe_called = true end, supports_capability = function() return false end, get_endpoints = function() return { DOORBELL_EP } end, + get_field = function() return nil end, } local original_match_profile = camera_cfg.match_profile @@ -803,6 +906,9 @@ test.register_coroutine_test( get_endpoints = function() return { CAMERA_EP } end, + get_field = function() + return nil + end, emit_event_for_endpoint = function() init_event_count = init_event_count + 1 end @@ -818,6 +924,419 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Camera privacy mode should be added to profile when HardPrivacyModeOn is present even without the PRIV feature", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local updated_metadata = nil + local fake_device = { + profile = { components = {} }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, -- no PRIVACY feature + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) + return field == camera_fields.HARD_PRIVACY_MODE_PRESENT + end, + get_endpoints = function() return {} end, + try_update_metadata = function(_, metadata) updated_metadata = metadata end, + } + + camera_cfg.match_profile(fake_device) + + assert(updated_metadata ~= nil, "profile update should be requested when HardPrivacyModeOn is present") + local main_capabilities + for _, component in ipairs(updated_metadata.optional_component_capabilities) do + if component[1] == "main" then + main_capabilities = component[2] + end + end + local found = false + for _, cap_id in ipairs(main_capabilities or {}) do + if cap_id == capabilities.cameraPrivacyMode.ID then + found = true + end + end + assert(found, "cameraPrivacyMode should be added to the profile based on HardPrivacyModeOn presence alone") + end, + { + min_api_version = 14 + } +) + +local function build_fake_device_with_volume_ranges(raw_levels) + return { + profile = { components = {} }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER, + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) return raw_levels[field] end, + get_endpoints = function() return {} end, + supports_capability = function() return false end, + try_update_metadata = function(self, metadata) self.updated_metadata = metadata end, + } +end + +local function component_capabilities(updated_metadata, component_name) + for _, component in ipairs(updated_metadata.optional_component_capabilities) do + if component[1] == component_name then + return component[2] + end + end + return {} +end + +local function list_contains(list, value) + for _, v in ipairs(list) do + if v == value then return true end + end + return false +end + +test.register_coroutine_test( + "audioVolume should be excluded from speaker and microphone when the volume range is unusable or unknown", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + -- microphone reports an unusable range (min >= max); speaker hasn't reported a range at all yet + local fake_device = build_fake_device_with_volume_ranges({ + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 100, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 100, + }) + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioMute.ID), "audioMute should still be present on speaker") + assert(not list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from speaker when its range is unknown") + assert(list_contains(microphone_capabilities, capabilities.audioMute.ID), "audioMute should still be present on microphone") + assert(not list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from microphone when min >= max") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "audioVolume should be included on speaker and microphone when the volume range is usable", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local fake_device = build_fake_device_with_volume_ranges({ + [camera_fields.MIN_VOLUME_LEVEL .. "_speaker"] = 0, + [camera_fields.MAX_VOLUME_LEVEL .. "_speaker"] = 200, + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 0, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 200, + }) + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be included on speaker when max > min") + assert(list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be included on microphone when max > min") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "audioVolume should be preserved on a component whose volume range isn't fully known yet", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + -- Speaker already has audioVolume from a prior match; its raw min/max aren't in raw_levels (nil), + -- so it should be carried over as-is rather than being dropped for lack of information. + local existing_components = { + speaker = { capabilities = { audioVolume = { id = capabilities.audioVolume.ID } } } + } + local raw_levels = { + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 100, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 100, -- unusable; forces a real profile change so we can inspect it + } + local fake_device = { + profile = { components = existing_components }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER, + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) return raw_levels[field] end, + get_endpoints = function() return {} end, + supports_capability = function(_, capability, component) + local comp = existing_components[component] + if not comp then return false end + for _, cap in pairs(comp.capabilities) do + if cap.id == capability.ID then return true end + end + return false + end, + try_update_metadata = function(self, metadata) self.updated_metadata = metadata end, + } + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be preserved on speaker while its range is unknown") + assert(list_contains(microphone_capabilities, capabilities.audioMute.ID), "audioMute should still be present on microphone") + assert(not list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from microphone when min >= max") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Speaker audioVolume should be removed from the profile when its volume range becomes unusable", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, 0) + }) + + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", + { "switch", "mode" } + }, + { "speaker", + { "audioMute" } -- audioVolume removed: reported max (0) is no longer greater than the known min (0) + }, + { "microphone", + { "audioMute", "audioVolume" } + }, + { "doorbell", + { "button" } + } + }, + profile = "camera" + } + mock_device:expect_metadata_update(updated_expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Speaker/Microphone volume range attributes should stay subscribed even when audioVolume isn't granted yet", + function() + -- Fresh pairing: no volume range has been reported yet, so audioVolume shouldn't be granted on + -- the very first profile match. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + local first_pass_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", { "switch", "mode" } }, + { "speaker", { "audioMute" } }, + { "microphone", { "audioMute" } }, + { "doorbell", { "button" } } + }, + profile = "camera" + } + mock_device:expect_metadata_update(first_pass_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() + + local first_pass_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = first_pass_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = first_pass_profile })) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.supportedFeatures( + {audio="sendrecv", bundle=true, order="audio/video", supportTrickleICE=true, turnSource="player", video="recvonly"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "zoom", "zoomRange", "presets", "maxPresets"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation", "perZoneSensitivity"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.localMediaStorage.supportedAttributes( + {"localVideoRecording"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.audioRecording.audioRecording("enabled")) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports", "watermark", "onScreenDisplay"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( + {"softRecordingPrivacyMode", "softLivestreamPrivacyMode"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( + {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} + )) + ) + + -- audioVolume isn't active yet, so its current-value attributes shouldn't be (re-)subscribed, but + -- the min/max discovery attributes -- now bundled with audioMute -- must still be. Without that, + -- the driver could never learn the range needed to decide whether audioVolume belongs at all. + for _, attr in ipairs(additional_subscribed_attributes) do + if attr ~= clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel and + attr ~= clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel then + subscribe_request:merge(attr:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Camera privacy mode supportedAttributes/supportedCommands should only expose hardPrivacyMode when PRIV feature is absent", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local emitted = {} + local fake_device = { + endpoints = { + { + endpoint_id = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, -- no PRIVACY feature + cluster_type = "SERVER" + } + } + } + }, + supports_capability = function(_, capability) + return capability == capabilities.cameraPrivacyMode + end, + get_field = function(_, field) + return field == camera_fields.HARD_PRIVACY_MODE_PRESENT + end, + get_endpoints = function(self, cluster_id, opts) + opts = opts or {} + local eps = {} + for _, ep in ipairs(self.endpoints) do + for _, clus in ipairs(ep.clusters) do + if clus.cluster_id == cluster_id and + (opts.feature_bitmap == nil or (clus.feature_map & opts.feature_bitmap) == opts.feature_bitmap) then + table.insert(eps, ep.endpoint_id) + end + end + end + return eps + end, + emit_event_for_endpoint = function(_, _, capability_event) + table.insert(emitted, capability_event) + end, + } + + camera_cfg.initialize_camera_capabilities(fake_device) + + local function list_equals(a, b) + if #a ~= #b then return false end + for i, v in ipairs(a) do + if v ~= b[i] then return false end + end + return true + end + + local supported_attributes_event, supported_commands_event + for _, event in ipairs(emitted) do + if event.attribute == capabilities.cameraPrivacyMode.supportedAttributes then + supported_attributes_event = event + elseif event.attribute == capabilities.cameraPrivacyMode.supportedCommands then + supported_commands_event = event + end + end + + assert(supported_attributes_event ~= nil, "supportedAttributes should be emitted") + assert(list_equals(supported_attributes_event.value.value, {"hardPrivacyMode"}), + "supportedAttributes should contain only hardPrivacyMode when PRIV feature is absent") + assert(supported_commands_event ~= nil, "supportedCommands should be emitted") + assert(list_equals(supported_commands_event.value.value, {}), + "supportedCommands should be empty when PRIV feature is absent, since HardPrivacyModeOn is read-only") + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Reports mapping to EnabledState capability data type should generate appropriate events", function() @@ -849,10 +1368,6 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal", "imageFlipVertical"})) ) - elseif v.capability == capabilities.cameraPrivacyMode.hardPrivacyMode then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes({"softRecordingPrivacyMode", "softLivestreamPrivacyMode", "hardPrivacyMode"})) - ) end test.socket.matter:__queue_receive({ mock_device.id, @@ -1065,6 +1580,56 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "A volume level report racing a zero-width range update should not divide by zero", + function() + update_device_profile() + test.wait_for_events() + + -- Narrow the range to min == max. This drops audioVolume from the profile, but the internal + -- MAX/MIN_VOLUME_LEVEL fields used for normalization are updated synchronously, before the + -- platform round-trips the narrowed profile back down. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", { "switch", "mode" } }, + { "speaker", { "audioMute" } }, -- audioVolume dropped: the range is now zero-width (100 == 100) + { "microphone", { "audioMute", "audioVolume" } }, + { "doorbell", { "button" } } + }, + profile = "camera" + } + mock_device:expect_metadata_update(updated_expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() + + -- A late SpeakerVolumeLevel report can still arrive before the narrowed profile comes back down. + -- It should report a fixed value instead of dividing by (max - min) == 0. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(0)) + ) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Status Light Enabled reports should generate appropriate events", function() @@ -1358,6 +1923,32 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "PTZ Position report with zoom omitted should be handled normally on a device with no zoom feature", + function() + update_device_profile_pan_tilt_only() + test.wait_for_events() + -- This device has no zoom feature, so its MPTZPosition report never has a zoom field; + -- pan/tilt should still be handled normally. + test.socket.matter:__queue_receive({ + mock_device_pan_tilt_only.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device_pan_tilt_only, CAMERA_EP, {pan = 10, tilt = 20}) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device_pan_tilt_only:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) + ) + test.socket.capability:__expect_send( + mock_device_pan_tilt_only:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) + ) + end, + { + min_api_version = 14, + test_init = test_init_pan_tilt_only + } +) + test.register_coroutine_test( "PTZ Presets reports should generate appropriate events", function() @@ -2006,21 +2597,21 @@ test.register_coroutine_test( { capability = "mechanicalPanTiltZoom", component = "main", command = "panRelative", args = { 10 } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 10, 0, 0) + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 10, nil, nil) }) test.socket.capability:__queue_receive({ mock_device.id, { capability = "mechanicalPanTiltZoom", component = "main", command = "tiltRelative", args = { -35 } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 0, -35, 0) + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, nil, -35, nil) }) test.socket.capability:__queue_receive({ mock_device.id, { capability = "mechanicalPanTiltZoom", component = "main", command = "zoomRelative", args = { 80 } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 0, 0, 80) + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, nil, nil, 80) }) end, { @@ -2061,7 +2652,7 @@ test.register_coroutine_test( { capability = "mechanicalPanTiltZoom", component = "main", command = "setPan", args = { 50 } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, 20, 30) + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, nil, nil) }) test.socket.matter:__queue_receive({ mock_device.id, @@ -2077,7 +2668,7 @@ test.register_coroutine_test( { capability = "mechanicalPanTiltZoom", component = "main", command = "setTilt", args = { -44 } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, -44, 30) + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, nil, -44, nil) }) test.socket.matter:__queue_receive({ mock_device.id, @@ -2093,7 +2684,7 @@ test.register_coroutine_test( { capability = "mechanicalPanTiltZoom", component = "main", command = "setZoom", args = { 5 } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, -44, 5) + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, nil, nil, 5) }) test.socket.matter:__queue_receive({ mock_device.id, @@ -2109,6 +2700,24 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Set PTZ command with a zoom-omitting setPanTiltZoom should not clamp the missing axis", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setPanTiltZoom", args = { 0, 0 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 0, 0, nil) + }) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Preset commands should send the appropriate commands", function() @@ -3315,6 +3924,35 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Camera privacy mode supportedAttributes should include hardPrivacyMode when reported in AttributeList", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID), + uint32(clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID) + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( + {"softRecordingPrivacyMode", "softLivestreamPrivacyMode", "hardPrivacyMode"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( + {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} + )) + ) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Camera profile should include zoneManagement when USER_DEFINED feature is present", function() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua index b3390edc29..1c9e901277 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua @@ -419,17 +419,20 @@ test.register_coroutine_test( "Test driver switched event", function() test.mock_device.add_test_device(mock_child) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) + test.wait_for_events() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_WITH_CHILD[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) - mock_child:expect_metadata_update({ profile = "light-color-level" }) - mock_device:expect_metadata_update({ profile = "light-level-3-button" }) - expect_configure_buttons() - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + mock_child:expect_metadata_update({ profile = "light-color-level" }) + mock_device:expect_metadata_update({ profile = "light-level-3-button" }) + expect_configure_buttons() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, { min_api_version = 15 @@ -475,6 +478,7 @@ test.register_coroutine_test( test.socket.matter:__expect_send({mock_device.id, clusters.OnOff.attributes.OnOff:read(mock_device)}) test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) test.socket.device_lifecycle:__queue_receive({ mock_child.id, "init" }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) local subscribe_request = CLUSTER_SUBSCRIBE_LIST_WITH_CHILD[1]:subscribe(mock_device) for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end diff --git a/drivers/SmartThings/matter-thermostat/fingerprints.yml b/drivers/SmartThings/matter-thermostat/fingerprints.yml index 814261790e..399f3861fc 100644 --- a/drivers/SmartThings/matter-thermostat/fingerprints.yml +++ b/drivers/SmartThings/matter-thermostat/fingerprints.yml @@ -89,6 +89,21 @@ matterManufacturer: vendorId: 0x134E productId: 0x0002 deviceProfileName: thermostat-humidity-heating-only-nostate-nobattery + - id: "4942/7" + deviceLabel: Smart Thermostat X (2nd Gen) + vendorId: 0x134E + productId: 0x0007 + deviceProfileName: thermostat-humidity-heating-only-nostate-batteryLevel + - id: "4942/8" + deviceLabel: Wireless Temperature Sensor X (2nd Gen) + vendorId: 0x134E + productId: 0x0008 + deviceProfileName: thermostat-humidity-heating-only-nostate-batteryLevel + - id: "4942/9" + deviceLabel: Smart Radiator Thermostat X + vendorId: 0x134E + productId: 0x0009 + deviceProfileName: thermostat-humidity-heating-only-nostate-batteryLevel #Taruie - id: "5151/4101" deviceLabel: TARUIE AC Remote diff --git a/drivers/SmartThings/matter-window-covering/fingerprints.yml b/drivers/SmartThings/matter-window-covering/fingerprints.yml index 34406cf01c..66b33f0bf7 100644 --- a/drivers/SmartThings/matter-window-covering/fingerprints.yml +++ b/drivers/SmartThings/matter-window-covering/fingerprints.yml @@ -255,6 +255,12 @@ matterManufacturer: vendorId: 0x1523 productId: 0x0A69 deviceProfileName: window-covering-battery +#3L Smart + - id: "5412/48897" + deviceLabel: Blinds motor + vendorId: 0x1524 + productId: 0xBF01 + deviceProfileName: window-covering-battery matterGeneric: - id: "windowcovering" deviceLabel: Matter Window Covering diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 27857231c8..e73218da29 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -41,6 +41,11 @@ 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 + --- 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 @@ -111,11 +116,9 @@ end local function try_emit_idle_window_shade_status(device, ep) if not device:get_field(TARGET_LIFT_PERCENT.STATE) and not device:get_field(TARGET_TILT_PERCENT.STATE) then - if ep then - device:emit_event_for_endpoint(ep, get_idle_window_shade_status(device)) - else - device:emit_event(get_idle_window_shade_status(device)) - end + --- 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 end @@ -182,13 +185,8 @@ local function match_profile(device, battery_supported) device:try_update_metadata({profile = profile_name}) 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 - local function create_or_update_multi_devices(driver, device, wc_cluster_eps) - local default_endpoint_id = find_default_endpoint(device) + 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 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 index b4aae11e58..fb10d6b13b 100644 --- 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 @@ -1,21 +1,34 @@ --- Copyright 2022 SmartThings, Inc. +-- 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() -local ENDPOINT_MAP = {5, 6, 7, 8, 9, 10, 11} -local mock_device = test.mock_device.build_test_matter_device( +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}, @@ -31,7 +44,7 @@ local mock_device = test.mock_device.build_test_matter_device( } }, { - endpoint_id = ENDPOINT_MAP[1], + endpoint_id = 5, clusters = { { cluster_id = WindowCovering.ID, @@ -44,7 +57,7 @@ local mock_device = test.mock_device.build_test_matter_device( }, }, { - endpoint_id = ENDPOINT_MAP[2], + endpoint_id = CHILD_DEVICE_MAP[1].ep, clusters = { { cluster_id = WindowCovering.ID, @@ -57,7 +70,7 @@ local mock_device = test.mock_device.build_test_matter_device( }, }, { - endpoint_id = ENDPOINT_MAP[3], + endpoint_id = CHILD_DEVICE_MAP[2].ep, clusters = { { cluster_id = WindowCovering.ID, @@ -70,7 +83,7 @@ local mock_device = test.mock_device.build_test_matter_device( }, }, { - endpoint_id = ENDPOINT_MAP[4], + endpoint_id = CHILD_DEVICE_MAP[3].ep, clusters = { { cluster_id = WindowCovering.ID, @@ -83,7 +96,7 @@ local mock_device = test.mock_device.build_test_matter_device( }, }, { - endpoint_id = ENDPOINT_MAP[5], + endpoint_id = CHILD_DEVICE_MAP[4].ep, clusters = { { cluster_id = WindowCovering.ID, @@ -96,7 +109,7 @@ local mock_device = test.mock_device.build_test_matter_device( }, }, { - endpoint_id = ENDPOINT_MAP[6], + endpoint_id = CHILD_DEVICE_MAP[5].ep, clusters = { { cluster_id = WindowCovering.ID, @@ -109,7 +122,7 @@ local mock_device = test.mock_device.build_test_matter_device( }, }, { - endpoint_id = ENDPOINT_MAP[7], + endpoint_id = CHILD_DEVICE_MAP[6].ep, clusters = { { cluster_id = WindowCovering.ID, @@ -125,6 +138,57 @@ local mock_device = test.mock_device.build_test_matter_device( } ) +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, @@ -149,63 +213,66 @@ local function set_preset(device) end local function test_init() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + 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_device:generate_test_message( + mock_parent_device:generate_test_message( "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, {visibility = {displayed = false}}) ) ) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - set_preset(mock_device) - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + 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_device)) end + if i > 1 then subscribe_request:merge(clus:subscribe(mock_parent_device)) end end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - for i, ep in ipairs(ENDPOINT_MAP) do - mock_device:expect_device_create({ + 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", i), + label = string.format("%s %d", "Window Covering", child.key), profile = "window-covering", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", ENDPOINT_MAP[i]) + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%d", child.ep) }) - if i == 1 then - test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) - end end - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + 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 _, ep in ipairs(ENDPOINT_MAP) do + "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_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, ep, 0 + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(100) ) ) test.socket.matter:__queue_receive( { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + mock_child_device[i].id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShade.windowShade.open() ) ) @@ -217,29 +284,30 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Idle WindowCovering OperationalStatus state triggers 'closed' capability emission if lift position is 0", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "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_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, ep, 10000 + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 10000 ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(0) ) ) test.socket.matter:__queue_receive( { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + mock_child_device[i].id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShade.windowShade.closed() ) ) @@ -251,29 +319,30 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if lift position is 50", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "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_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, ep, 5000 + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 5000 ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(50) ) ) test.socket.matter:__queue_receive( { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + mock_child_device[i].id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShade.windowShade.partially_open() ) ) @@ -285,30 +354,31 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if tilt position is between 1-100", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "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_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, ep, ((100 - 15) *100) + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 15) *100) ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) ) ) test.socket.matter:__queue_receive( { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 0), + mock_child_device[i].id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShade.windowShade.partially_open() ) ) @@ -320,16 +390,17 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Unknown WindowCovering OperationalStatus state triggers 'unknown' capability emission", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "Unknown WindowCovering OperationalStatus state triggers 'unknown' capability emission", + function() + for i = 1, #mock_child_device do test.socket.matter:__queue_receive( { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 99), + mock_child_device[i].id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 99), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShade.windowShade.unknown() ) ) @@ -341,16 +412,17 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Opening WindowCovering OperationalStatus state triggers 'closing' capability emission", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "Opening WindowCovering OperationalStatus state triggers 'closing' capability emission", + function() + for i = 1, #mock_child_device do test.socket.matter:__queue_receive( { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 2), + mock_child_device[i].id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 2), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShade.windowShade.closing() ) ) @@ -362,16 +434,17 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Closing WindowCovering OperationalStatus state triggers 'opening' capability emission", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "Closing WindowCovering OperationalStatus state triggers 'opening' capability emission", + function() + for i = 1, #mock_child_device do test.socket.matter:__queue_receive( { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, ep, 1), + mock_child_device[i].id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 1), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShade.windowShade.opening() ) ) @@ -383,44 +456,45 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "WindowCovering CurrentPositionLiftPercent100ths triggers appropriate capability emission", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "WindowCovering CurrentPositionLiftPercent100ths triggers appropriate capability emission", + function() + for i = 1, #mock_child_device do test.socket.matter:__queue_receive( { - mock_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, ep, 10000 + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 10000 ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(0) ) ) test.socket.matter:__queue_receive( { - mock_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, ep, 0 + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(100) ) ) test.socket.matter:__queue_receive( { - mock_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, ep, ((100 - 25) *100) + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 25) *100) ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(25) ) ) @@ -432,60 +506,649 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "WindowCovering CurrentPositionTiltPercent100ths triggers appropriate capability emission", function() - for _, ep in ipairs(ENDPOINT_MAP) do + "WindowCovering CurrentPositionTiltPercent100ths triggers appropriate capability emission", + function() + for i = 1, #mock_child_device do test.socket.matter:__queue_receive( { - mock_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, ep, 10000 + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 10000 ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) ) ) test.socket.matter:__queue_receive( { - mock_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, ep, 0 + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) ) ) test.socket.matter:__queue_receive( { - mock_device.id, + mock_child_device[i].id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, ep, ((100 - 15) *100) + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 15) *100) ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) ) ) test.socket.matter:__queue_receive( { - mock_device.id, + mock_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, ep, ((100 - 65) *100) + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, ((100 - 65) *100) ), } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child_device[i]:generate_test_message( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) ) ) + test.socket.matter:__queue_receive( + { + mock_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_child_device[i], CHILD_DEVICE_MAP[i].ep, 0 + ) + }) + test.socket.matter:__queue_receive({ + mock_child_device[i].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, { @@ -493,4 +1156,225 @@ test.register_coroutine_test( } ) +-- -- 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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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_child_device[i].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.run_registered_tests() diff --git a/drivers/SmartThings/sonos/src/api/sonos_connection.lua b/drivers/SmartThings/sonos/src/api/sonos_connection.lua index 6a131c3b0c..4852fc517c 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_connection.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_connection.lua @@ -547,6 +547,8 @@ function SonosConnection.new(driver, device) self.on_close = function(_) log.debug(string.format("OnClose for %s", device_name)) if self._initialized then + self._initialized = false + log.debug(string.format("Marking %s as offline due to websocket closure", device_name)) self.device:offline() end if self._keepalive then @@ -566,10 +568,10 @@ function SonosConnection:is_running() string.format( "%s all connections running? %s", self.device.label, - st_utils.stringify_table({ coordinator = self_running, mine = self_running }) + st_utils.stringify_table({ coordinator = coord_running, mine = self_running }) ) ) - return self_running and coord_running + return self_running and coord_running and self._initialized end --- Whether or not the connection has a live websocket connection @@ -586,7 +588,7 @@ function SonosConnection:self_running() ) ) end - return type(unique_key) == "string" and Router.is_connected(unique_key) and self._initialized + return type(unique_key) == "string" and Router.is_connected(unique_key) end --- Whether or not the connection has a live websocket connection to its coordinator @@ -685,7 +687,7 @@ function SonosConnection:start() end if not self:coordinator_running() then - --TODO this is not infallible + --TODO this is not infallible, but is checked below _open_coordinator_socket(self, household_id, player_id, api_key) end @@ -700,6 +702,7 @@ function SonosConnection:start() and coordinator_unique_key and Router.is_connected(coordinator_unique_key) then + log.debug(string.format("Marking %s as online since all websockets connected", self.device.label)) self.device:online() self._initialized = true self._keepalive = true diff --git a/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua b/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua index c19e35274b..9ba4710a66 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua @@ -233,11 +233,11 @@ end ---@return string? function SonosWebSocketRouter.register_listener_for_socket(listener, unique_key_for_socket) if listener and listener.device and listener.device.label then - log.debug( + log.debug(string.format( "Registering SonosConnection for device %s as listener for player %s websocket", listener.device.label, unique_key_for_socket - ) + )) end local ws = websockets[unique_key_for_socket] diff --git a/drivers/SmartThings/zigbee-button/fingerprints.yml b/drivers/SmartThings/zigbee-button/fingerprints.yml index 347fa818df..119b8a18ee 100644 --- a/drivers/SmartThings/zigbee-button/fingerprints.yml +++ b/drivers/SmartThings/zigbee-button/fingerprints.yml @@ -248,7 +248,7 @@ zigbeeManufacturer: deviceLabel: ITM Switch manufacturer: Samsung Electronics model: SAMSUNG-ITM-Z-005 - deviceProfileName: SLED-three-buttons + deviceProfileName: SLED-three-buttons - id: "Linxura Smart Controller" deviceLabel: Linxura Smart Controller manufacturer: Linxura diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index ee36659670..086c79346a 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -93,6 +93,11 @@ zigbeeManufacturer: manufacturer: SONOFF model: SNZB-02B deviceProfileName: humidity-temp-battery + - id: SONOFF/SNZB-02UL + deviceLabel: SONOFF Zigbee Temperature Humidity Sensor + manufacturer: SONOFF + model: SNZB-02UL + deviceProfileName: humidity-temp-battery - id: "Third Reality/3RTHS24BZ" deviceLabel: ThirdReality Temperature and Humidity Sensor manufacturer: Third Reality, Inc diff --git a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml index 6fce8d0d3a..837463ab08 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml +++ b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml @@ -5,7 +5,7 @@ zigbeeManufacturer: model: lumi.sensor_gas.acn02 deviceProfileName: gas-lifetime-selfcheck-aqara - id: "LUMI/lumi.sensor_smoke.acn03" - deviceLabel: Aqara Smart Smoke Detector + deviceLabel: Aqara Smart Smoke Detector manufacturer: LUMI model: lumi.sensor_smoke.acn03 deviceProfileName: smoke-battery-aqara diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index bebe5295a2..613c471dcb 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -1744,7 +1744,7 @@ zigbeeManufacturer: deviceLabel: SMART ZIGBEE PLUG EU EM T manufacturer: LEDVANCE model: PLUG EU EM T - deviceProfileName: switch-power-energy + deviceProfileName: switch-power-energy - id: "OSRAM/LIGHTIFY Edge-lit flushmount" deviceLabel: SYLVANIA Light manufacturer: OSRAM @@ -2115,11 +2115,6 @@ zigbeeManufacturer: manufacturer: OSRAM model: CLA60 RGBW OSRAM deviceProfileName: rgbw-bulb - - id: "OSRAM/Flex RGBW" - deviceLabel: OSRAM Light - manufacturer: OSRAM - model: Flex RGBW - deviceProfileName: rgbw-bulb - id: "OSRAM/Gardenpole RGBW-Lightify" deviceLabel: OSRAM Light manufacturer: OSRAM @@ -2230,11 +2225,6 @@ zigbeeManufacturer: manufacturer: IKEA of Sweden model: JORMLIEN door WS 40x80 deviceProfileName: color-temp-bulb-2200K-4000K - - id: "OSRAM/Classic B40 TW - LIGHTIFY" - deviceLabel: OSRAM Light - manufacturer: OSRAM - model: Classic B40 TW - LIGHTIFY - deviceProfileName: color-temp-bulb - id: "OSRAM/CLA60 TW OSRAM" deviceLabel: OSRAM Light manufacturer: OSRAM @@ -2552,6 +2542,22 @@ zigbeeManufacturer: manufacturer: FIRSTLED model: DC2DC12MiV1 deviceProfileName: light-color-temp-time-restore +#Legrand/BTicino + - id: Legrand/Micromodule switch + deviceLabel: Connected Lighting Micromodule + manufacturer: Legrand + model: Micromodule switch + deviceProfileName: basic-switch + - id: Legrand/Light switch with neutral + deviceLabel: Connected Switch with Neutral + manufacturer: Legrand + model: Light switch with neutral + deviceProfileName: basic-switch + - id: Legrand/Connected outlet + deviceLabel: Connected Socket + manufacturer: Legrand + model: Connected outlet + deviceProfileName: switch-power zigbeeGeneric: - id: "genericSwitch" deviceLabel: Zigbee Switch diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index efa89be552..9f8bf3ce3c 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -1,4 +1,9 @@ zigbeeManufacturer: + - id: "SONOFF/TRV-ZBT" + deviceLabel: SONOFF TRV-ZBT + manufacturer: SONOFF + model: TRV-ZBT + deviceProfileName: thermostat-sonoff-trv-zbt - id: "Vimar/02973-smart-wheel-thermostat" deviceLabel: Vimar Smart Thermostat manufacturer: Vimar @@ -139,6 +144,11 @@ zigbeeManufacturer: manufacturer: WALL HERO model: ACL-403STC1 deviceProfileName: thermostat-thirty-buttons-wallhero + - id: "SONOFF/TP-WGZBA" + deviceLabel: SONOFF Thermostat + manufacturer: SONOFF + model: TP-WGZBA + deviceProfileName: thermostat-sonoff zigbeeGeneric: - id: "genericThermostat" deviceLabel: Zigbee Thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml new file mode 100644 index 0000000000..16ad1353b6 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml @@ -0,0 +1,38 @@ +name: thermostat-sonoff-trv-zbt +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + - id: thermostatMode + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: +- name: "childLock" + title: "Child lock" + description: "Enable or disable the child lock of the thermostat." + required: false + preferenceType: enumeration + definition: + options: + 0: "Unlock" + 1: "Lock" + default: 0 +- name: "enterBluetoothPairing" + title: "Enter Bluetooth pairing" + description: "Enable to put the thermostat into Bluetooth pairing mode. The pairing window is controlled by the thermostat firmware." + required: false + preferenceType: boolean + definition: + default: false diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml new file mode 100644 index 0000000000..80bd8a5b1d --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml @@ -0,0 +1,248 @@ +name: thermostat-sonoff +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - heating + - idle + - id: thermostatMode + version: 1 + config: + values: + - key: "thermostatMode.value" + enabledValues: + - off + - auto + - heat + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + step: 0.5 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: +- name: temperatureCompensation + title: Temperature compensation + description: Local temperature calibration in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: -10 + maximum: 10 + default: 0 +- name: childLock + title: Child lock + description: Lock the thermostat controls. + required: true + preferenceType: boolean + definition: + default: false +- name: btPairingBroadcast + title: Bluetooth pairing broadcast + description: Enable Bluetooth pairing broadcasts. + required: true + preferenceType: boolean + definition: + default: false +- name: openWindowDetection + title: Open window detection + description: Enable open-window detection. + required: true + preferenceType: boolean + definition: + default: false +- name: frostProofTemperature + title: Frost protection temperature + description: Frost protection setpoint in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 5 + maximum: 15 + default: 5 +- name: temporaryModeAction + title: Temporary mode + description: Apply an override using the duration and temperature below. + required: true + preferenceType: enumeration + definition: + options: + exit: Exit + boost: Boost + timer: Timer + default: exit +- name: temporaryModeDuration + title: Temporary mode duration + description: Override duration in minutes. + required: true + preferenceType: integer + definition: + minimum: 1 + maximum: 1440 + default: 30 +- name: temporaryModeTemperature + title: Temporary mode temperature + description: Timer override temperature in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 5 + maximum: 30 + default: 30 +- name: temperatureThresholdLow + title: Temperature threshold low + description: Lower temperature-control threshold in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: -2.6 + maximum: -0.2 + default: -2 +- name: temperatureThresholdHigh + title: Temperature threshold high + description: Upper temperature-control threshold in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 2.6 + default: 2 +- name: radarSensitivity + title: Radar sensitivity + description: Set the radar sensitivity level. + required: true + preferenceType: enumeration + definition: + options: + "0": Low + "1": Medium + "2": High + default: "2" +- name: radarDoNotDisturb + title: Radar do not disturb + description: Enable radar do-not-disturb mode. + required: true + preferenceType: boolean + definition: + default: false +- name: radarDoNotDisturbStart + title: Radar do not disturb start + description: Start time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: radarDoNotDisturbEnd + title: Radar do not disturb end + description: End time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: screenWorkingBrightness + title: Screen working brightness + description: Screen brightness while active. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 5 +- name: screenStandbyBrightness + title: Screen standby brightness + description: Screen brightness while on standby. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 0 +- name: nightStandbyBrightness + title: Screen night standby brightness + description: Screen brightness during night standby. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 0 +- name: screenNightMode + title: Screen night mode + description: Enable screen night mode. + required: true + preferenceType: boolean + definition: + default: false +- name: screenNightStart + title: Screen night mode start + description: Start time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: screenNightEnd + title: Screen night mode end + description: End time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: heatRelayNormClosed + title: Heating relay normally closed + description: Configure the heating relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: boilerRelayNormClosed + title: Boiler relay normally closed + description: Configure the boiler relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: overheatProtectTemp + title: Overheat protection temperature + description: Overheat protection setpoint in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 20 + maximum: 50 + default: 33.5 +- name: overheatProtection + title: Overheat protection + description: Enable overheat protection. + required: true + preferenceType: boolean + definition: + default: false +- name: radarEnabled + title: Radar enabled + description: Enable the radar sensor. + required: true + preferenceType: boolean + definition: + default: false diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua new file mode 100644 index 0000000000..9b270451e8 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device) + if device:get_manufacturer() == "SONOFF" and device:get_model() == "TRV-ZBT" then + return true, require("sonoff") + end + + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua new file mode 100644 index 0000000000..0097dae163 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -0,0 +1,321 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local generic_body = require "st.zigbee.generic_body" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zb_const = require "st.zigbee.constants" +local utils = require "st.utils" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat + +local Battery = capabilities.battery +local Refresh = capabilities.refresh +local TemperatureMeasurement = capabilities.temperatureMeasurement +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +local WORK_MODE_ATTR = 0x0018 +local WORK_MODE_OFF = 0x01 +local WORK_MODE_MANUAL = 0x03 +local WORK_MODE_SCHEDULE = 0x04 +local WORK_MODE_TEMP_MANUAL = 0x05 + +local DEFAULT_MIN_SETPOINT = 5 +local DEFAULT_MAX_SETPOINT = 30 +local SETPOINT_STEP = 0.5 +local CHILD_LOCK_PREFERENCE = "childLock" +local BLUETOOTH_PAIRING_PREFERENCE = "enterBluetoothPairing" +local MIN_HEAT_SETPOINT_LIMIT_FIELD = "min_heat_setpoint_limit" +local MAX_HEAT_SETPOINT_LIMIT_FIELD = "max_heat_setpoint_limit" +local SUPPORTED_MODES = { + ThermostatMode.thermostatMode.off.NAME, + ThermostatMode.thermostatMode.heat.NAME, + ThermostatMode.thermostatMode.auto.NAME, +} + +local CONFIGURATIONS = { + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.LocalTemperature.ID, + minimum_interval = 10, + maximum_interval = 300, + data_type = Thermostat.attributes.LocalTemperature.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.OccupiedHeatingSetpoint.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.OccupiedHeatingSetpoint.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.SystemMode.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.SystemMode.base_type, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MinHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MinHeatSetpointLimit.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MaxHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MaxHeatSetpointLimit.base_type, + reportable_change = 50, + }, +} + +local function get_setpoint_range(device) + local reported_min = device:get_field(MIN_HEAT_SETPOINT_LIMIT_FIELD) + local reported_max = device:get_field(MAX_HEAT_SETPOINT_LIMIT_FIELD) + local min = reported_min and (reported_min / 100) or DEFAULT_MIN_SETPOINT + local max = reported_max and (reported_max / 100) or DEFAULT_MAX_SETPOINT + + if min > max then + min = DEFAULT_MIN_SETPOINT + max = DEFAULT_MAX_SETPOINT + end + + return min, max +end + +local function emit_setpoint_range(device) + local min, max = get_setpoint_range(device) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ + value = { + minimum = min, + maximum = max, + step = SETPOINT_STEP, + }, + unit = "C", + }, { visibility = { displayed = false } })) +end + +local function emit_supported_modes(device) + device:emit_event(ThermostatMode.supportedThermostatModes(SUPPORTED_MODES, { + visibility = { displayed = false }, + })) +end + +local function emit_mode(device, mode) + device:emit_event(ThermostatMode.thermostatMode[mode]()) +end + +local function command_argument(command, name) + if command.args ~= nil and command.args[name] ~= nil then + return command.args[name] + end + + return command.named_args and command.named_args[name] +end + +local function refresh(_, device) + local attributes = { + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + } + + for _, attribute in ipairs(attributes) do + device:send(attribute:read(device)) + end +end + +local function configure(driver, device) + device:configure() + refresh(driver, device) + device:send(Thermostat.attributes.MinHeatSetpointLimit:read(device)) + device:send(Thermostat.attributes.MaxHeatSetpointLimit:read(device)) +end + +local function added(_, device) + emit_supported_modes(device) + emit_setpoint_range(device) +end + +local function init(driver, device) + for _, configuration in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(configuration) + end + added(driver, device) + refresh(driver, device) +end + +local function info_changed(_, device, _, args) + local old_preferences = args.old_st_store and args.old_st_store.preferences or {} + local child_lock = device.preferences[CHILD_LOCK_PREFERENCE] + if child_lock ~= nil and old_preferences[CHILD_LOCK_PREFERENCE] ~= child_lock then + device:send(cluster_base.write_attribute( + device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(tonumber(child_lock) == 1, data_types.Boolean, "payload") + )) + end + + local bluetooth_pairing = device.preferences[BLUETOOTH_PAIRING_PREFERENCE] + if bluetooth_pairing == true and old_preferences[BLUETOOTH_PAIRING_PREFERENCE] ~= true then + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(0x10), + }) + zcl_header.frame_ctrl:set_cluster_specific() + device:send(messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(SONOFF_CLUSTER) or 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + })) + end +end + +local function local_temperature_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(TemperatureMeasurement.temperature({ value = value.value / 100, unit = "C" })) + end +end + +local function heating_setpoint_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = value.value / 100, unit = "C" })) + end +end + +local function setpoint_limit_handler(field) + return function(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:set_field(field, value.value, { persist = true }) + emit_setpoint_range(device) + end + end +end + +local function system_mode_handler(_, device, value) + if value.value == Thermostat.attributes.SystemMode.OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == Thermostat.attributes.SystemMode.AUTO then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == Thermostat.attributes.SystemMode.HEAT then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function battery_handler(_, device, value) + device:emit_event(Battery.battery(utils.clamp_value(utils.round(value.value / 2), 0, 100))) +end + +local function work_mode_handler(_, device, value) + if value.value == WORK_MODE_OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == WORK_MODE_SCHEDULE then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == WORK_MODE_MANUAL or value.value == WORK_MODE_TEMP_MANUAL then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function set_heating_setpoint(_, device, command) + local setpoint = command_argument(command, "setpoint") + if setpoint >= 40 then + setpoint = utils.f_to_c(setpoint) + end + + local min, max = get_setpoint_range(device) + setpoint = utils.clamp_value(setpoint, min, max) + -- The TRV-ZBT persists heating setpoints in 0.5 C increments. + setpoint = math.floor((setpoint / SETPOINT_STEP) + 0.000001) * SETPOINT_STEP + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) +end + +local function set_mode(_, device, command) + local mode_to_system_mode = { + [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, + [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, + [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, + } + local mode = command_argument(command, "mode") + local system_mode = mode_to_system_mode[mode] + if system_mode == nil then + return + end + + device:send(Thermostat.attributes.SystemMode:write(device, system_mode)) + emit_mode(device, mode) +end + +local function mode_setter(mode) + return function(driver, device, command) + set_mode(driver, device, { args = { mode = mode } }) + end +end + +local sonoff = { + NAME = "SONOFF TRV-ZBT", + capability_handlers = { + [Refresh.ID] = { + [Refresh.commands.refresh.NAME] = refresh, + }, + [ThermostatHeatingSetpoint.ID] = { + [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, + }, + [ThermostatMode.ID] = { + [ThermostatMode.commands.setThermostatMode.NAME] = set_mode, + [ThermostatMode.commands.off.NAME] = mode_setter(ThermostatMode.thermostatMode.off.NAME), + [ThermostatMode.commands.heat.NAME] = mode_setter(ThermostatMode.thermostatMode.heat.NAME), + [ThermostatMode.commands.auto.NAME] = mode_setter(ThermostatMode.thermostatMode.auto.NAME), + }, + }, + zigbee_handlers = { + attr = { + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_handler, + }, + [Thermostat.ID] = { + [Thermostat.attributes.LocalTemperature.ID] = local_temperature_handler, + [Thermostat.attributes.OccupiedHeatingSetpoint.ID] = heating_setpoint_handler, + [Thermostat.attributes.MinHeatSetpointLimit.ID] = setpoint_limit_handler(MIN_HEAT_SETPOINT_LIMIT_FIELD), + [Thermostat.attributes.MaxHeatSetpointLimit.ID] = setpoint_limit_handler(MAX_HEAT_SETPOINT_LIMIT_FIELD), + [Thermostat.attributes.SystemMode.ID] = system_mode_handler, + }, + [SONOFF_CLUSTER] = { + [WORK_MODE_ATTR] = work_mode_handler, + }, + }, + }, + lifecycle_handlers = { + added = added, + init = init, + doConfigure = configure, + infoChanged = info_changed, + }, + can_handle = require "sonoff.can_handle", +} + +return sonoff diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua new file mode 100644 index 0000000000..0a97dad57a --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local fingerprints = require "sonoff.tp_wgzba.fingerprints" + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sonoff.tp_wgzba") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua new file mode 100644 index 0000000000..664e907947 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "SONOFF", model = "TP-WGZBA" } +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua new file mode 100644 index 0000000000..89e21ca91e --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua @@ -0,0 +1,206 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local device_management = require "st.zigbee.device_management" +local generic_body = require "st.zigbee.generic_body" +local utils = require "st.utils" +local ZigbeeConstants = require "st.zigbee.constants" +local ZigbeeMessages = require "st.zigbee.messages" +local ZigbeeZcl = require "st.zigbee.zcl" + +local Thermostat = clusters.Thermostat +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_MFG_CODE = 0x1286 +local SONOFF_PRIVATE_CLUSTER = 0xFC11 +local TEMPORARY_MODE_COMMAND_ID = 0x11 + +local PRIVATE_ATTR_CHILD_LOCK = 0x0000 +local PRIVATE_ATTR_BT_PAIRING_BROADCAST = 0x0029 +local PRIVATE_ATTR_OPEN_WINDOW_DETECTION = 0x6000 +local PRIVATE_ATTR_FROST_PROOF_TEMPERATURE = 0x6002 +local PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD = 0x601F +local PRIVATE_ATTR_RADAR_SENSITIVITY = 0x6020 +local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB = 0x6021 +local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD = 0x6022 +local PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS = 0x6023 +local PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS = 0x6024 +local PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS = 0x6025 +local PRIVATE_ATTR_SCREEN_NIGHT_MODE = 0x6026 +local PRIVATE_ATTR_SCREEN_NIGHT_PERIOD = 0x6027 +local PRIVATE_ATTR_RELAY_OUTPUT_TYPE = 0x6028 +local PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE = 0x6032 +local PRIVATE_ATTR_OVERHEAT_PROTECTION = 0x6034 +local PRIVATE_ATTR_RADAR_ENABLED = 0x6035 + +local PRIVATE_PREFERENCES = { + childLock = { attribute = PRIVATE_ATTR_CHILD_LOCK, data_type = data_types.Boolean }, + btPairingBroadcast = { attribute = PRIVATE_ATTR_BT_PAIRING_BROADCAST, data_type = data_types.Uint8 }, + openWindowDetection = { attribute = PRIVATE_ATTR_OPEN_WINDOW_DETECTION, data_type = data_types.Boolean }, + frostProofTemperature = { attribute = PRIVATE_ATTR_FROST_PROOF_TEMPERATURE, data_type = data_types.Int16, scale = 100 }, + radarSensitivity = { attribute = PRIVATE_ATTR_RADAR_SENSITIVITY, data_type = data_types.Uint8 }, + radarDoNotDisturb = { attribute = PRIVATE_ATTR_RADAR_DO_NOT_DISTURB, data_type = data_types.Boolean }, + screenWorkingBrightness = { attribute = PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS, data_type = data_types.Uint8 }, + screenStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS, data_type = data_types.Uint8 }, + nightStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS, data_type = data_types.Uint8 }, + screenNightMode = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_MODE, data_type = data_types.Boolean }, + overheatProtectTemp = { attribute = PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE, data_type = data_types.Int16, scale = 100 }, + overheatProtection = { attribute = PRIVATE_ATTR_OVERHEAT_PROTECTION, data_type = data_types.Boolean }, + radarEnabled = { attribute = PRIVATE_ATTR_RADAR_ENABLED, data_type = data_types.Boolean }, +} + +-- Builds a manufacturer-specific attribute write for the SONOFF private cluster. +local function write_private_attribute(device, attribute_id, data_type, value) + -- The vendor cluster requires the eWeLink manufacturer code on every write. + return cluster_base.write_manufacturer_specific_attribute(device, SONOFF_PRIVATE_CLUSTER, attribute_id, SONOFF_MFG_CODE, data_type, value) +end + +-- Converts a preference value into the primitive expected by its Zigbee data type. +local function preference_value(value, preference) + -- Boolean preferences are already represented as Lua booleans by the platform. + if preference.data_type == data_types.Boolean then + return value + end + -- Boolean preferences backed by numeric attributes (e.g. Uint8 0x00/0x01) must be encoded as 0/1. + if type(value) == "boolean" then + return value and 1 or 0 + end + local numeric_value = tonumber(value) + if numeric_value == nil then + return nil + end + return preference.scale and utils.round(numeric_value * preference.scale) or numeric_value +end + +-- Restricts a clock value to the device's 30-minute schedule grid. +local function normalize_schedule_minutes(value) + -- The device only accepts values from 00:00 through 23:30 at 30-minute intervals. + local numeric_value = tonumber(value) + if numeric_value == nil then + return nil + end + return utils.clamp_value(math.floor((numeric_value + 15) / 30) * 30, 0, 1410) +end + +-- Writes a paired start/end time preference as a typed Zigbee Structure. +local function write_schedule(device, attribute_id, start_value, end_value) + -- Both schedule attributes are encoded symmetrically as two Uint16 structure members. + local start_minutes = normalize_schedule_minutes(start_value) + local end_minutes = normalize_schedule_minutes(end_value) + if start_minutes == nil or end_minutes == nil then + return + end + device:send(write_private_attribute(device, attribute_id, data_types.Structure, data_types.Structure({ data_types.Uint16(start_minutes), data_types.Uint16(end_minutes) }))) +end + +-- Sends the vendor temporary-mode command using the current preference values. +local function write_temporary_mode(device) + -- Temporary mode is a cluster command rather than an attribute write. + local action_byte = ({ exit = 0, boost = 1, timer = 2 })[device.preferences.temporaryModeAction] + if action_byte == nil then + return + end + local duration_seconds = math.floor(utils.clamp_value(tonumber(device.preferences.temporaryModeDuration) or 30, 1, 1440) * 60) + local temperature = device.preferences.temporaryModeAction == "boost" and 30 or utils.clamp_value(tonumber(device.preferences.temporaryModeTemperature) or 30, 5, 30) + local temperature_centi = utils.round(temperature * 100) + local payload = string.pack("= 40 then + value = utils.f_to_c(value) + end + device:send_to_component(command.component, Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(utils.clamp_value(value, 5, 30) * 100))) +end + +local sonoff_thermostat = { + NAME = "SONOFF TP-WGZBA Thermostat Handler", + can_handle = require "sonoff.tp_wgzba.can_handle", + lifecycle_handlers = { + added = do_refresh, + doConfigure = do_configure, + infoChanged = info_changed, + }, + capability_handlers = { + [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh }, + [ThermostatHeatingSetpoint.ID] = { [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint }, + }, +} + +return sonoff_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua index 3b535c3cf0..f1f59d13a1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -3,6 +3,7 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { + lazy_load_if_possible("sonoff"), lazy_load_if_possible("zenwithin"), lazy_load_if_possible("fidure"), lazy_load_if_possible("sinope"), @@ -16,5 +17,6 @@ local sub_drivers = { lazy_load_if_possible("resideo_korea"), lazy_load_if_possible("aqara"), lazy_load_if_possible("wallhero"), + lazy_load_if_possible("sonoff.tp_wgzba"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua new file mode 100644 index 0000000000..387befbfb4 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua @@ -0,0 +1,224 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local generic_body = require "st.zigbee.generic_body" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zb_const = require "st.zigbee.constants" + +local Basic = clusters.Basic +local Thermostat = clusters.Thermostat +local MFG_CODE = 0x1286 +local PRIVATE_CLUSTER = 0xFC11 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("thermostat-sonoff.yml"), + preferences = { + temperatureCompensation = 0, + childLock = false, + btPairingBroadcast = false, + openWindowDetection = false, + frostProofTemperature = 5, + temporaryModeAction = "exit", + temporaryModeDuration = 30, + temporaryModeTemperature = 30, + temperatureThresholdLow = -2, + temperatureThresholdHigh = 2, + radarSensitivity = "2", + radarDoNotDisturb = false, + radarDoNotDisturbStart = 0, + radarDoNotDisturbEnd = 0, + screenWorkingBrightness = 5, + screenStandbyBrightness = 0, + nightStandbyBrightness = 0, + screenNightMode = false, + screenNightStart = 0, + screenNightEnd = 0, + heatRelayNormClosed = false, + boilerRelayNormClosed = false, + overheatProtectTemp = 33.5, + overheatProtection = false, + radarEnabled = false, + }, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TP-WGZBA", + server_clusters = { Basic.ID, Thermostat.ID, PRIVATE_CLUSTER }, + }, + }, +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + -- Register the device before every test so the SONOFF sub-driver is selected. + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +-- Builds the cluster-specific temporary-mode command expected from a preference update. +local function build_temporary_mode_command(payload) + -- The command uses the same manufacturer cluster endpoint as private attribute writes. + local header = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(0x11) }) + header.frame_ctrl:set_cluster_specific() + return messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, mock_device:get_short_address(), + mock_device:get_endpoint(PRIVATE_CLUSTER), zb_const.HA_PROFILE_ID, PRIVATE_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ zcl_header = header, zcl_body = generic_body.GenericBody(payload) }), + }) +end + +test.register_coroutine_test( + "SONOFF sub-driver should match only TP-WGZBA", + function() + -- The flattened handler owns the fingerprint without a nested TP-WGZBA dispatcher. + local can_handle = require "sonoff.tp_wgzba.can_handle" + assert(can_handle({}, nil, mock_device)) + local rejected = can_handle({}, nil, { + get_manufacturer = function() return "SONOFF" end, + get_model = function() return "OTHER" end, + }) + assert(not rejected) + end +) + +test.register_message_test( + "Refresh should read only standard capability attributes", + { + { channel = "capability", direction = "receive", message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:read(mock_device) } }, + } +) + +test.register_coroutine_test( + "Configure should bind and enable standard thermostat reporting", + function() + -- All visible state is now maintained by standard reporting instead of custom capabilities. + test.socket.zigbee:__set_channel_ordering("relaxed") + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ "off", "auto", "heat" }, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Thermostat.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperature:configure_reporting(mock_device, 10, 300, 10) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 10, 300, 50) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 300) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.ThermostatRunningState:configure_reporting(mock_device, 10, 300, 1) }) + end +) + +test.register_coroutine_test( + "Changing preferences should write typed private attributes and structures", + function() + -- Settings are sent through infoChanged rather than custom capability commands. + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { openWindowDetection = true, temperatureThresholdLow = -1.5, temperatureThresholdHigh = 1.5 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x6000, MFG_CODE, data_types.Boolean, true) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x601F, MFG_CODE, data_types.Structure, + data_types.Structure({ data_types.Int16(-150), data_types.Int16(150) })) + }) + end +) + +test.register_coroutine_test( + "Bluetooth pairing broadcast preference should write the Uint8 private attribute", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { btPairingBroadcast = true }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x0029, MFG_CODE, data_types.Uint8, 1) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { btPairingBroadcast = false }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x0029, MFG_CODE, data_types.Uint8, 0) }) + end +) + +test.register_coroutine_test( + "Temperature compensation preference should write LocalTemperatureCalibration in 0.1 degree units", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = -10 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, -100) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = 10 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, 100) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = 2.3 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, 23) }) + end +) + +test.register_coroutine_test( + "Temporary-mode preference should send the vendor cluster command", + function() + -- Timer mode carries the selected duration and temperature as little-endian values. + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temporaryModeAction = "timer", temporaryModeDuration = 45, temporaryModeTemperature = 28.5 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, build_temporary_mode_command("\x02\x8C\x0A\x00\x00\x22\x0B") }) + end +) + +test.register_coroutine_test( + "Setting thermostat mode should use the base driver write and readback", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.thermostatMode.ID, component = "main", command = "auto", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.AUTO) }) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) }) + end +) + +test.register_message_test( + "Decoded standard reports should publish standard thermostat events", + { + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2350) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 23.5, unit = "C" })) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2150) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = "C" })) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.AUTO) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()) }, + } +) + +-- TP-WGZBA firmware sets ThermostatRunningState to 0x0001 (Heat State On) while the relay is on and 0x0000 otherwise. +test.register_message_test( + "Running state reports should map the heat bit to heating and zero to idle", + { + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0001) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0000) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.idle()) }, + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua new file mode 100644 index 0000000000..150ad7bfe2 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -0,0 +1,429 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local device_management = require "st.zigbee.device_management" +local generic_body = require "st.zigbee.generic_body" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local zb_const = require "st.zigbee.constants" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +local WORK_MODE_ATTR = 0x0018 +local BUTTON_COMMAND = 0x10 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("thermostat-sonoff-trv-zbt.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TRV-ZBT", + server_clusters = { 0x0001, 0x0201, 0x0402, SONOFF_CLUSTER }, + }, + }, +}) + +zigbee_test_utils.prepare_zigbee_env_info() +test.set_test_init_function(function() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "auto" }, { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 5, maximum = 30, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end +end) + +local function bluetooth_pairing_command(device) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(BUTTON_COMMAND), + }) + zcl_header.frame_ctrl:set_cluster_specific() + + return messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + }) +end + +test.register_message_test( + "SONOFF TRV-ZBT reports local temperature", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2150) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = "C" })), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT reports setpoint and battery percentage", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2250) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.5, unit = "C" })), + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 146) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(73)), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT maps standard and proprietary modes", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.HEAT) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()), + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_CLUSTER, { + { WORK_MODE_ATTR, data_types.Uint8.ID, 0x04 }, + }), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()), + }, + }, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes setpoints in Celsius and Fahrenheit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 21.5 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2150), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = "C" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 68 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2000), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 20.0, unit = "C" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 64 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1750), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 17.5, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT updates setpoint range from reported limits", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 1200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 30, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 22.0, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 25 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.0, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT starts Bluetooth pairing when the preference is enabled", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = true }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, bluetooth_pairing_command(mock_device) }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = false }, + })) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes the child lock preference", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 1 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(true, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 0 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(false, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes thermostat mode and refreshes required attributes", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {}, + named_args = { mode = "auto" }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.AUTO), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT configures reporting and cluster bindings", + function() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, Thermostat.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, PowerConfiguration.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, TemperatureMeasurement.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperature:configure_reporting(mock_device, 10, 300, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 1, 600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:configure_reporting(mock_device, 1, 600, 1), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 600, 100), + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:read(mock_device), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:read(mock_device), + }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-valve/fingerprints.yml b/drivers/SmartThings/zigbee-valve/fingerprints.yml index 6c9678452d..31ea03cd3e 100644 --- a/drivers/SmartThings/zigbee-valve/fingerprints.yml +++ b/drivers/SmartThings/zigbee-valve/fingerprints.yml @@ -39,5 +39,23 @@ zigbeeManufacturer : manufacturer: Compacta model: ZBVC1(1023A) deviceProfileName: valve-battery-powerSource - - + - id: SONOFF/SWV-ZFE + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZFE + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZFU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZFU + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZNE + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNE + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZNU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNU + deviceProfileName: valve-battery diff --git a/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml b/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml new file mode 100644 index 0000000000..9aa59a8482 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml @@ -0,0 +1,14 @@ +name: valve-battery +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories : + - name: WaterValve diff --git a/drivers/SmartThings/zigbee-valve/src/init.lua b/drivers/SmartThings/zigbee-valve/src/init.lua index 717012e2bb..b3e250cbd3 100644 --- a/drivers/SmartThings/zigbee-valve/src/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/init.lua @@ -1,7 +1,6 @@ -- Copyright 2022 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua new file mode 100644 index 0000000000..90e56b541c --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "SONOFF", model = "SWV-ZFU" }, + { mfr = "SONOFF", model = "SWV-ZFE" }, + { mfr = "SONOFF", model = "SWV-ZNU" }, + { mfr = "SONOFF", model = "SWV-ZNE" }, +} + +local function sonoff_can_handle(opts, driver, device, ...) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require "sonoff" + end + end + return false +end + +return sonoff_can_handle diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua new file mode 100644 index 0000000000..8bc14a84ef --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua @@ -0,0 +1,53 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local OnOff = zcl_clusters.OnOff +local PowerConfiguration = zcl_clusters.PowerConfiguration +local utils = require "st.utils" + +--- OnOff Property Reporting Handler → Valve Capability Point Event +--- SONOFF reports valve state through the OnOff cluster, so keep an explicit mapping +--- from OnOff reports to valve events while using the parent driver's default commands. +--- @param driver table Driver instance +--- @param device table Device instance +--- @param value table Zigbee attribute value +local function onoff_attr_handler(driver, device, value) + local is_on = value.value ~= false and value.value ~= 0 + if is_on then + device:emit_event(capabilities.valve.valve.open()) + else + device:emit_event(capabilities.valve.valve.closed()) + end +end + +--- Battery Percentage Attribute Handler +--- Zigbee BatteryPercentageRemaining range 0-200 (0%-100%), needs to be divided by 2 +--- @param driver table Driver instance +--- @param device table Device instance +--- @param value table Zigbee attribute value +local function battery_percentage_handler(driver, device, value) + local raw = value.value + local percent = utils.round(raw / 2) + device:emit_event(capabilities.battery.battery(percent)) +end + +local sonoff_valve_handler = { + NAME = "SONOFF Water Valve Handler", + zigbee_handlers = { + attr = { + -- OnOff property reporting → valve event (to compensate for the defect of the parent driver's default mapping being skipped) + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = onoff_attr_handler + }, + -- Battery percentage attribute reporting → battery event + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_percentage_handler + } + } + }, + can_handle = require "sonoff.can_handle", +} + +return sonoff_valve_handler diff --git a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua index 258579c7fb..649c5fc898 100644 --- a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua @@ -5,5 +5,6 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { lazy_load_if_possible("sinope"), lazy_load_if_possible("ezex"), + lazy_load_if_possible("sonoff"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua new file mode 100644 index 0000000000..93fd9ad153 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua @@ -0,0 +1,251 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("valve-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SWV-ZFE", + server_clusters = {0x0000, 0x0001, 0x0006, 0x0404, 0xFC11} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "OnOff(on) should set valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +local uint8_onoff_off_report = OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) +uint8_onoff_off_report.body.zcl_body.attr_records[1].data_type.value = data_types.Uint8.ID + +test.register_message_test( + "OnOff(off) report with Uint8 data type should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, uint8_onoff_off_report } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + } +) + +test.register_coroutine_test( + "Init lifecycle should use default battery percentage reporting configuration", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Capability(valve) command(open) should send OnOff.On", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device) } + } + } +) + +test.register_message_test( + "Capability(valve) command(close) should send OnOff.Off", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device) } + } + } +) + +test.register_coroutine_test( + "doConfigure lifecycle should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_message_test( + "Device added event should refresh device states", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_device.id, "added" }, + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml index e7007de4f7..9745b5c652 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml @@ -108,7 +108,7 @@ zigbeeManufacturer: deviceLabel: Sengled Water Leak Sensor manufacturer: sengled model: E1L-G7K - deviceProfileName: water-battery + deviceProfileName: water-battery - id: NEO/NAS_WS11 deviceLabel: NEO Water Leak Sensor manufacturer: NEO diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua index f581f457df..484ba90c7c 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua @@ -128,7 +128,8 @@ test.register_coroutine_test( ) end end - end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} ) test.register_coroutine_test( diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua index 43ea5cdbe3..7ab752246a 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua @@ -128,7 +128,8 @@ test.register_coroutine_test( ) end end - end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} ) test.register_coroutine_test( diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua index 465e3add9f..c1fedbf717 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua @@ -128,7 +128,8 @@ test.register_coroutine_test( ) end end - end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} ) test.register_coroutine_test( diff --git a/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml b/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml index e15aee1a63..006334ea60 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml +++ b/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml @@ -41,4 +41,4 @@ zwaveGeneric: commandClasses: supported: - 0x98 - deviceProfileName: base-garage-door + deviceProfileName: base-garage-door diff --git a/drivers/SmartThings/zwave-switch/fingerprints.yml b/drivers/SmartThings/zwave-switch/fingerprints.yml index f606edf119..77aba90d2c 100644 --- a/drivers/SmartThings/zwave-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-switch/fingerprints.yml @@ -885,6 +885,12 @@ zwaveManufacturer: productId: 0xFF97 productType: 0xFF01 deviceProfileName: switch-binary + - id: "0312/FF00/FF0C" + deviceLabel: Z-Wave Smart Plug + manufacturerId: 0x0312 + productId: 0xFF0C + productType: 0xFF00 + deviceProfileName: smartplug-binary - id: Evolve/RelaySwitch deviceLabel: Evolve Switch manufacturerId: 0x0113 @@ -965,7 +971,7 @@ zwaveManufacturer: manufacturerId: 0x0460 productId: 0x0083 productType: 0x0002 - deviceProfileName: switch-binary + deviceProfileName: switch-binary - id: 1120/2/132 deviceLabel: Wave 1PM manufacturerId: 0x0460 diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua index ef2e4997b2..9b5328a748 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua @@ -36,12 +36,15 @@ local mock_parent = test.mock_device.build_test_zwave_device({ zwave_manufacturer_id = 0x0086, zwave_product_type = 0x0103, zwave_product_id = 0x008C, - child_ids = { - "abcdefghijklmnopq", - "12345678910111213" - } }) +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("switch-binary.yml"), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + + local mock_parent_no_data = test.mock_device.build_test_zwave_device({ label = "Aeotec Switch 1", profile = t_utils.get_profile_definition("switch-binary.yml"), @@ -53,6 +56,7 @@ local mock_parent_no_data = test.mock_device.build_test_zwave_device({ local function test_init() test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child) test.mock_device.add_test_device(mock_parent_no_data) end diff --git a/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml b/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml index b93e7f071a..b62288b90e 100644 --- a/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml @@ -1,4 +1,3 @@ -zwaveManufacturer: zwaveGeneric: - id: "GenericSwitch/1" deviceLabel: Switch diff --git a/drivers/SmartThings/zwave-window-treatment/fingerprints.yml b/drivers/SmartThings/zwave-window-treatment/fingerprints.yml index 0eed587497..13c648f3e7 100644 --- a/drivers/SmartThings/zwave-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zwave-window-treatment/fingerprints.yml @@ -62,7 +62,7 @@ zwaveManufacturer: manufacturerId: 0x0115 productId: 0x0010 productType: 0x0211 - deviceProfileName: window-treatment-preset-reverse + deviceProfileName: window-treatment-preset-reverse zwaveGeneric: - id: window-treatment/generic/1 deviceLabel: Z-Wave Window Treatment From 05fa70276852d92d89b04bdf9def7ab24df75ba6 Mon Sep 17 00:00:00 2001 From: Hunsup Jung Date: Fri, 23 Oct 2026 10:48:11 +0900 Subject: [PATCH 5/5] Change get/set_field to get/set_field_for_endpoint Signed-off-by: Hunsup Jung --- .../matter-window-covering/src/init.lua | 84 +++++++++++-------- .../test_matter_multi_window_covering.lua | 4 +- .../src/test/test_matter_window_covering.lua | 4 +- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index e73218da29..c0aa9340fa 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -6,6 +6,7 @@ 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" @@ -41,6 +42,22 @@ 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)) @@ -102,22 +119,22 @@ 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, ep) - if not device:get_field(TARGET_LIFT_PERCENT.STATE) and - not device:get_field(TARGET_TILT_PERCENT.STATE) then + 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 = ep and find_child(device, ep) or device + local status_device = find_child(device, ep) or device status_device:emit_event(get_idle_window_shade_status(status_device)) end end @@ -126,18 +143,18 @@ end --- @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, nil) + 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) @@ -266,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 )) @@ -288,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 @@ -302,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 @@ -322,13 +339,14 @@ 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", @@ -336,9 +354,9 @@ local function handle_step_shade_level(driver, device, cmd) 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, @@ -351,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 )) @@ -362,9 +380,8 @@ 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) + 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 @@ -373,9 +390,8 @@ local function current_position_tilt_percent_100ths_handler(driver, device, ib, 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) + 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 @@ -389,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) + 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 @@ -406,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) + 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 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 index fb10d6b13b..b482fa4c93 100644 --- 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 @@ -581,7 +581,7 @@ test.register_coroutine_test( } ) test.wait_for_events() - assert(mock_child_device[i]:get_field("__target_lift_percent") == 50) + assert(mock_parent_device:get_field(string.format("__target_lift_percent_%d", CHILD_DEVICE_MAP[i].ep)) == 50) end end, { @@ -602,7 +602,7 @@ test.register_coroutine_test( } ) test.wait_for_events() - assert(mock_child_device[i]:get_field("__target_tilt_percent") == 50) + assert(mock_parent_device:get_field(string.format("__target_tilt_percent_%d", CHILD_DEVICE_MAP[i].ep)) == 50) end end, { diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua index 7199e3704d..f2e2f3530e 100644 --- a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua @@ -465,7 +465,7 @@ test.register_coroutine_test( } ) test.wait_for_events() - assert(mock_device:get_field("__target_lift_percent") == 50) + assert(mock_device:get_field("__target_lift_percent_10") == 50) end, { min_api_version = 17 @@ -484,7 +484,7 @@ test.register_coroutine_test( } ) test.wait_for_events() - assert(mock_device:get_field("__target_tilt_percent") == 50) + assert(mock_device:get_field("__target_tilt_percent_10") == 50) end, { min_api_version = 17