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..cf12c89be9 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart.lua @@ -0,0 +1,389 @@ +-- 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 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 }) + 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, { 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, + 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, { min_api_version = 15 }) + +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, { min_api_version = 15 }) + +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, { min_api_version = 15 }) +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, { min_api_version = 15 }) + +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, { 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", + 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, { min_api_version = 15 }) + +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..9457c63f3d --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_zemismart_confirmation.lua @@ -0,0 +1,756 @@ +-- 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 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() + 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, { min_api_version = 15 }) + +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, { min_api_version = 15 }) + +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, { min_api_version = 15 }) + +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