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
5 changes: 5 additions & 0 deletions drivers/SmartThings/zigbee-window-treatment/fingerprints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ zigbeeManufacturer:
manufacturer: LUMI
model: lumi.curtain.agl001
deviceProfileName: window-treatment-aqara-curtain-driver-e1
- id: "LUMI/lumi.curtain.vagl02"
deviceLabel: Roller Shade Controller T1S
manufacturer: LUMI
model: lumi.curtain.vagl02
deviceProfileName: window-treatment-aqara-roller-shade-rotate
- id: "REXENSE/KG0001"
deviceLabel: Window Treatment
manufacturer: REXENSE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ local FINGERPRINTS = {
{ mfr = "LUMI", model = "lumi.curtain" },
{ mfr = "LUMI", model = "lumi.curtain.v1" },
{ mfr = "LUMI", model = "lumi.curtain.aq2" },
{ mfr = "LUMI", model = "lumi.curtain.agl001" }
{ mfr = "LUMI", model = "lumi.curtain.agl001" },
{ mfr = "LUMI", model = "lumi.curtain.vagl02" }
}

return FINGERPRINTS
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
-- Copyright 2025 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local MODELS = {
["lumi.curtain.aq2"] = true,
["lumi.curtain.vagl02"] = true
}
local function roller_shade_can_handle(opts, driver, device, ...)
if device:get_model() == "lumi.curtain.aq2" then
if MODELS[device:get_model()] then
return true, require("aqara.roller-shade")
end
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ local aqara_roller_shade_handler = {
}
},
can_handle = require("aqara.roller-shade.can_handle"),
sub_drivers = require("aqara.roller-shade.sub_drivers"),
}

return aqara_roller_shade_handler
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local lazy_load_if_possible = require "lazy_load_subdriver"
local sub_drivers = {
lazy_load_if_possible("aqara.roller-shade.t1s"),
}
return sub_drivers
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local function t1s_can_handle(opts, driver, device, ...)
if device:get_model() == "lumi.curtain.vagl02" then
return true, require("aqara.roller-shade.t1s")
end
return false
end

return t1s_can_handle
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local capabilities = require "st.capabilities"
local clusters = require "st.zigbee.zcl.clusters"
local cluster_base = require "st.zigbee.cluster_base"
local FrameCtrl = require "st.zigbee.zcl.frame_ctrl"
local data_types = require "st.zigbee.data_types"
local aqara_utils = require "aqara/aqara_utils"
local window_treatment_utils = require "window_treatment_utils"
local utils = require "st.utils"

local Groups = clusters.Groups
local WindowCovering = clusters.WindowCovering
local AnalogOutput = clusters.AnalogOutput

local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"]
local reverseRollerShadeDir = capabilities["stse.reverseRollerShadeDir"]
local shadeRotateState = capabilities["stse.shadeRotateState"]
local setRotateStateCommandName = "setRotateState"

local MULTISTATE_CLUSTER_ID = 0x0013
local MULTISTATE_ATTRIBUTE_ID = 0x0055
local INIT_STATE_ATTRIBUTE_ID = 0x0407
local DIRECTION_ATTRIBUTE_ID = 0x0400

local ROTATE_UP_VALUE = 0x0006
local ROTATE_DOWN_VALUE = 0x0005
local SHADE_STATE_STOP = 2
local SHADE_STATE_OPEN = 1
local SHADE_STATE_CLOSE = 0
local SHADE_STATE_BLOCKED = 4

local function is_not_init(device)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is_not_init returns false (control allowed) until the device has reported INIT_STATE_ATTRIBUTE_ID at least once. Allowing commands before the init state is reported is the opposite of the existing roller shade, which prevents commands until the device reports as initialized. Why is this device different? If they can function the same that would be a better experience for users.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The requested changes have been applied.

-- True until the device reports that its travel points have been initialized
return device:get_latest_state("main", initializedStateWithGuide.ID, initializedStateWithGuide.initializedStateWithGuide.NAME) ~=
initializedStateWithGuide.initializedStateWithGuide.initialized.NAME
end
Comment on lines +34 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
local function is_not_init(device)
if not device:get_field(INIT_STATE_REPORTED) then return false end
-- True if uninitialized; false if already initialized
local initialized = device:get_latest_state("main", initializedStateWithGuide.ID,
initializedStateWithGuide.initializedStateWithGuide.NAME) or 0
local ret = true
if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then ret = false end
return ret
end
local function is_not_init(device)
-- Only gate commands once the device has reported its init state at least once
return device:get_field(INIT_STATE_REPORTED) and
device:get_latest_state("main", initializedStateWithGuide.ID,
initializedStateWithGuide.initializedStateWithGuide.NAME) ~=
initializedStateWithGuide.initializedStateWithGuide.initialized.NAME
end

nit: Can be compressed/simplified

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have updated the function based on your feedback.


local function window_shade_level_cmd(driver, device, command)
-- Cannot be controlled if not initialized
if is_not_init(device) then return end
local level = command.args.shadeLevel > 100 and 100 or utils.round(command.args.shadeLevel)
device:emit_event(capabilities.windowShadeLevel.shadeLevel(level))
device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level))
end

local function window_shade_open_cmd(driver, device, command)
-- Cannot be controlled if not initialized
if is_not_init(device) then return end
device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 100))
end

local function window_shade_close_cmd(driver, device, command)
-- Cannot be controlled if not initialized
if is_not_init(device) then return end
device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 0))
end

local function set_rotate_command_handler(driver, device, command)
device:emit_event(shadeRotateState.rotateState.idle({ state_change = true, visibility = { displayed = false } })) -- update UI

