Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ components:
version: 1
- id: windowShadeLevel
version: 1
- id: statelessWindowShadeLevelStep
version: 1
- id: windowShadePreset
version: 1
- id: firmwareUpdate
Expand Down
41 changes: 41 additions & 0 deletions drivers/Unofficial/tuya-zigbee/src/curtain/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ local packet_id = 0
local PRESET_LEVEL = 50
local PRESET_LEVEL_KEY = "_presetLevel"

-- When the curtain is moving, LATEST_TARGET_LEVEL is used to store the latest target position value,
-- which will be cleared after 3 seconds timeout
local LATEST_TARGET_LEVEL = "_latestTargetLevel"
-- Field name for the timer that clears the target position after timeout
local TARGET_LEVEL_TIME_OUT = "_targetLevelTimeOut"
-- Timeout for the stateless step to accumulate
local TARGET_LEVEL_TIME_OUT_SECONDS = 3

local FINGERPRINTS = {
{ mfr = "_TZE284_nladmfvf", model = "TS0601"}
}
Expand Down Expand Up @@ -153,6 +161,36 @@ local function tuya_cluster_handler(driver, device, zb_rx)
end
end

local function handle_step_shade_level(driver, device, command)
local step = command.args.stepSize or command.args[1] or (command.positional_args and command.positional_args.stepSize)

-- When LATEST_TARGET_LEVEL is empty, it means the curtain motor is not moving,
-- and capabilities.windowShadeLevel.shadeLevel is used as the reference for position offset calculation;
-- when LATEST_TARGET_LEVEL is not empty, it means the curtain motor is moving,
-- and LATEST_TARGET_LEVEL is used as the reference for position offset calculation to obtain a more accurate position calculation.
local latest_target_level = device:get_field(LATEST_TARGET_LEVEL)
local current_level = latest_target_level or
device:get_latest_state("main", capabilities.windowShadeLevel.ID,
capabilities.windowShadeLevel.shadeLevel.NAME) or 0
Comment on lines +172 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, if the curtain is initially at level 30 and receives a +10 command, setting the target_level to 40, and then receives a +2 command when the actual curtain position reaches 35, the current_level might still be 30. If the current_level were used as the baseline and the target_level were set to 32, the motor would reverse. Therefore, the target_level is calculated based on the saved LATEST_TARGET_LEVEL and is set to 42.


local target_level = utils.clamp_value(current_level + step, 0, 100)
target_level = utils.round(target_level)
-- Cancel any previous timer and set a 3 second timeout timer to ensure 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
if device:get_field(TARGET_LEVEL_TIME_OUT) then
device.thread:cancel_timer(device:get_field(TARGET_LEVEL_TIME_OUT))
end
local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d)
device:set_field(LATEST_TARGET_LEVEL, nil)
end)
device:set_field(TARGET_LEVEL_TIME_OUT, timer)
device:set_field(LATEST_TARGET_LEVEL, target_level)

local new_command = { args = { shadeLevel = target_level }, component = command.component }
window_shade_level(driver, device, new_command)
end

local tuya_curtain_driver = {
NAME = "tuya curtain",
lifecycle_handlers = {
Expand All @@ -170,6 +208,9 @@ local tuya_curtain_driver = {
[capabilities.windowShadeLevel.ID] = {
[capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level
},
[capabilities.statelessWindowShadeLevelStep.ID] = {
[capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = handle_step_shade_level
},
[capabilities.windowShadePreset.ID] = {
[capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset,
[capabilities.windowShadePreset.commands.setPresetPosition.NAME] = set_preset_position_cmd
Expand Down
166 changes: 165 additions & 1 deletion drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,168 @@ test.register_message_test(
}
}
)
test.run_registered_tests()

test.register_message_test(
"Handle Window Shade step level command - step up",
{
{
channel = "capability",
direction = "receive",
message = { mock_simple_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 } }
},
{
channel = "zigbee",
direction = "send",
-- For _TZE284_nladmfvf, level is inverted: 100 - 10 = 90 (0x5A)
message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x5a', 0) }
}
}
)

test.register_message_test(
"Handle Window Shade step level command - step down",
{
{
channel = "capability",
direction = "receive",
message = { mock_simple_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -10 }, stepSize = -10 } }
},
{
channel = "zigbee",
direction = "send",
-- For _TZE284_nladmfvf, level is inverted: 100 - 0 = 100 (0x64), but clamped to 0 so 100 - 0 = 100
message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x64', 0) }
}
}
)

test.register_coroutine_test(
"Handle Window Shade step level command - step up from current level",
function()
-- First, simulate a current shade level of 40
test.socket.zigbee:__queue_receive({
mock_simple_device.id,
tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0x01)
})
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40))
)
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open"))
)
test.wait_for_events()

-- Then send step command with stepSize 20, should result in level 60
-- For _TZE284_nladmfvf, level is inverted: 100 - 60 = 40 (0x28)
test.socket.capability:__queue_receive({
mock_simple_device.id,
{ capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 }, stepSize = 20 }
})
test.socket.zigbee:__expect_send({
mock_simple_device.id,
tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0)
})
end
)

test.register_coroutine_test(
"Handle Window Shade step level command - step up clamped to 100",
function()
-- First, simulate a current shade level of 90
test.socket.zigbee:__queue_receive({
mock_simple_device.id,
tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x5a', 0x01)
})
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(90))
)
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open"))
)
test.wait_for_events()

-- Then send step command with stepSize 20, should be clamped to 100
-- For _TZE284_nladmfvf, level is inverted: 100 - 100 = 0 (0x00)
test.socket.capability:__queue_receive({
mock_simple_device.id,
{ capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 }, stepSize = 20 }
})
test.socket.zigbee:__expect_send({
mock_simple_device.id,
tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x00', 0)
})
end
)

test.register_coroutine_test(
"Handle Window Shade step level command - step down clamped to 0",
function()
-- First, simulate a current shade level of 10
test.socket.zigbee:__queue_receive({
mock_simple_device.id,
tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x0a', 0x01)
})
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(10))
)
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open"))
)
test.wait_for_events()

-- Then send step command with stepSize -20, should be clamped to 0
-- For _TZE284_nladmfvf, level is inverted: 100 - 0 = 100 (0x64)
test.socket.capability:__queue_receive({
mock_simple_device.id,
{ capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 }, stepSize = -20 }
})
test.socket.zigbee:__expect_send({
mock_simple_device.id,
tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x64', 0)
})
end
)

test.register_coroutine_test(
"Handle Window Shade step level command - uses latest_target_level when available",
function()
-- First, simulate a current shade level of 40
test.socket.zigbee:__queue_receive({
mock_simple_device.id,
tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0x01)
})
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40))
)
test.socket.capability:__expect_send(
mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open"))
)
test.wait_for_events()

-- Send first step command to set latest_target_level to 50
-- For _TZE284_nladmfvf, level is inverted: 100 - 50 = 50 (0x32)
test.socket.capability:__queue_receive({
mock_simple_device.id,
{ capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 }
})
test.socket.zigbee:__expect_send({
mock_simple_device.id,
tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x32', 0)
})
test.wait_for_events()

-- Send second step command before timeout - should use latest_target_level (50) as base
-- Target = 50 + 10 = 60, For _TZE284_nladmfvf: 100 - 60 = 40 (0x28)
-- Packet ID is 1 because it was incremented after the first step command
test.socket.capability:__queue_receive({
mock_simple_device.id,
{ capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 }
})
test.socket.zigbee:__expect_send({
mock_simple_device.id,
tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 1)
})
end
)

test.run_registered_tests()