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,204 @@
-- 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

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.

This is 0x6 instead of 0x4 (what the base aqara roller shade driver has. Is that intentional, or should they match for a consistent experience?

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.

Yes, this is intentional. According to the manufacturer's technical guide, the T1S model requires the value to be 0x6. We have also verified this expected behavior using the physical test samples.

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)
Comment thread
cjswedes marked this conversation as resolved.
Resolved
-- 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 thread
cjswedes marked this conversation as resolved.
Resolved

local function window_shade_level_cmd(driver, device, command)
-- Cannot be controlled if not initialized
if is_not_init(device) then return end
aqara_utils.shade_level_cmd(driver, device, command)
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)
aqara_utils.emit_shade_level_event(device, value)
local level = utils.round(value.value)
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.
Comment thread
cjswedes marked this conversation as resolved.
Resolved
-- 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,

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.

This will result in a lot of reads if the controller is not yet initialized; a single move usually results in multiple current position percent reports from the device. It seems like there could be a situation where the start/stop limits of the controller are never set by the user, and so initialization never gets set, and this always issues unnecessary reads.

Currently the driver is only reading init state from the refresh handler. Is this read needed here, or can we remove it entirely? At the very least I think we need some mechanism to reduce these reads to a single read while the controller is moving the blind and it is generating reports.

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.

Due to the nature of the roller shade, initialization (setting the limits) is mandatory, not optional.

Even if a user attempts to operate the device without initialization, level control is not supported. In this uninitialized state, the device only sends a report after the motor stops completely, so the excessive read scenario you are concerned about will rarely occur. (Since the 0%-100% range is unknown without initialization, the device does not generate intermediate position reports while moving.)

This read logic was added as a safeguard to handle edge cases where the initialization state fails to update properly. Could you please take this context into consideration and review it again?

We need to consider whether prioritizing the accurate display of the current state outweighs the minimal risk of additional traffic.

INIT_STATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE))
end
end
Comment thread
cjswedes marked this conversation as resolved.
Resolved

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
device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID,
DIRECTION_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Boolean, reverseRollerShadeDirPrefValue))
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