From dd8ec73b067b1c246d5e47cc1784cdfd8c0ab673 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 28 May 2026 09:58:52 -0500 Subject: [PATCH 1/4] WWSTCERT-11891: Linkind Smart Ceiling Light (#3004) --- drivers/SmartThings/matter-switch/fingerprints.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index ece0bcaf72..c54d5492d2 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -185,7 +185,7 @@ matterManufacturer: deviceLabel: Linkind Smart Ceiling Light vendorId: 0x1396 productId: 0x10B1 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5014/4164" deviceLabel: Linkind Smart Light Bulb vendorId: 0x1396 From dd871eddd20bb9ce4a92fcdc36f429823536c53b Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 28 May 2026 10:50:28 -0500 Subject: [PATCH 2/4] WWSTCERT-11941 OSRAM PLANON SLIM (#3005) --- drivers/SmartThings/matter-switch/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index c54d5492d2..592d62b436 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -2070,6 +2070,11 @@ matterManufacturer: vendorId: 0x1189 productId: 0x0892 deviceProfileName: switch-binary + - id: "4489/4097" + deviceLabel: OSRAM PLANON SLIM + vendorId: 0x1189 + productId: 0x1001 + deviceProfileName: light-color-level #Shelly - id: "5264/1" deviceLabel: Shelly Plug S MTR Gen3 From 2f28b5a60fa03f35c640bd85133ebf5f6f14f078 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 29 May 2026 11:15:15 -0500 Subject: [PATCH 3/4] Matter Camera: Prevent duplicate zones in triggeredZones report --- .../camera/camera_handlers/event_handlers.lua | 34 +++++++++++---- .../src/test/test_matter_camera.lua | 41 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua index 02b63bb37f..8549b02d18 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua @@ -3,14 +3,23 @@ local camera_fields = require "sub_drivers.camera.camera_utils.fields" local capabilities = require "st.capabilities" -local switch_utils = require "switch_utils.utils" local CameraEventHandlers = {} +local function has_triggered_zone(triggered_zones, zone_id) + for _, zone in ipairs(triggered_zones or {}) do + if zone.zoneId == zone_id then + return true + end + end + return false +end + function CameraEventHandlers.zone_triggered_handler(driver, device, ib, response) local triggered_zones = device:get_field(camera_fields.TRIGGERED_ZONES) or {} - if not switch_utils.tbl_contains(triggered_zones, ib.data.elements.zone.value) then - table.insert(triggered_zones, {zoneId = ib.data.elements.zone.value}) + local zone_id = ib.data.elements.zone.value + if not has_triggered_zone(triggered_zones, zone_id) then + table.insert(triggered_zones, { zoneId = zone_id }) device:set_field(camera_fields.TRIGGERED_ZONES, triggered_zones) device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(triggered_zones)) end @@ -18,13 +27,22 @@ end function CameraEventHandlers.zone_stopped_handler(driver, device, ib, response) local triggered_zones = device:get_field(camera_fields.TRIGGERED_ZONES) or {} - for i, v in pairs(triggered_zones) do - if v.zoneId == ib.data.elements.zone.value then - table.remove(triggered_zones, i) - device:set_field(camera_fields.TRIGGERED_ZONES, triggered_zones) - device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(triggered_zones)) + local zone_id = ib.data.elements.zone.value + local updated_triggered_zones = {} + local zone_removed = false + + for _, zone in ipairs(triggered_zones) do + if zone.zoneId ~= zone_id then + table.insert(updated_triggered_zones, zone) + else + zone_removed = true end end + + if zone_removed then + device:set_field(camera_fields.TRIGGERED_ZONES, updated_triggered_zones) + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(updated_triggered_zones)) + end end return CameraEventHandlers 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 9198dc253f..d443bdc573 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -1471,6 +1471,47 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Duplicate ZoneTriggered events should not duplicate triggeredZones state", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}})) + ) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneStopped:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventStoppedReasonEnum.ACTION_STOPPED + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({})) + ) + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Button events should generate appropriate events", function() From 917bdad8335bb48f661869aa48915e924185014f Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:29:12 -0500 Subject: [PATCH 4/4] Zigbee Switch: Add Stateless Step ZLL Refresh (#3006) --- .../src/stateless_handlers/init.lua | 35 ++++++++- .../zigbee-switch/src/switch_utils.lua | 5 -- .../src/test/test_zll_color_temp_bulb.lua | 76 +++++++++++++++++++ .../src/test/test_zll_dimmer_bulb.lua | 27 +++++++ 4 files changed, 136 insertions(+), 7 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua index 82be544f4a..07adfe0e84 100644 --- a/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua @@ -3,6 +3,7 @@ local capabilities = require "st.capabilities" local st_utils = require "st.utils" +local constants = require "st.zigbee.constants" local clusters = require "st.zigbee.zcl.clusters" local switch_utils = require "switch_utils" @@ -11,19 +12,47 @@ local DEFAULT_MIRED_MAX_BOUND = 370 -- 2700 Kelvin (Mireds are the inverse of Ke local DEFAULT_MIRED_MIN_BOUND = 154 -- 6500 Kelvin (Mireds are the inverse of Kelvin) -- Transition Time: The time that shall be taken to perform the step change, in units of 1/10ths of a second. +-- Specific fields can store custom transition times for stateless capabilities +local SWITCH_LEVEL_STEP_TRANSITION_TIME = "__switch_level_step_transition_time" +local COLOR_TEMP_STEP_TRANSITION_TIME = "__color_temp_step_transition_time" local DEFAULT_STEP_TRANSITION_TIME = 3 -- 0.3 seconds -- Options Mask & Override: Indicates which options are being overridden by the Level/ColorControl cluster commands local OPTIONS_MASK = 0x01 -- default: The `ExecuteIfOff` option is overriden local IGNORE_COMMAND_IF_OFF = 0x00 -- default: the command will not be executed if the device is off +-- Indicates whether a delayed refresh for ZLL devices is in progress, to prevent multiple refreshes in a quick series of step commands +local IS_REFRESH_CALLBACK_QUEUED = "__is_refresh_callback_queued" +-- Stores a timer object, which is required to cancel a timer early +local REFRESH_CALLBACK_TIMER = "__refresh_callback_timer" + +-- Note: These commands' native handlers do not match the driver's ZLL behavior 1-1. +-- Instead, they will queue a 2s timer and read refresh for each command, in all cases. +local function trigger_delayed_refresh_if_zll(device) + if device:get_profile_id() ~= constants.ZLL_PROFILE_ID then + return + end + + -- If a refresh callback is already queued, cancel it and create a new one with the updated time + if device:get_field(IS_REFRESH_CALLBACK_QUEUED) then + device.thread:cancel_timer(device:get_field(REFRESH_CALLBACK_TIMER)) + end + local delay_s = 2 + local new_timer = device.thread:call_with_delay(delay_s, function() + device:refresh() + device:set_field(IS_REFRESH_CALLBACK_QUEUED, nil) + end) + device:set_field(REFRESH_CALLBACK_TIMER, new_timer) + device:set_field(IS_REFRESH_CALLBACK_QUEUED, true) +end + local function step_color_temperature_by_percent_handler(driver, device, cmd) if type(device.register_native_capability_cmd_handler) == "function" then device:register_native_capability_cmd_handler(cmd.capability, cmd.command) end local step_percent_change = cmd.args and cmd.args.stepSize or 0 if step_percent_change == 0 then return end - local transition_time = device:get_field(switch_utils.COLOR_TEMP_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME + local transition_time = device:get_field(COLOR_TEMP_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME -- Reminder, stepSize > 0 == Kelvin UP == Mireds DOWN. stepSize < 0 == Kelvin DOWN == Mireds UP local step_mode = (step_percent_change > 0) and clusters.ColorControl.types.CcStepMode.DOWN or clusters.ColorControl.types.CcStepMode.UP -- note: the field containing the color temp bounds will be associated with a parent device @@ -37,6 +66,7 @@ local function step_color_temperature_by_percent_handler(driver, device, cmd) end local step_size_in_mireds = st_utils.round((max_mireds - min_mireds) * (math.abs(step_percent_change)/100.0)) device:send(clusters.ColorControl.server.commands.StepColorTemperature(device, step_mode, step_size_in_mireds, transition_time, min_mireds, max_mireds, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF)) + trigger_delayed_refresh_if_zll(device) end local function step_level_handler(driver, device, cmd) @@ -45,9 +75,10 @@ local function step_level_handler(driver, device, cmd) end local step_size = st_utils.round((cmd.args and cmd.args.stepSize or 0)/100.0 * 254) if step_size == 0 then return end - local transition_time = device:get_field(switch_utils.SWITCH_LEVEL_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME + local transition_time = device:get_field(SWITCH_LEVEL_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME local step_mode = (step_size > 0) and clusters.Level.types.MoveStepMode.UP or clusters.Level.types.MoveStepMode.DOWN device:send(clusters.Level.server.commands.Step(device, step_mode, math.abs(step_size), transition_time, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF)) + trigger_delayed_refresh_if_zll(device) end local stateless_handlers = { diff --git a/drivers/SmartThings/zigbee-switch/src/switch_utils.lua b/drivers/SmartThings/zigbee-switch/src/switch_utils.lua index e974d52474..d30ada0588 100644 --- a/drivers/SmartThings/zigbee-switch/src/switch_utils.lua +++ b/drivers/SmartThings/zigbee-switch/src/switch_utils.lua @@ -8,11 +8,6 @@ local switch_utils = {} switch_utils.MIRED_MAX_BOUND = "__max_mired_bound" switch_utils.MIRED_MIN_BOUND = "__min_mired_bound" --- Fields to store the transition times for the stateless capabilities, --- in case native handler implementations need to be re-configured in the future -switch_utils.SWITCH_LEVEL_STEP_TRANSITION_TIME = "__switch_level_step_transition_time" -switch_utils.COLOR_TEMP_STEP_TRANSITION_TIME = "__color_temp_step_transition_time" - switch_utils.MIREDS_CONVERSION_CONSTANT = 1000000 switch_utils.convert_mired_to_kelvin = function(mired) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua index a02e3978f5..f6842b077f 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua @@ -11,6 +11,12 @@ local OnOff = clusters.OnOff local Level = clusters.Level local ColorControl = clusters.ColorControl +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 +local DEFAULT_MIRED_MIN = 154 +local DEFAULT_MIRED_MAX = 370 + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("color-temp-bulb.yml"), fingerprinted_endpoint_id = 0x01, @@ -279,4 +285,74 @@ test.register_coroutine_test( max_api_version = 19 } ) + +test.register_coroutine_test( + "StatelessColorTemperatureStep stepColorTemperatureByPercent should trigger delayed refresh on ZLL device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } }) + mock_device:expect_native_cmd_handler_registration("statelessColorTemperatureStep", "stepColorTemperatureByPercent") + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 43, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Rapid StatelessSwitchLevelStep stepLevel commands should cancel and recreate delayed refresh timer", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + test.wait_for_events() + test.mock_time.advance_time(1) + + -- Second step command: cancels timer #1, creates timer #2 + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + test.wait_for_events() + test.mock_time.advance_time(1) + -- now, nothing should happen since the first timer was cancelled and the second timer has not yet reached its 2s delay + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua index 19d91d697a..ec10a66a62 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua @@ -10,6 +10,10 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff local Level = clusters.Level +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("on-off-level.yml"), fingerprinted_endpoint_id = 0x01, @@ -178,4 +182,27 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "StatelessSwitchLevelStep stepLevel should trigger delayed refresh on ZLL device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + test.run_registered_tests()