-- Cannot be controlled if not initialized
if is_not_init(device) then return end
local state = command.args.state
if state == "rotateUp" then
local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID,
MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_UP_VALUE)
message.body.zcl_header.frame_ctrl = FrameCtrl(0x10)
device:send(message)
elseif state == "rotateDown" then
local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID,
MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_DOWN_VALUE)
message.body.zcl_header.frame_ctrl = FrameCtrl(0x10)
device:send(message)
end
end

local function do_refresh(driver, device)
device:send(AnalogOutput.attributes.PresentValue:read(device))
device:send(cluster_base.read_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, INIT_STATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE))
end

local function init_state_handler(driver, device, value, zb_rx)
local state = value.value == 0x02 and initializedStateWithGuide.initializedStateWithGuide.initialized() or
initializedStateWithGuide.initializedStateWithGuide.notInitialized()
device:emit_event(state)
end

local function shade_level_report_handler(driver, device, value, zb_rx)
local level = value.value > 100 and 100 or utils.round(value.value)
device:emit_event(capabilities.windowShadeLevel.shadeLevel(level))
if level >= 100 then
device:emit_event(capabilities.windowShade.windowShade.open())
elseif level <= 0 then
device:emit_event(capabilities.windowShade.windowShade.closed())
else
device:emit_event(capabilities.windowShade.windowShade.partially_open())
end
end

-- The position of this device is reported through the present value of the analog output
-- cluster, where 0 is fully closed and 100 is fully open. The lift percentage of the window
-- covering cluster is reported with the opposite polarity (100% = fully closed), so it is
-- ignored here to keep the parent Aqara handler from emitting an inverted shade level.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

current_position_lift_percentage_handler is never registered in zigbee_handlers. Lift-percentage reports therefore go to the parent aqara sub-driver's shade_level_report_legacy_handler, which emits the inverted level the comment is trying to prevent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please add a test to validate this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for pointing out the missing part. I have added the handler along with the relevant test cases.

-- The device does not report the init state when its travel points have been set, so it is
-- re-read whenever the shade moves while the device is still not initialized.
local function current_position_lift_percentage_handler(driver, device, value, zb_rx)
if is_not_init(device) then
device:send(cluster_base.read_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID,
INIT_STATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE))
end
end
Comment on lines +102 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Function is unused

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've updated it.


local function shade_state_report_handler(driver, device, value, zb_rx)
local state = value.value
-- update state ui
if state == SHADE_STATE_STOP or state == SHADE_STATE_BLOCKED then
-- read shade position to update the UI
device:send(AnalogOutput.attributes.PresentValue:read(device))
elseif state == SHADE_STATE_OPEN then
device:emit_event(capabilities.windowShade.windowShade.opening())
elseif state == SHADE_STATE_CLOSE then
device:emit_event(capabilities.windowShade.windowShade.closing())
end
end

local function device_info_changed(driver, device, event, args)
if device.preferences ~= nil then
local reverseRollerShadeDirPrefValue = device.preferences[reverseRollerShadeDir.ID]
if reverseRollerShadeDirPrefValue ~= nil and
reverseRollerShadeDirPrefValue ~= args.old_st_store.preferences[reverseRollerShadeDir.ID] then
local raw_value = reverseRollerShadeDirPrefValue
device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID,
DIRECTION_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Boolean, raw_value))
end
end
end

local function device_added(driver, device)
device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },
{ visibility = { displayed = false } }))
window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadeLevel,
capabilities.windowShadeLevel.shadeLevel.NAME, capabilities.windowShadeLevel.shadeLevel(0))
window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShade,
capabilities.windowShade.windowShade.NAME, capabilities.windowShade.windowShade.closed())
window_treatment_utils.emit_event_if_latest_state_missing(device, "main", initializedStateWithGuide,
initializedStateWithGuide.initializedStateWithGuide.NAME,
initializedStateWithGuide.initializedStateWithGuide.notInitialized())
device:emit_event(shadeRotateState.rotateState.idle({ visibility = { displayed = false } }))
device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID,
aqara_utils.PRIVATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint8, 1))

-- The direction is stored on the device and survives a re-pairing, so sync it with the preference
local reverse = device.preferences ~= nil and device.preferences[reverseRollerShadeDir.ID] == true
device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID,
DIRECTION_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Boolean, reverse))
end

local function do_configure(self, device)
device:configure()
device:send(Groups.server.commands.RemoveAllGroups(device))
do_refresh(self, device)
end

local aqara_roller_shade_t1s_handler = {
NAME = "Aqara T1S Blinder Controller Handler",
lifecycle_handlers = {
added = device_added,
doConfigure = do_configure,
infoChanged = device_info_changed
},
capability_handlers = {
[capabilities.windowShadeLevel.ID] = {
[capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd
},
[capabilities.windowShade.ID] = {
[capabilities.windowShade.commands.open.NAME] = window_shade_open_cmd,
[capabilities.windowShade.commands.close.NAME] = window_shade_close_cmd
},
[shadeRotateState.ID] = {
[setRotateStateCommandName] = set_rotate_command_handler
},
[capabilities.refresh.ID] = {
[capabilities.refresh.commands.refresh.NAME] = do_refresh
}
},
zigbee_handlers = {
attr = {
[aqara_utils.PRIVATE_CLUSTER_ID] = {
[INIT_STATE_ATTRIBUTE_ID] = init_state_handler,
},
[AnalogOutput.ID] = {
[AnalogOutput.attributes.PresentValue.ID] = shade_level_report_handler
},
[WindowCovering.ID] = {
[WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = current_position_lift_percentage_handler
},
[MULTISTATE_CLUSTER_ID] = {
[MULTISTATE_ATTRIBUTE_ID] = shade_state_report_handler
}
}
},
can_handle = require("aqara.roller-shade.t1s.can_handle"),
}

return aqara_roller_shade_t1s_handler
Loading
Loading