From 9c82a6baa62d32ab3c1d8e0923074b32945db68a Mon Sep 17 00:00:00 2001 From: Zemismart <275927643+zemismart-dev@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:29:17 +0800 Subject: [PATCH 1/2] Add Zemismart ZM25Z window treatment support Use the standard profile without battery and confirm position feedback before allowing motion after startup or inconsistent reports. Preserve step targets and invalidate stale callbacks safely across device removal. --- .../zigbee-window-treatment/fingerprints.yml | 6 + .../zigbee-window-treatment/src/init.lua | 1 + .../src/sub_drivers.lua | 1 + .../src/test/test_zigbee_window_treatment.lua | 11 + ...test_zigbee_window_treatment_zemismart.lua | 360 +++++++++ ...indow_treatment_zemismart_confirmation.lua | 728 ++++++++++++++++++ .../test/zemismart_feedback_test_utils.lua | 139 ++++ .../src/window_shade_utils.lua | 24 +- .../src/zemismart/can_handle.lua | 14 + .../src/zemismart/init.lua | 133 ++++ .../src/zemismart/position_confirmation.lua | 333 ++++++++ 11 files changed, 1749 insertions(+), 1 deletion(-) create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/zemismart_feedback_test_utils.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/zemismart/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/zemismart/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/zemismart/position_confirmation.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml index 021d59d2d4..fc94b5cf02 100644 --- a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml @@ -154,6 +154,12 @@ zigbeeManufacturer: model: SOMBRA/Z-M deviceProfileName: window-treatment-powerSource + - id: "_TZE200_cirjrpxe/TS0301" + deviceLabel: Zemismart ZM25Z Roller Shade Motor + manufacturer: _TZE200_cirjrpxe + model: TS0301 + deviceProfileName: window-treatment-profile + zigbeeGeneric: - id: "genericShade" deviceLabel: Zigbee Window Treatment diff --git a/drivers/SmartThings/zigbee-window-treatment/src/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/init.lua index b58c20a5be..d31cce8086 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/init.lua @@ -34,6 +34,7 @@ local zigbee_window_treatment_driver_template = { capabilities.windowShadePreset, capabilities.windowShadeLevel, capabilities.statelessWindowShadeLevelStep, + capabilities.refresh, capabilities.powerSource, capabilities.battery }, diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua index 3353c87fd4..dda7e513fe 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -3,6 +3,7 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { + lazy_load_if_possible("zemismart"), lazy_load_if_possible("vimar"), lazy_load_if_possible("aqara"), lazy_load_if_possible("feibit"), diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua index 30bc6bfa5c..f94c2bca8e 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua @@ -26,6 +26,17 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Driver switched accepts the window treatment profile with refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "State transnsition from opening to partially open", function() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua new file mode 100644 index 0000000000..84857fd8ef --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua @@ -0,0 +1,360 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local z_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local can_handle = require "zemismart.can_handle" + +local function build_device(profile) + return test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition(profile), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE200_cirjrpxe", + model = "TS0301", + server_clusters = { 0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x0102, 0xEF00 }, + client_clusters = { 0x0019 }, + }, + }, + }) +end + +local mock_device = build_device("window-treatment-profile.yml") +local battery_device = build_device("window-treatment-battery.yml") + +local function expect_event(device, event) + test.socket.capability:__expect_send(device:generate_test_message("main", event)) +end + +local function initialize_device(device) + test.mock_device.add_test_device(device) + expect_event(device, capabilities.windowShadePreset.supportedCommands( + { "presetPosition", "setPresetPosition" }, { visibility = { displayed = false } })) + expect_event(device, capabilities.windowShadePreset.position( + 50, { visibility = { displayed = false } })) + if device == mock_device then + expect_event(device, capabilities.windowShade.windowShade.unknown()) + test.socket.zigbee:__expect_send({ device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(device) }) + end +end + +local function command(capability, name, args) + test.socket.capability:__queue_receive({ mock_device.id, { + capability = capability, component = "main", command = name, args = args or {}, + } }) +end + +local function expect_position_read() + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) +end + +local sequence = 0 +local function read_response(level) + sequence = (sequence + 1) % 256 + local message = clusters.WindowCovering.attributes.CurrentPositionLiftPercentage: + build_test_read_attr_response(mock_device, level) + message.body.zcl_header.seqno.value = sequence + test.socket.zigbee:__queue_receive({ mock_device.id, message }) +end + +local function confirm_position(level) + assert(mock_device:get_field("_zemismartMotionContext") == nil) + if mock_device:get_field("_zemismartPositionCheck") == nil then + command("refresh", "refresh") + expect_position_read() + test.wait_for_events() + end + local was_trusted = (require "zemismart.position_confirmation").position_is_known(mock_device) + local previous = mock_device:get_latest_state("main", "windowShadeLevel", "shadeLevel") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + read_response(level) + if was_trusted and previous ~= level then + expect_event(mock_device, capabilities.windowShade.windowShade.unknown()) + end + test.wait_for_events() + test.mock_time.advance_time(2) + expect_position_read() + test.wait_for_events() + read_response(level) + expect_event(mock_device, capabilities.windowShadeLevel.shadeLevel(level)) + expect_event(mock_device, level == 0 and capabilities.windowShade.windowShade.closed() + or level == 100 and capabilities.windowShade.windowShade.open() + or capabilities.windowShade.windowShade.partially_open()) + test.wait_for_events() + assert((require "zemismart.position_confirmation").position_is_known(mock_device)) +end + +local function report_level(level) + local motion = mock_device:get_field("_zemismartMotionContext") + if not motion then confirm_position(level); return end + test.socket.zigbee:__queue_receive({ mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report( + mock_device, level) }) + if level == motion.target then + expect_event(mock_device, capabilities.windowShade.windowShade.unknown()) + expect_position_read() + test.wait_for_events() + confirm_position(level) + else + expect_event(mock_device, capabilities.windowShadeLevel.shadeLevel(level)) + expect_event(mock_device, level > motion.last and capabilities.windowShade.windowShade.opening() + or capabilities.windowShade.windowShade.closing()) + test.wait_for_events() + end +end + +local function step_and_expect(step, target) + local new_check = mock_device:get_field("_zemismartPositionCheck") == nil + command("statelessWindowShadeLevelStep", "stepShadeLevel", { step }) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, target) }) + if new_check then expect_position_read() end + test.wait_for_events() +end + +z_utils.prepare_zigbee_env_info() +test.set_test_init_function(function() initialize_device(mock_device) end) + +test.register_coroutine_test("Battery suppression is restricted to the non-battery target", function() + assert(can_handle(nil, nil, mock_device)) + assert(not can_handle(nil, nil, { + get_manufacturer = function() return "_TZE200_cirjrpxe" end, + get_model = function() return "TS0301" end, + supports_capability_by_id = function(_, id) return id == capabilities.battery.ID end, + })) + assert(not can_handle(nil, nil, { + get_manufacturer = function() return "different_manufacturer" end, + get_model = function() return "TS0301" end, + })) + assert(not can_handle(nil, nil, { + get_manufacturer = function() return "_TZE200_cirjrpxe" end, + get_model = function() return "different_model" end, + })) +end) + +test.register_coroutine_test("Target driver switch accepts the official non-battery profile", function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end) + +test.register_coroutine_test("Unsolicited battery reports do not attempt unsupported events", function() + local original_emit = mock_device.emit_event_for_endpoint + mock_device.emit_event_for_endpoint = function(self, endpoint, event) + assert(event.capability.ID ~= capabilities.battery.ID, "Unexpected battery event attempt") + return original_emit(self, endpoint, event) + end + for _, attribute in ipairs({ clusters.PowerConfiguration.attributes.BatteryVoltage, + clusters.PowerConfiguration.attributes.BatteryPercentageRemaining }) do + test.socket.zigbee:__queue_receive({ mock_device.id, + attribute:build_test_attr_report(mock_device, 100) }) + test.wait_for_events() + end + assert(mock_device:get_latest_state("main", capabilities.battery.ID, "battery") == nil) + mock_device.emit_event_for_endpoint = original_emit +end) + +test.register_coroutine_test("Configuration and refresh read position without battery traffic", function() + confirm_position(50) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_position_read() + test.socket.zigbee:__expect_send({ mock_device.id, + z_utils.build_bind_request(mock_device, z_utils.mock_hub_eui, clusters.WindowCovering.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting( + mock_device, 0, 600, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + command("refresh", "refresh") -- Coalesces with the configuration read already in flight. + test.wait_for_events() + confirm_position(50) + command("refresh", "refresh") + expect_position_read() +end) + +test.register_coroutine_test("Target open close and pause retain standard commands", function() + confirm_position(50) + for i, pair in ipairs({ { "open", "UpOrOpen" }, { "close", "DownOrClose" }, + { "pause", "Stop" } }) do + command("windowShade", pair[1]) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands[pair[2]](mock_device) }) + if i == 1 or i == 3 then expect_position_read() end + if i == 3 then expect_event(mock_device, capabilities.windowShade.windowShade.unknown()) end + test.wait_for_events() + end + assert(mock_device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 50) +end) + +test.register_coroutine_test("Target level control and actual position reports are unchanged", function() + confirm_position(43) + for _, level in ipairs({ 25, 75, 50, 0, 100 }) do + local previous = mock_device:get_latest_state("main", "windowShadeLevel", "shadeLevel") + command("windowShadeLevel", "setShadeLevel", { level }) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, level) }) + expect_position_read() + test.wait_for_events() + assert(mock_device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == previous) + report_level(math.floor((previous + level) / 2)) + report_level(level) + end +end) + +test.register_coroutine_test("Target preset and relative step retain standard commands", function() + confirm_position(50) + command("windowShadePreset", "setPresetPosition", { 40 }) + expect_event(mock_device, capabilities.windowShadePreset.position(40)) + test.wait_for_events() + command("windowShadePreset", "presetPosition") + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 40) }) + expect_position_read() + test.wait_for_events() + report_level(45) + report_level(40) + step_and_expect(10, 50) +end) + +test.register_coroutine_test("Existing battery profiles retain percentage and voltage reporting", function() + test.socket.zigbee:__queue_receive({ battery_device.id, + clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report( + battery_device, 150) }) + expect_event(battery_device, capabilities.battery.battery(75)) + test.wait_for_events() + battery_device:set_field(battery_defaults.DEVICE_MIN_VOLTAGE_KEY, 2.0) + battery_device:set_field(battery_defaults.DEVICE_MAX_VOLTAGE_KEY, 3.0) + test.socket.zigbee:__queue_receive({ battery_device.id, + clusters.PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(battery_device, 25) }) + expect_event(battery_device, capabilities.battery.battery(50)) +end, { test_init = function() initialize_device(battery_device) end }) + +test.register_coroutine_test("Absolute movement ends the previous relative-step sequence", function() + report_level(51) + step_and_expect(10, 61) + report_level(61) + command("windowShadeLevel", "setShadeLevel", { 50 }) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) + expect_position_read() + test.wait_for_events() + report_level(50) + step_and_expect(-10, 40) +end) + +test.register_coroutine_test("Rapid relative steps accumulate despite intermediate reports", function() + report_level(50) + step_and_expect(10, 60) + report_level(52) + step_and_expect(-5, 55) + step_and_expect(15, 70) +end) + +local motion_commands = { + { name = "open", capability = "windowShade", zigbee = "UpOrOpen", actual = 70 }, + { name = "close", capability = "windowShade", zigbee = "DownOrClose", actual = 30 }, + { name = "pause", capability = "windowShade", zigbee = "Stop", actual = 55 }, + { name = "presetPosition", capability = "windowShadePreset", + zigbee = "GoToLiftPercentage", actual = 50, target = 50 }, +} + +for _, motion in ipairs(motion_commands) do + test.register_coroutine_test(motion.name .. " ends the previous relative-step sequence", function() + report_level(51) + step_and_expect(10, 61) + command(motion.capability, motion.name) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands[motion.zigbee](mock_device, motion.target) }) + if motion.name == "pause" then + expect_position_read() + expect_event(mock_device, capabilities.windowShade.windowShade.unknown()) + end + test.wait_for_events() + report_level(motion.actual) + step_and_expect(-10, motion.actual - 10) + end) +end + +test.register_coroutine_test("Refresh and preset configuration preserve relative-step accumulation", + function() + report_level(50) + step_and_expect(10, 60) + command("refresh", "refresh") + test.wait_for_events() + command("windowShadePreset", "setPresetPosition", { 40 }) + expect_event(mock_device, capabilities.windowShadePreset.position(40)) + test.wait_for_events() + step_and_expect(-5, 55) + end) + +test.register_coroutine_test("Interrupted step timers cannot clear a later sequence", function() + report_level(50) + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + step_and_expect(10, 60) + test.mock_time.advance_time(5) + test.wait_for_events() + command("windowShade", "pause") + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.Stop(mock_device) }) + expect_position_read() + expect_event(mock_device, capabilities.windowShade.windowShade.unknown()) + test.wait_for_events() + assert(mock_device:get_field("_latestTargetLevel") == nil) + assert(mock_device:get_field("_targetLevelTimeOut") == nil) + confirm_position(50) + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + step_and_expect(-10, 40) + test.mock_time.advance_time(11) + test.wait_for_events() + assert(mock_device:get_field("_latestTargetLevel") == 40) + test.mock_time.advance_time(5) + test.wait_for_events() + assert(mock_device:get_field("_latestTargetLevel") == nil) + assert(mock_device:get_field("_targetLevelTimeOut") == nil) + command("windowShadeLevel", "setShadeLevel", { 45 }) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 45) }) + test.wait_for_events() + report_level(45) + step_and_expect(-5, 40) +end) + +test.register_coroutine_test("Zero relative steps preserve the sequence and clear the dispatch marker", + function() + report_level(50) + step_and_expect(10, 60) + command("statelessWindowShadeLevelStep", "stepShadeLevel", { 0 }) + test.wait_for_events() + assert(mock_device:get_field("_latestTargetLevel") == 60) + assert(mock_device:get_field("_zemismartStepDispatch") == nil) + step_and_expect(-5, 55) + end) + +test.register_coroutine_test("Failed internal step dispatch leaves no stale target or marker", function() + report_level(50) + local step_handler = (require "zemismart").capability_handlers[ + capabilities.statelessWindowShadeLevelStep.ID][ + capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] + expect_event(mock_device, capabilities.windowShade.windowShade.unknown()) + local ok, err = pcall(step_handler, { + inject_capability_command = function() error("simulated dispatch failure") end, + }, mock_device, { component = "main", args = { stepSize = 10 } }) + assert(not ok and string.find(err, "simulated dispatch failure", 1, true)) + assert(mock_device:get_field("_zemismartStepDispatch") == nil) + assert(mock_device:get_field("_latestTargetLevel") == nil) + assert(mock_device:get_field("_targetLevelTimeOut") == nil) + confirm_position(50) + command("windowShadeLevel", "setShadeLevel", { 30 }) + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 30) }) + expect_position_read() +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua new file mode 100644 index 0000000000..7dc96100ea --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua @@ -0,0 +1,728 @@ +-- 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 Status = require "st.zigbee.generated.types.ZclStatus" +local confirmation = require "zemismart.position_confirmation" +local shade_utils = require "window_shade_utils" +local helpers = require "test.zemismart_feedback_test_utils" + +local device = helpers.create() +local battery_device = helpers.create("window-treatment-battery.yml") +local shade = capabilities.windowShade.windowShade +local window = clusters.WindowCovering +local CHECK = "_zemismartPositionCheck" +local MOTION = "_zemismartMotionContext" + +helpers.set_test_init(device) + +local function is_unknown() + assert(not confirmation.position_is_known(device), "position must remain untrusted") + assert(device:get_latest_state("main", "windowShade", "windowShade") == "unknown") +end + +local function expect_motion(command, argument) + local message = argument ~= nil and window.server.commands[command](device, argument) + or window.server.commands[command](device) + test.socket.zigbee:__expect_send({ device.id, message }) +end + +local function start_refresh() + helpers.command("refresh", "refresh") + helpers.expect_read() + test.wait_for_events() +end + +local function make_unknown() + helpers.report(0xFF, 0x70) + helpers.expect_event(shade.unknown()) + helpers.expect_read() + test.wait_for_events() + is_unknown() +end + +local function next_read(seconds) + helpers.expect_read() + helpers.advance(seconds or 2) +end + +local function finish_response(level, sequence) + helpers.response(level, sequence) + helpers.expect_static(level) + test.wait_for_events() + assert(confirmation.position_is_known(device)) +end + +local function capture_delayed_callbacks() + local original = device.thread.call_with_delay + local callbacks = {} + device.thread.call_with_delay = function(thread, seconds, callback, ...) + callbacks[#callbacks + 1] = { seconds = seconds, callback = callback } + return original(thread, seconds, callback, ...) + end + return callbacks, function() device.thread.call_with_delay = original end +end + +local function callback_for(callbacks, seconds) + for _, entry in ipairs(callbacks) do + if entry.seconds == seconds then return entry.callback end + end + error("requested callback was not scheduled") +end + +test.register_coroutine_test("Startup requires reads instead of trusting a cached percentage", function() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == nil) + assert(device:get_field(CHECK).reads == 1) + helpers.report(43, 3) + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == nil) +end) + +for _, level in ipairs({ 0, 43, 100 }) do + test.register_coroutine_test("Two separated successful reads establish static " .. level, function() + helpers.confirm(level) + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == level) + assert(device:get_field(CHECK) == nil) + helpers.advance(35) + end) +end + +test.register_coroutine_test("H03 reports at 0, 0.576, 4.750 seconds cannot establish startup trust", + function() + helpers.report(43, 3) + test.wait_for_events() + helpers.advance(0.576) + helpers.report(0, 0) + test.wait_for_events() + helpers.advance(4.174) + helpers.report(43, 8) + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == nil) + assert(device:get_field(CHECK).reads == 1) + end) + +test.register_coroutine_test("H03 recovery sequence never emits zero closed opening or motion", function() + helpers.confirm(43) + make_unknown() + helpers.report(43, 3) + test.wait_for_events() + helpers.advance(0.576) + helpers.report(0, 0) + test.wait_for_events() + helpers.advance(4.174) + helpers.report(43, 8) + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) + assert(device:get_field(CHECK).observations == 0) +end) + +for _, level in ipairs({ 0, 43, 100 }) do + test.register_coroutine_test("Unknown recovery needs two static reads at " .. level, function() + helpers.confirm(43) + make_unknown() + helpers.confirm(level, 0, 0xFF) + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == level) + helpers.advance(35) + end) +end + +test.register_coroutine_test("Two replies in one read round count only once", function() + helpers.response(43, 1) + test.wait_for_events() + helpers.response(43, 2) + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK).observations == 1) + next_read() + finish_response(43, 2) +end) + +test.register_coroutine_test("Repeated sequence in a later round does not establish trust", function() + helpers.response(43, 0) + test.wait_for_events() + next_read() + helpers.response(43, 0) + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK).observations == 1) + assert(device:get_field(CHECK).pending) + finish_response(43, 0xFF) +end) + +test.register_coroutine_test("Sequence FF to 00 is allowed without ordering assumptions", function() + helpers.confirm(43, 0xFF, 0) +end) + +test.register_coroutine_test("Sequence de-duplication resets with a genuinely new session", function() + helpers.confirm(43, 0, 1) + make_unknown() + helpers.confirm(43, 0, 1) +end) + +test.register_coroutine_test("Replies outside any active read session cannot replace trusted position", + function() + helpers.confirm(43) + helpers.response(0, 0x44) + helpers.response(100, 0x45) + test.wait_for_events() + assert(confirmation.position_is_known(device)) + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) + assert(device:get_latest_state("main", "windowShade", "windowShade") == "partially open") + end) + +test.register_coroutine_test("A contradictory report invalidates the first read observation", function() + helpers.response(43, 1) + test.wait_for_events() + helpers.report(0, 0) + test.wait_for_events() + assert(device:get_field(CHECK).observations == 0) + next_read() + helpers.response(43, 2) + test.wait_for_events() + is_unknown() + next_read() + finish_response(43, 3) +end) + +test.register_coroutine_test("Mismatching static replies need a new agreeing observation", function() + helpers.response(43, 1) + test.wait_for_events() + next_read() + helpers.response(44, 2) + test.wait_for_events() + is_unknown() + next_read() + finish_response(44, 3) +end) + +test.register_coroutine_test("Three mutually inconsistent replies exhaust the static read count", function() + for round, level in ipairs({ 43, 0, 43 }) do + helpers.response(level, round) + test.wait_for_events() + if round < 3 then next_read() end + end + helpers.advance(2) + is_unknown() + assert(device:get_field(CHECK) == nil) + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == nil) +end) + +test.register_coroutine_test("Wrong endpoint and failed read status cannot confirm the position", function() + helpers.response(43, 1, 2) + helpers.report(43, 2, 2) + local failed = helpers.response(43, 3) + failed.body.zcl_body.attr_records[1].status.value = Status.UNSUPPORTED_ATTRIBUTE + test.socket.zigbee:__queue_receive({ device.id, + clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report( + device, 200) }) + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK).observations == 0) + helpers.confirm(43, 4, 5) +end) + +for _, level in ipairs({ 0xFF, 101, 254 }) do + for _, kind in ipairs({ "response", "report" }) do + test.register_coroutine_test("Invalid position " .. level .. " in " .. kind .. " revokes trust", + function() + helpers.confirm(43) + helpers[kind](level, 1) + helpers.expect_event(shade.unknown()) + helpers.expect_read() + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) + helpers.confirm(43, 2, 3) + end) + end +end + +test.register_coroutine_test("Only three unanswered reads fit the original thirty second budget", function() + next_read(10) + next_read(10) + helpers.advance(9) + assert(device:get_field(CHECK) ~= nil) + helpers.advance(1) + is_unknown() + assert(device:get_field(CHECK) == nil) + helpers.response(43, 0x55) + helpers.advance(20) + is_unknown() + assert(device:get_field(CHECK) == nil) +end) + +test.register_coroutine_test("A first reply at twenty-nine seconds cannot extend the hard budget", function() + next_read(10) + next_read(10) + helpers.advance(9) + helpers.response(43, 1) + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK).observations == 1) + helpers.advance(1) + assert(device:get_field(CHECK) == nil) + helpers.response(43, 2) + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == nil) +end) + +test.register_coroutine_test("Repeated refresh and report storms do not extend a static session", function() + for second = 1, 29 do + helpers.command("refresh", "refresh") + helpers.report(second % 2 == 0 and 43 or 0, second % 256) + test.wait_for_events() + if second == 10 or second == 20 then helpers.expect_read() end + helpers.advance(1) + end + helpers.advance(1) + is_unknown() + assert(device:get_field(CHECK) == nil) + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == nil) +end) + +test.register_coroutine_test("Passive recovery cannot loop forever under an unsolicited report storm", + function() + next_read(10) + next_read(10) + helpers.advance(10) + helpers.report(43, 1) + helpers.expect_read() + test.wait_for_events() + next_read(10) + next_read(10) + helpers.advance(10) + for sequence = 2, 20 do helpers.report(43, sequence) end + helpers.advance(60) + is_unknown() + assert(device:get_field(CHECK) == nil) + end) + +test.register_coroutine_test("An old round callback cannot consume or end the next round", function() + helpers.confirm(43) + local callbacks, restore = capture_delayed_callbacks() + start_refresh() + local stale = callback_for(callbacks, 10) + helpers.response(43, 1) + test.wait_for_events() + next_read() + restore() + local check = device:get_field(CHECK) + stale() + test.wait_for_events() + assert(device:get_field(CHECK) == check) + assert(check.reads == 2 and check.pending and check.observations == 1) + finish_response(43, 2) + stale() + helpers.advance(35) +end) + +test.register_coroutine_test("Old session callbacks cannot contaminate reinitialization", function() + helpers.confirm(43) + local callbacks, restore = capture_delayed_callbacks() + start_refresh() + local old_round = callback_for(callbacks, 10) + local old_total = callback_for(callbacks, 30) + restore() + helpers.expect_event(shade.unknown()) + helpers.expect_read() + confirmation.initialize({}, device) + test.wait_for_events() + local check = device:get_field(CHECK) + old_round() + old_total() + test.wait_for_events() + assert(device:get_field(CHECK) == check and check.reads == 1) + helpers.confirm(43, 0, 1) +end) + +test.register_coroutine_test("A failed read send revokes trust and cancels the verification session", + function() + helpers.confirm(43) + local original = device.send_to_component + device.send_to_component = function() error("simulated position-read send failure") end + helpers.expect_event(shade.unknown()) + local ok, err = pcall(confirmation.start, {}, device, "main") + device.send_to_component = original + assert(not ok and string.find(err, "simulated position-read send failure", 1, true)) + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK) == nil) + helpers.advance(35) + end) + +test.register_coroutine_test("A failed motion send must also revoke position trust", function() + helpers.confirm(43) + local handler = (require "zemismart").capability_handlers.windowShade.open + local original = device.send_to_component + device.send_to_component = function() error("simulated movement-send failure") end + helpers.expect_event(shade.unknown()) + local ok, err = pcall(handler, {}, device, { + capability = "windowShade", command = "open", component = "main", args = {}, + }) + device.send_to_component = original + assert(not ok and string.find(err, "simulated movement-send failure", 1, true)) + test.wait_for_events() + is_unknown() +end) + +test.register_coroutine_test("All non-stop motion commands are read-only while unknown", function() + for _, request in ipairs({ + { "windowShade", "open" }, { "windowShade", "close" }, + { "windowShadeLevel", "setShadeLevel", { 0 } }, + { "windowShadeLevel", "setShadeLevel", { 100 } }, + { "windowShadePreset", "presetPosition" }, + { "statelessWindowShadeLevelStep", "stepShadeLevel", { 10 } }, + { "statelessWindowShadeLevelStep", "stepShadeLevel", { -10 } }, + }) do + helpers.command(request[1], request[2], request[3]) + end + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK).reads == 1) + helpers.confirm(43) + helpers.advance(35) -- No queued motion is replayed after recovery. +end) + +test.register_coroutine_test("Unknown without an active session starts only a read on movement", function() + next_read(10) + next_read(10) + helpers.advance(10) + helpers.command("windowShade", "open") + helpers.expect_read() + test.wait_for_events() + is_unknown() + helpers.confirm(43) +end) + +test.register_coroutine_test("Pause may send Stop while unknown and restarts static sampling", function() + helpers.command("windowShade", "pause") + expect_motion("Stop") + test.wait_for_events() + is_unknown() + next_read() + helpers.confirm(43, 1, 2) +end) + +test.register_coroutine_test("Pause discards an observation and ignores replies before its next read", + function() + helpers.response(43, 1) + test.wait_for_events() + helpers.command("windowShade", "pause") + expect_motion("Stop") + test.wait_for_events() + helpers.response(43, 2) + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK).observations == 0) + assert(not device:get_field(CHECK).pending) + next_read() + helpers.response(43, 3) + test.wait_for_events() + is_unknown() + next_read() + finish_response(43, 4) + end) + +for _, first_expiry in ipairs({ "read budget", "motion silence" }) do + test.register_coroutine_test("Repeated commands cannot extend expiry: " .. first_expiry .. " first", + function() + helpers.confirm(43) + local callbacks, restore = capture_delayed_callbacks() + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + restore() + local expiry_callbacks = {} + for _, entry in ipairs(callbacks) do + if entry.seconds == 30 then expiry_callbacks[#expiry_callbacks + 1] = entry.callback end + end + assert(#expiry_callbacks == 2) + helpers.advance(9) + helpers.command("windowShade", "close") + expect_motion("DownOrClose") + test.wait_for_events() + next_read(1) + helpers.advance(9) + helpers.command("windowShadeLevel", "setShadeLevel", { 50 }) + expect_motion("GoToLiftPercentage", 50) + test.wait_for_events() + next_read(1) + helpers.advance(9) + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + test.wait_for_events() + -- Both deadlines are due together. Exercise each event order explicitly. + test.mock_time.advance_time(1) + helpers.expect_event(shade.unknown()) + if first_expiry == "motion silence" then + helpers.expect_read() + expiry_callbacks[1]() + else + expiry_callbacks[2]() + end + test.wait_for_events() + is_unknown() + assert(device:get_field(MOTION) == nil) + local check = device:get_field(CHECK) + if first_expiry == "motion silence" then + assert(check and check.mode == "static" and check.reads == 1) + else + assert(check == nil) + end + end) +end + +test.register_coroutine_test("Only strict directional progress renews the motion silence lease", function() + helpers.confirm(43) + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + helpers.advance(9) + helpers.report(44, 4) + helpers.expect_event(capabilities.windowShadeLevel.shadeLevel(44)) + helpers.expect_event(shade.opening()) + test.wait_for_events() + helpers.expect_event(shade.partially_open()) + helpers.advance(29) + helpers.report(44, 4) + test.wait_for_events() + helpers.expect_event(shade.unknown()) + helpers.expect_read() + helpers.advance(1) + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 44) +end) + +test.register_coroutine_test("A long movement remains valid while fresh directional progress continues", + function() + helpers.confirm(43) + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + helpers.advance(9) + for sequence, level in ipairs({ 44, 45, 46, 47 }) do + helpers.report(level, sequence) + helpers.expect_event(capabilities.windowShadeLevel.shadeLevel(level)) + helpers.expect_event(shade.opening()) + test.wait_for_events() + helpers.expect_event(shade.partially_open()) + helpers.advance(20) + assert(confirmation.position_is_known(device)) + assert(device:get_field(MOTION) ~= nil) + assert(device:get_field(CHECK) == nil) + end + helpers.report(100, 9) + helpers.expect_event(shade.unknown()) + helpers.expect_read() + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 47) + helpers.confirm(100, 0, 1) + assert(device:get_field(MOTION) == nil) + helpers.advance(35) + end) + +test.register_coroutine_test("An old partial-state callback cannot overwrite unknown or restored trust", + function() + helpers.confirm(43) + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + local callbacks, restore = capture_delayed_callbacks() + helpers.report(44, 1) + helpers.expect_event(capabilities.windowShadeLevel.shadeLevel(44)) + helpers.expect_event(shade.opening()) + test.wait_for_events() + restore() + local stale_partial = callback_for(callbacks, 2) + helpers.report(43, 2) + helpers.expect_event(shade.unknown()) + helpers.expect_read() + test.wait_for_events() + stale_partial() + test.wait_for_events() + is_unknown() + helpers.confirm(43, 3, 4) + stale_partial() + helpers.advance(35) + assert(device:get_latest_state("main", "windowShade", "windowShade") == "partially open") + end) + +test.register_coroutine_test("Failed motion during active progress cancels old context without replay", + function() + helpers.confirm(43) + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + helpers.report(44, 1) + helpers.expect_event(capabilities.windowShadeLevel.shadeLevel(44)) + helpers.expect_event(shade.opening()) + test.wait_for_events() + local handler = (require "zemismart").capability_handlers.windowShade.close + local original = device.send_to_component + device.send_to_component = function() error("simulated active-motion send failure") end + helpers.expect_event(shade.unknown()) + local ok, err = pcall(handler, {}, device, { + capability = "windowShade", command = "close", component = "main", args = {}, + }) + device.send_to_component = original + assert(not ok and string.find(err, "simulated active-motion send failure", 1, true)) + test.wait_for_events() + is_unknown() + assert(device:get_field(CHECK) == nil and device:get_field(MOTION) == nil) + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 44) + helpers.advance(35) + end) + +test.register_coroutine_test("Reinitialization cannot derive trust from an earlier cached position", function() + helpers.confirm(43) + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + helpers.expect_event(shade.unknown()) + helpers.expect_read() + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) + helpers.report(43, 0) + test.wait_for_events() + is_unknown() + helpers.confirm(43, 0, 1) +end) + +test.register_coroutine_test("A target report requires static reads before emitting the endpoint", function() + helpers.confirm(43) + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + helpers.report(100, 9) + helpers.expect_event(shade.unknown()) + helpers.expect_read() + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) + helpers.confirm(100, 0, 1) +end) + +test.register_coroutine_test("A direction-conflicting report is quarantined without false movement", function() + helpers.confirm(43) + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + helpers.report(42, 8) + helpers.expect_event(shade.unknown()) + helpers.expect_read() + test.wait_for_events() + is_unknown() + assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) +end) + +test.register_coroutine_test("Removal invalidates round total motion partial and step callbacks", function() + helpers.confirm(43) + local callbacks, restore = capture_delayed_callbacks() + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + helpers.report(44, 1) + helpers.expect_event(capabilities.windowShadeLevel.shadeLevel(44)) + helpers.expect_event(shade.opening()) + test.wait_for_events() + helpers.command("statelessWindowShadeLevelStep", "stepShadeLevel", { 10 }) + expect_motion("GoToLiftPercentage", 54) + helpers.expect_read() + test.wait_for_events() + restore() + local scheduled = {} + for _, entry in ipairs(callbacks) do scheduled[entry.seconds] = true end + assert(scheduled[2] and scheduled[10] and scheduled[15] and scheduled[30]) + -- Let the real lifecycle dispatcher perform cleanup with all contexts active. + test.socket.device_lifecycle:__queue_receive({ device.id, "removed" }) + test.wait_for_events() + -- SDK deletion invalidates methods, but a timer event can already be queued. + -- Only closure-local cancellation tokens may be consulted before the device. + for _, entry in ipairs(callbacks) do + local ok, err = pcall(entry.callback) + assert(ok, "callback touched a deleted device: " .. tostring(err)) + end + helpers.advance(60) -- Do not access the device after SDK invalidates it. +end) + +test.register_coroutine_test("Already-complete step timer cancellation still invalidates saved fields", + function() + helpers.confirm(43) + local callbacks, restore_capture = capture_delayed_callbacks() + helpers.command("statelessWindowShadeLevelStep", "stepShadeLevel", { 10 }) + expect_motion("GoToLiftPercentage", 53) + helpers.expect_read() + test.wait_for_events() + restore_capture() + local stale = callback_for(callbacks, 15) + local old_timer = device:get_field("_targetLevelTimeOut") + local old_generation = device:get_field("_targetLevelGeneration") + local original_cancel = device.thread.cancel_timer + device.thread.cancel_timer = function(thread, timer) + if timer == old_timer then error("simulated already-complete step timer") end + return original_cancel(thread, timer) + end + local ok, err = pcall(shade_utils.clear_step_target, device) + device.thread.cancel_timer = original_cancel + assert(ok, tostring(err)) + assert(device:get_field("_latestTargetLevel") == nil) + assert(device:get_field("_targetLevelTimeOut") == nil) + assert(device:get_field("_targetLevelGeneration") == nil) + assert(old_generation.active == false) + stale() + test.wait_for_events() + end) + +test.register_coroutine_test("Replacing a completed step timer cannot let its old callback clear the new target", + function() + helpers.confirm(43) + local callbacks, restore_capture = capture_delayed_callbacks() + helpers.command("statelessWindowShadeLevelStep", "stepShadeLevel", { 10 }) + expect_motion("GoToLiftPercentage", 53) + helpers.expect_read() + test.wait_for_events() + restore_capture() + local stale = callback_for(callbacks, 15) + local old_timer = device:get_field("_targetLevelTimeOut") + local old_generation = device:get_field("_targetLevelGeneration") + local original_cancel = device.thread.cancel_timer + device.thread.cancel_timer = function(thread, timer) + if timer == old_timer then error("simulated already-complete step timer") end + return original_cancel(thread, timer) + end + helpers.command("statelessWindowShadeLevelStep", "stepShadeLevel", { 1 }) + expect_motion("GoToLiftPercentage", 54) + test.wait_for_events() + device.thread.cancel_timer = original_cancel + assert(old_generation.active == false) + local new_timer = device:get_field("_targetLevelTimeOut") + assert(new_timer ~= nil and new_timer ~= old_timer) + stale() + test.wait_for_events() + assert(device:get_field("_latestTargetLevel") == 54) + assert(device:get_field("_targetLevelTimeOut") == new_timer) + assert(device:get_field("_targetLevelGeneration").active) + end) + +test.register_coroutine_test("Battery profile retains its original initialization without trust reads", + function() + assert(battery_device:get_field(CHECK) == nil) + assert(battery_device:get_latest_state("main", "windowShade", "windowShade") == nil) + end, { test_init = function() helpers.initialize_device(battery_device) end }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/zemismart_feedback_test_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/zemismart_feedback_test_utils.lua new file mode 100644 index 0000000000..90b6351d2e --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/zemismart_feedback_test_utils.lua @@ -0,0 +1,139 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local z_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local confirmation = require "zemismart.position_confirmation" + +local helpers = {} +local position = clusters.WindowCovering.attributes.CurrentPositionLiftPercentage +local battery_profiles = {} + +-- Exercise actual requested delays, including simultaneous response/total/lease timers. +-- The SDK's default unqueued mock timer never fires, which would hide expiry defects. +local create_oneshot = test.timer.create_oneshot +test.timer.create_oneshot = function(seconds) + if #test.timer.__oneshot_timers == 0 then + test.timer.__create_and_queue_test_time_advance_timer(seconds, "oneshot") + end + return create_oneshot(seconds) +end + +local function target(device) + return assert(device or helpers.device, "create a feedback test device first") +end + +function helpers.create(profile) + local definition = t_utils.get_profile_definition(profile or "window-treatment-profile.yml") + local device = test.mock_device.build_test_zigbee_device({ + profile = definition, + zigbee_endpoints = { + [1] = { + id = 1, manufacturer = "_TZE200_cirjrpxe", model = "TS0301", + server_clusters = { 0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x0102, 0xEF00 }, + client_clusters = { 0x0019 }, + }, + }, + }) + for _, component in pairs(definition.components) do + for _, capability in pairs(component.capabilities) do + if capability.id == "battery" then battery_profiles[device] = true end + end + end + helpers.device = helpers.device or device + return device +end + +function helpers.expect_event(event, device) + device = target(device) + test.socket.capability:__expect_send(device:generate_test_message("main", event)) +end + +function helpers.expect_read(device) + device = target(device) + test.socket.zigbee:__expect_send({ device.id, position:read(device) }) +end + +function helpers.command(capability, name, args, device) + device = target(device) + test.socket.capability:__queue_receive({ device.id, { + capability = capability, component = "main", command = name, args = args or {}, + } }) +end + +function helpers.response(level, sequence, endpoint, device) + device = target(device) + local message = position:build_test_read_attr_response(device, level) + if sequence ~= nil then message.body.zcl_header.seqno.value = sequence end + if endpoint ~= nil then message.address_header.src_endpoint.value = endpoint end + test.socket.zigbee:__queue_receive({ device.id, message }) + return message +end + +function helpers.report(level, sequence, endpoint, device) + device = target(device) + local message = position:build_test_attr_report(device, level) + if sequence ~= nil then message.body.zcl_header.seqno.value = sequence end + if endpoint ~= nil then message.address_header.src_endpoint.value = endpoint end + test.socket.zigbee:__queue_receive({ device.id, message }) + return message +end + +function helpers.expect_static(level, device) + helpers.expect_event(capabilities.windowShadeLevel.shadeLevel(level), device) + local shade = capabilities.windowShade.windowShade + helpers.expect_event(level == 0 and shade.closed() + or level == 100 and shade.open() or shade.partially_open(), device) +end + +function helpers.initialize_device(device) + device = target(device) + helpers.device = device + test.mock_device.add_test_device(device) + helpers.expect_event(capabilities.windowShadePreset.supportedCommands( + { "presetPosition", "setPresetPosition" }, { visibility = { displayed = false } }), device) + helpers.expect_event(capabilities.windowShadePreset.position( + 50, { visibility = { displayed = false } }), device) + if not battery_profiles[device] then + helpers.expect_event(capabilities.windowShade.windowShade.unknown(), device) + helpers.expect_read(device) + end +end + +function helpers.set_test_init(device) + device = target(device) + z_utils.prepare_zigbee_env_info() + local init = function() helpers.initialize_device(device) end + test.set_test_init_function(init) + return init +end + +function helpers.advance(seconds) + test.mock_time.advance_time(seconds) + test.wait_for_events() +end + +-- Trust is established solely through two successful protocol reads, never set_field. +-- These synthetic messages test acceptance policy, not on-air TSN correlation. +function helpers.confirm(level, first_sequence, second_sequence, device) + device = target(device) + test.wait_for_events() + if not device:get_field("_zemismartPositionCheck") then + helpers.command("refresh", "refresh", nil, device) + helpers.expect_read(device) + test.wait_for_events() + end + helpers.response(level, first_sequence or 0x11, nil, device) + test.wait_for_events() + helpers.expect_read(device) + helpers.advance(2) + helpers.response(level, second_sequence or 0x12, nil, device) + helpers.expect_static(level, device) + test.wait_for_events() + assert(confirmation.position_is_known(device), "two real read responses must establish trust") +end + +return helpers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua index fd07648021..cd650adef5 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua @@ -18,6 +18,19 @@ local LATEST_TARGET_LEVEL = "_latestTargetLevel" local TARGET_LEVEL_TIME_OUT_SECONDS = 15 -- Field name for the timer that clears LATEST_TARGET_LEVEL after timeout local TARGET_LEVEL_TIME_OUT = "_targetLevelTimeOut" +local TARGET_LEVEL_GENERATION = "_targetLevelGeneration" + +utils.clear_step_target = function(device) + local generation = device:get_field(TARGET_LEVEL_GENERATION) + if generation then generation.active = false end + device:set_field(TARGET_LEVEL_GENERATION, nil) + local timer = device:get_field(TARGET_LEVEL_TIME_OUT) + if timer then + pcall(device.thread.cancel_timer, device.thread, timer) + end + device:set_field(TARGET_LEVEL_TIME_OUT, nil) + device:set_field(LATEST_TARGET_LEVEL, nil) +end utils.get_preset_level = function(device, component) local level = device:get_latest_state(component, "windowShadePreset", "position") or @@ -54,11 +67,20 @@ utils.step_shade_level_handler = function(driver, device, command) -- Cancel any previous timer and set a 15 second timeout timer to ensure LATEST_TARGET_LEVEL is cleared -- This is to prevent the stateless step from accumulating indefinitely, while ensuring that a single -- "scroll" action can accumulate multiple steps within a short time frame + local previous_generation = device:get_field(TARGET_LEVEL_GENERATION) + if previous_generation then previous_generation.active = false end if device:get_field(TARGET_LEVEL_TIME_OUT) then - device.thread:cancel_timer(device:get_field(TARGET_LEVEL_TIME_OUT)) + pcall(device.thread.cancel_timer, device.thread, device:get_field(TARGET_LEVEL_TIME_OUT)) end + local generation = { active = true } + device:set_field(TARGET_LEVEL_GENERATION, generation) local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + if not generation.active then return end + if device:get_field(TARGET_LEVEL_GENERATION) ~= generation then return end + generation.active = false + device:set_field(TARGET_LEVEL_GENERATION, nil) device:set_field(LATEST_TARGET_LEVEL, nil) + device:set_field(TARGET_LEVEL_TIME_OUT, nil) end) device:set_field(TARGET_LEVEL_TIME_OUT, timer) device:set_field(LATEST_TARGET_LEVEL, new_target_level) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/zemismart/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/zemismart/can_handle.lua new file mode 100644 index 0000000000..a031d2ec7a --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/zemismart/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +local function can_handle(opts, driver, device) + if device:get_manufacturer() == "_TZE200_cirjrpxe" and device:get_model() == "TS0301" + and not device:supports_capability_by_id(capabilities.battery.ID) then + return true, require("zemismart") + end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/zemismart/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/zemismart/init.lua new file mode 100644 index 0000000000..a0f00810a4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/zemismart/init.lua @@ -0,0 +1,133 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local PowerConfiguration = (require "st.zigbee.zcl.clusters").PowerConfiguration +local capabilities = require "st.capabilities" +local shade_defaults = require "st.zigbee.defaults.windowShade_defaults" +local level_defaults = require "st.zigbee.defaults.windowShadeLevel_defaults" +local shade_utils = require "window_shade_utils" +local position_confirmation = require "zemismart.position_confirmation" +local WindowCovering = (require "st.zigbee.zcl.clusters").WindowCovering +local log = require "log" + +local STEP_DISPATCH = "_zemismartStepDispatch" + +local function motion_target(device, command) + if command.capability == capabilities.windowShadeLevel.ID then return command.args.shadeLevel end + if command.capability == capabilities.windowShadePreset.ID then + return shade_utils.get_preset_level(device, command.component) + end + return command.command == capabilities.windowShade.commands.open.NAME and 100 or 0 +end + +local function clear_step_target_before(handler) + return function(driver, device, command) + local is_step = device:get_field(STEP_DISPATCH) + local is_pause = command.capability == capabilities.windowShade.ID + and command.command == capabilities.windowShade.commands.pause.NAME + if not is_pause and not position_confirmation.position_is_known(device) then + log.warn_with({ hub_logs = true }, "ZM25Z movement rejected until position is confirmed; reading") + position_confirmation.start(driver, device, command.component) + return + end + if not is_step then + shade_utils.clear_step_target(device) + end + local ok, result = pcall(handler, driver, device, command) + if not ok then + position_confirmation.command_failed(device) + error(result) + end + if is_pause then + position_confirmation.pause_sent(driver, device) + else + position_confirmation.motion_sent(driver, device, motion_target(device, command)) + if not is_step then position_confirmation.start(driver, device, command.component) end + end + return result + end +end + +local function step_shade_level_handler(driver, device, command) + if (command.args.stepSize or 0) == 0 then return end + if not position_confirmation.position_is_known(device) then + log.warn_with({ hub_logs = true }, "ZM25Z relative movement requires a confirmed position; reading") + position_confirmation.start(driver, device, command.component) + return + end + -- SDK injection dispatches synchronously; its internal setShadeLevel belongs + -- to the same relative-step sequence rather than a new absolute command. + local previous_dispatch = device:get_field(STEP_DISPATCH) + device:set_field(STEP_DISPATCH, true) + local ok, result = pcall(shade_utils.step_shade_level_handler, driver, device, command) + device:set_field(STEP_DISPATCH, previous_dispatch) + if not ok then + shade_utils.clear_step_target(device) + position_confirmation.command_failed(device) + error(result) + end + position_confirmation.start(driver, device, command.component) +end + +-- This externally powered profile has no battery, despite unsolicited battery reports. +local function ignore_battery_report() end + +local function init_handler(driver, device) + -- Preserve the parent driver's preset initialization before establishing volatile trust. + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) + and device:get_latest_state("main", "windowShadePreset", "position") == nil then + device:emit_event(capabilities.windowShadePreset.supportedCommands( + { "presetPosition", "setPresetPosition" }, { visibility = { displayed = false } })) + local preset = device:get_field(shade_utils.PRESET_LEVEL_KEY) + or (device.preferences ~= nil and device.preferences.presetPosition) or shade_utils.PRESET_LEVEL + device:emit_event(capabilities.windowShadePreset.position(preset, + { visibility = { displayed = false } })) + device:set_field(shade_utils.PRESET_LEVEL_KEY, preset, { persist = true }) + end + position_confirmation.initialize(driver, device) +end + +return { + NAME = "Zemismart window treatment without battery", + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = function(driver, device, command) + position_confirmation.start(driver, device, command and command.component or "main") + end, + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.open.NAME] = clear_step_target_before( + shade_defaults.capability_handlers[capabilities.windowShade.commands.open.NAME]), + [capabilities.windowShade.commands.close.NAME] = clear_step_target_before( + shade_defaults.capability_handlers[capabilities.windowShade.commands.close.NAME]), + [capabilities.windowShade.commands.pause.NAME] = clear_step_target_before( + shade_defaults.capability_handlers[capabilities.windowShade.commands.pause.NAME]), + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = clear_step_target_before( + level_defaults.window_shade_level_cmd), + }, + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = clear_step_target_before( + shade_utils.window_shade_preset_cmd), + }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = + step_shade_level_handler, + }, + }, + zigbee_handlers = { + attr = { + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryVoltage.ID] = ignore_battery_report, + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = ignore_battery_report, + }, + [WindowCovering.ID] = { + [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = + position_confirmation.position_handler, + }, + }, + }, + lifecycle_handlers = { init = init_handler, removed = position_confirmation.removed }, + can_handle = require("zemismart.can_handle"), +} diff --git a/drivers/SmartThings/zigbee-window-treatment/src/zemismart/position_confirmation.lua b/drivers/SmartThings/zigbee-window-treatment/src/zemismart/position_confirmation.lua new file mode 100644 index 0000000000..a9cab779d8 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/zemismart/position_confirmation.lua @@ -0,0 +1,333 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local WindowCovering = (require "st.zigbee.zcl.clusters").WindowCovering +local shade_utils = require "window_shade_utils" +local log = require "log" + +local confirmation = {} +local CHECK = "_zemismartPositionCheck" +local TRUST = "_zemismartTrustedPosition" +local MOTION = "_zemismartMotionContext" +local AUTO_RECOVERY = "_zemismartAutoRecovery" +local PARTIAL_GENERATION = "_zemismartPartialGeneration" +local PARTIAL_TIMER = "partial_open_timer" +-- Engineering consistency policy; these are not certification or travel-time limits. +local RESPONSE_SECONDS = 10 +local SAMPLE_SEPARATION_SECONDS = 2 +local SESSION_SECONDS = 30 +local MAX_READS = 3 +local PROGRESS_SILENCE_SECONDS = 30 +local PARTIAL_SETTLE_SECONDS = 2 + +local function cancel_timer(device, timer) + if timer then + -- The callback may already be queued. Generation checks remain authoritative. + pcall(device.thread.cancel_timer, device.thread, timer) + end +end + +function confirmation.cancel(device) + local check = device:get_field(CHECK) + device:set_field(CHECK, nil) + if check then + check.timer_token = nil + check.total_token = nil + cancel_timer(device, check.timer) + cancel_timer(device, check.total_timer) + check.timer = nil + check.total_timer = nil + end +end + +local function cancel_partial(device) + local timer = device:get_field(PARTIAL_TIMER) + local generation = device:get_field(PARTIAL_GENERATION) + if generation then generation.active = false end + device:set_field(PARTIAL_GENERATION, nil) + device:set_field(PARTIAL_TIMER, nil) + cancel_timer(device, timer) +end + +local function cancel_motion(device) + local motion = device:get_field(MOTION) + device:set_field(MOTION, nil) + if motion then + motion.lease = nil + cancel_timer(device, motion.timer) + motion.timer = nil + end +end + +local function untrust(device, reason) + device:set_field(TRUST, nil) + cancel_motion(device) + cancel_partial(device) + shade_utils.clear_step_target(device) + if device:get_latest_state("main", "windowShade", "windowShade") ~= "unknown" then + log.warn_with({ hub_logs = true }, "ZM25Z position is unconfirmed: " .. reason) + device:emit_event(capabilities.windowShade.windowShade.unknown()) + end +end + +function confirmation.position_is_known(device) + return device:get_field(TRUST) ~= nil +end + +function confirmation.command_failed(device) + confirmation.cancel(device) + untrust(device, "local motion command could not be sent") +end + +local start_check +local function fail_check(device, reason) + confirmation.cancel(device) + untrust(device, reason) +end + +local function publish_static(device, level) + confirmation.cancel(device) + cancel_partial(device) + device:set_field(TRUST, { level = level }) + device:set_field(AUTO_RECOVERY, 1) + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + local shade = capabilities.windowShade.windowShade + device:emit_event(level == 0 and shade.closed() + or level == 100 and shade.open() or shade.partially_open()) +end + +start_check = function(driver, device, component, mode) + if device:get_field(CHECK) then return end + component = component or "main" + local check = { + component = component, endpoint = device:get_endpoint_for_component_id(component), + mode = mode or "static", reads = 0, round = 0, observations = 0, + seen_sequences = {}, deadline = os.time() + SESSION_SECONDS, + } + device:set_field(CHECK, check) + local send_read + local function schedule(delay, callback) + local remaining = check.deadline - os.time() + if remaining <= 0 then fail_check(device, "read consistency budget exhausted"); return end + local token = {} + check.timer_token = token + check.timer = device.thread:call_with_delay(math.min(delay, remaining), function() + if check.timer_token ~= token then return end + if device:get_field(CHECK) ~= check then return end + check.timer_token = nil + check.timer = nil + if os.time() >= check.deadline then + fail_check(device, "read consistency budget exhausted") + else + callback() + end + end) + end + send_read = function() + if check.reads >= MAX_READS then + fail_check(device, "insufficient consistent read observations") + return + end + local ok, err = pcall(function() + device:send_to_component(component, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) + end) + if not ok then + confirmation.cancel(device) + untrust(device, "local position read could not be sent") + error(err) + end + check.reads = check.reads + 1 + check.round = check.round + 1 + check.pending = true + schedule(RESPONSE_SECONDS, function() check.pending = false; send_read() end) + end + check.next_read = function() + check.timer_token = nil + cancel_timer(device, check.timer) + check.timer = nil + schedule(SAMPLE_SEPARATION_SECONDS, send_read) + end + send_read() + if device:get_field(CHECK) == check then + local token = {} + check.total_token = token + check.total_timer = device.thread:call_with_delay(SESSION_SECONDS, function() + if check.total_token ~= token then return end + if device:get_field(CHECK) ~= check then return end + check.total_timer = nil + check.total_token = nil + fail_check(device, "read consistency budget exhausted") + end) + end +end + +function confirmation.start(driver, device, component) + if device:get_field(CHECK) then return end -- Repeated triggers do not extend the budget. + device:set_field(AUTO_RECOVERY, 1) + start_check(driver, device, component, device:get_field(MOTION) and "motion" or "static") +end + +local function passive_check(driver, device) + if device:get_field(CHECK) then return end + if (device:get_field(AUTO_RECOVERY) or 0) <= 0 then return end + device:set_field(AUTO_RECOVERY, 0) + start_check(driver, device, "main", "static") +end + +local function end_motion(driver, device, reason) + confirmation.cancel(device) + untrust(device, reason) + device:set_field(AUTO_RECOVERY, 0) + start_check(driver, device, "main", "static") +end + +local function renew_motion(driver, device, motion) + cancel_timer(device, motion.timer) + local lease = {} + motion.lease = lease + motion.timer = device.thread:call_with_delay(PROGRESS_SILENCE_SECONDS, function() + if motion.lease ~= lease then return end + if device:get_field(MOTION) ~= motion then return end + motion.timer = nil + end_motion(driver, device, "no new directional progress") + end) +end + +function confirmation.motion_sent(driver, device, target) + local trusted = device:get_field(TRUST) + if not trusted then return end + local previous = device:get_field(MOTION) + local check = device:get_field(CHECK) + if check then + check.mode = "motion" + check.observations = 0 + check.value = nil + end + if previous then + -- A new target is not evidence of progress; keep the original silence timer. + previous.start = trusted.level + previous.last = trusted.level + previous.target = target + return + end + local motion = { + start = trusted.level, last = trusted.level, target = target, + } + device:set_field(MOTION, motion) + renew_motion(driver, device, motion) +end + +function confirmation.pause_sent(driver, device) + local check = device:get_field(CHECK) + if check and check.mode == "motion" then confirmation.cancel(device); check = nil end + untrust(device, "stop requires a new static observation") + if check then + check.observations = 0 + check.value = nil + check.pending = false + check.next_read() + end + confirmation.start(driver, device, "main") +end + +local function publish_progress(device, level, direction) + cancel_partial(device) + device:set_field(TRUST, { level = level }) + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + local shade = capabilities.windowShade.windowShade + device:emit_event(direction > 0 and shade.opening() or shade.closing()) + local generation = { active = true } + device:set_field(PARTIAL_GENERATION, generation) + local timer = device.thread:call_with_delay(PARTIAL_SETTLE_SECONDS, function() + if not generation.active then return end + if device:get_field(PARTIAL_GENERATION) ~= generation then return end + generation.active = false + device:set_field(PARTIAL_GENERATION, nil) + device:set_field(PARTIAL_TIMER, nil) + if device:get_field(TRUST) then device:emit_event(shade.partially_open()) end + end) + device:set_field(PARTIAL_TIMER, timer) +end + +function confirmation.position_handler(driver, device, value, zb_rx) + local endpoint = zb_rx.address_header.src_endpoint.value + if endpoint ~= device:get_endpoint_for_component_id("main") then return end + local level = value.value + local check = device:get_field(CHECK) + if type(level) ~= "number" or level < 0 or level > 100 or level % 1 ~= 0 then + if check and check.mode == "motion" then confirmation.cancel(device); check = nil end + untrust(device, level == 0xFF and "device position unavailable" or "invalid position value") + if check then check.observations = 0; check.value = nil end + passive_check(driver, device) + return + end + local is_read = zb_rx.body.zcl_header.cmd.value == 0x01 + local motion = device:get_field(MOTION) + if motion and device:get_field(TRUST) then + if level == motion.target then + end_motion(driver, device, "target report requires static read confirmation") + return + end + local direction = motion.target > motion.start and 1 or -1 + local in_range = direction > 0 and level >= motion.last and level < motion.target + or direction < 0 and level <= motion.last and level > motion.target + if not in_range then + end_motion(driver, device, "position conflicts with the current motion") + return + end + if is_read or level ~= motion.last then confirmation.cancel(device) end + if level ~= motion.last then + motion.last = level + renew_motion(driver, device, motion) + publish_progress(device, level, direction) + end + return + end + + local trusted = device:get_field(TRUST) + if not is_read then + if trusted and level ~= trusted.level then untrust(device, "unsolicited conflicting position") end + check = device:get_field(CHECK) + if check and check.value ~= nil and check.value ~= level then + check.observations = 0 + check.value = nil + end + if not device:get_field(TRUST) then passive_check(driver, device) end + return -- Reports alone cannot establish or restore trust. + end + + check = device:get_field(CHECK) + if not check or check.mode ~= "static" or not check.pending then return end + local sequence = zb_rx.body.zcl_header.seqno.value + if check.seen_sequences[sequence] then return end + check.seen_sequences[sequence] = true -- Session-local deduplication, never sequence ordering. + check.pending = false + check.timer_token = nil + cancel_timer(device, check.timer) + check.timer = nil + if trusted and level ~= trusted.level then untrust(device, "read differs from the trusted position") end + if check.value == level then check.observations = check.observations + 1 + else check.value = level; check.observations = 1 end + if check.observations >= 2 then publish_static(device, level) + else check.next_read() end +end + +function confirmation.initialize(driver, device) + confirmation.cancel(device) + untrust(device, "startup requires read confirmation") + device:set_field(AUTO_RECOVERY, 1) + start_check(driver, device, "main", "static") +end + +function confirmation.removed(driver, device) + confirmation.cancel(device) + device:set_field(TRUST, nil) + device:set_field(AUTO_RECOVERY, 0) + cancel_motion(device) + cancel_partial(device) + shade_utils.clear_step_target(device) +end + +return confirmation From c081c63b9fccca81b35365f0b65799abb6dca39e Mon Sep 17 00:00:00 2001 From: Zemismart <275927643+zemismart-dev@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:01:14 +0800 Subject: [PATCH 2/2] test: declare minimum API for ZM25Z step cases Match the stock step helper minimum API 15 for the thirteen tests that exercise named-argument command injection. Retain API 14 coverage for non-step removal callbacks, pure preset and zero-step behavior. No production driver, profile, fingerprint or installed package changes. --- ...test_zigbee_window_treatment_zemismart.lua | 41 ++++++++++++++++--- ...indow_treatment_zemismart_confirmation.lua | 34 +++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua index 84857fd8ef..cf12c89be9 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua @@ -208,6 +208,23 @@ test.register_coroutine_test("Target level control and actual position reports a end end) +test.register_coroutine_test("Target preset retains standard commands without steps", function() + confirm_position(43) + command("windowShadePreset", "setPresetPosition", { 40 }) + expect_event(mock_device, capabilities.windowShadePreset.position(40)) + test.wait_for_events() + command("windowShadePreset", "presetPosition") + test.socket.zigbee:__expect_send({ mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 40) }) + expect_position_read() + test.wait_for_events() + assert(mock_device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) + report_level(42) + report_level(40) + assert(mock_device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 40) +end) + +-- The shared step helper's named-argument injection requires capability-aware API 15. test.register_coroutine_test("Target preset and relative step retain standard commands", function() confirm_position(50) command("windowShadePreset", "setPresetPosition", { 40 }) @@ -221,7 +238,7 @@ test.register_coroutine_test("Target preset and relative step retain standard co report_level(45) report_level(40) step_and_expect(10, 50) -end) +end, { min_api_version = 15 }) test.register_coroutine_test("Existing battery profiles retain percentage and voltage reporting", function() test.socket.zigbee:__queue_receive({ battery_device.id, @@ -247,7 +264,7 @@ test.register_coroutine_test("Absolute movement ends the previous relative-step test.wait_for_events() report_level(50) step_and_expect(-10, 40) -end) +end, { min_api_version = 15 }) test.register_coroutine_test("Rapid relative steps accumulate despite intermediate reports", function() report_level(50) @@ -255,7 +272,7 @@ test.register_coroutine_test("Rapid relative steps accumulate despite intermedia report_level(52) step_and_expect(-5, 55) step_and_expect(15, 70) -end) +end, { min_api_version = 15 }) local motion_commands = { { name = "open", capability = "windowShade", zigbee = "UpOrOpen", actual = 70 }, @@ -279,7 +296,7 @@ for _, motion in ipairs(motion_commands) do test.wait_for_events() report_level(motion.actual) step_and_expect(-10, motion.actual - 10) - end) + end, { min_api_version = 15 }) end test.register_coroutine_test("Refresh and preset configuration preserve relative-step accumulation", @@ -292,7 +309,7 @@ test.register_coroutine_test("Refresh and preset configuration preserve relative expect_event(mock_device, capabilities.windowShadePreset.position(40)) test.wait_for_events() step_and_expect(-5, 55) - end) + end, { min_api_version = 15 }) test.register_coroutine_test("Interrupted step timers cannot clear a later sequence", function() report_level(50) @@ -324,6 +341,18 @@ test.register_coroutine_test("Interrupted step timers cannot clear a later seque test.wait_for_events() report_level(45) step_and_expect(-5, 40) +end, { min_api_version = 15 }) + +test.register_coroutine_test("Zero step sends nothing from a confirmed position", function() + confirm_position(43) + command("statelessWindowShadeLevelStep", "stepShadeLevel", { 0 }) + test.wait_for_events() + assert((require "zemismart.position_confirmation").position_is_known(mock_device)) + assert(mock_device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) + assert(mock_device:get_field("_zemismartPositionCheck") == nil) + assert(mock_device:get_field("_zemismartMotionContext") == nil) + assert(mock_device:get_field("_latestTargetLevel") == nil) + assert(mock_device:get_field("_zemismartStepDispatch") == nil) end) test.register_coroutine_test("Zero relative steps preserve the sequence and clear the dispatch marker", @@ -335,7 +364,7 @@ test.register_coroutine_test("Zero relative steps preserve the sequence and clea assert(mock_device:get_field("_latestTargetLevel") == 60) assert(mock_device:get_field("_zemismartStepDispatch") == nil) step_and_expect(-5, 55) - end) + end, { min_api_version = 15 }) test.register_coroutine_test("Failed internal step dispatch leaves no stale target or marker", function() report_level(50) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua index 7dc96100ea..9457c63f3d 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua @@ -629,6 +629,34 @@ test.register_coroutine_test("A direction-conflicting report is quarantined with assert(device:get_latest_state("main", "windowShadeLevel", "shadeLevel") == 43) end) +test.register_coroutine_test("Removal invalidates non-step timers through SDK deletion", function() + helpers.confirm(43) + local callbacks, restore = capture_delayed_callbacks() + helpers.command("windowShade", "open") + expect_motion("UpOrOpen") + helpers.expect_read() + test.wait_for_events() + helpers.report(44, 1) + helpers.expect_event(capabilities.windowShadeLevel.shadeLevel(44)) + helpers.expect_event(shade.opening()) + test.wait_for_events() + start_refresh() + restore() + local scheduled = {} + for _, entry in ipairs(callbacks) do scheduled[entry.seconds] = true end + assert(scheduled[2] and scheduled[10] and scheduled[30] and not scheduled[15]) + assert(device:get_field(CHECK) ~= nil and device:get_field(MOTION) ~= nil) + assert(device:get_field("partial_open_timer") ~= nil) + test.socket.device_lifecycle:__queue_receive({ device.id, "removed" }) + test.wait_for_events() + for _, entry in ipairs(callbacks) do + local ok, err = pcall(entry.callback) + assert(ok, "callback touched a deleted device: " .. tostring(err)) + end + helpers.advance(60) +end) + +-- Nonzero steps depend on the shared helper's capability-aware API 15 injection. test.register_coroutine_test("Removal invalidates round total motion partial and step callbacks", function() helpers.confirm(43) local callbacks, restore = capture_delayed_callbacks() @@ -658,7 +686,7 @@ test.register_coroutine_test("Removal invalidates round total motion partial and assert(ok, "callback touched a deleted device: " .. tostring(err)) end helpers.advance(60) -- Do not access the device after SDK invalidates it. -end) +end, { min_api_version = 15 }) test.register_coroutine_test("Already-complete step timer cancellation still invalidates saved fields", function() @@ -686,7 +714,7 @@ test.register_coroutine_test("Already-complete step timer cancellation still inv assert(old_generation.active == false) stale() test.wait_for_events() - end) + end, { min_api_version = 15 }) test.register_coroutine_test("Replacing a completed step timer cannot let its old callback clear the new target", function() @@ -717,7 +745,7 @@ test.register_coroutine_test("Replacing a completed step timer cannot let its ol assert(device:get_field("_latestTargetLevel") == 54) assert(device:get_field("_targetLevelTimeOut") == new_timer) assert(device:get_field("_targetLevelGeneration").active) - end) + end, { min_api_version = 15 }) test.register_coroutine_test("Battery profile retains its original initialization without trust reads", function()