Summary
SmartWings Matter-over-Thread window coverings can reportedly remain stuck in the transient opening or closing state in SmartThings after the physical movement has completed. The shades move correctly, and a manual refresh updates the device to the correct final state, which suggests that the final position can be read correctly but is not always resolved automatically after movement.
The generic Matter Window Covering driver currently emits opening / closing from OperationalStatus, but does nothing when OperationalStatus == 0 (not moving). It therefore relies on a separate final CurrentPositionLiftPercent100ths / CurrentPositionTiltPercent100ths report to clear the transient state. If a device does not reliably send that final position report after a SmartThings-originated command, the UI can remain stuck.
A defensive improvement would be to use OperationalStatus == 0 as a trigger to read the current lift/tilt position, then let the existing position handler emit the final open, closed, or partially_open state. This avoids guessing the final state and makes the generic driver more tolerant of device/reporting differences.
Device involved in report
SmartWings Window Covering
Matter over Thread
Vendor ID: 0x146F
Product ID: 0x1001
Profile: window-covering-battery
The device is already included in the official Matter Window Covering fingerprints:
# SmartWings
- id: "5231/4097"
deviceLabel: SmartWings Window Covering
vendorId: 0x146F
productId: 0x1001
deviceProfileName: window-covering-battery
Community report
Original thread:
https://community.smartthings.com/t/smartwings-shades-matter-over-thread-stuck-on-opening-and-closing/309925
A user reports that 7 SmartWings Matter-over-Thread roller shades connected through an Aeotec v3 hub execute SmartThings commands correctly, but remain stuck in opening or closing in the SmartThings app after reaching the physical end position.
Reported details:
- Commands from the SmartThings app move the shades correctly.
- The shades physically reach 0% / 100%.
- The SmartThings app can remain stuck showing
opening or closing.
- The state may remain stuck for days.
- A manual refresh in the device detail screen updates the GUI to the correct final state.
- When the shades are controlled with the SmartWings remote, they reportedly show the final
open / closed state correctly in SmartThings.
- Another user reports that their SmartWings Thread shades work correctly with the current Matter Window Covering driver in the iOS app.
So this may not be a universal driver failure. It may depend on device firmware, reporting sequence, command source, hub/app path, Android/iOS behavior, or some combination of those. However, there appears to be a weak spot in the generic driver that could explain the reported behavior and that may be worth hardening.
Current driver behavior
The driver subscribes to and handles:
[clusters.WindowCovering.ID] = {
[clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] =
current_pos_handler(capabilities.windowShadeLevel.shadeLevel),
[clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths.ID] =
current_pos_handler(capabilities.windowShadeTiltLevel.shadeTiltLevel),
[clusters.WindowCovering.attributes.OperationalStatus.ID] =
current_status_handler,
}
The position handler already seems to do the right thing when a final position value is received. It converts the Matter CurrentPosition*Percent100ths value into the SmartThings level representation, stores the current lift/tilt position, and then emits the final shade state:
-- current lift/tilt percentage, changed to 100ths percent
local current_pos_handler = function(attribute)
return function(driver, device, ib, response)
if ib.data.value == nil then
return
end
local windowShade = capabilities.windowShade.windowShade
local position = 100 - math.floor(ib.data.value / 100)
local reverse = device:get_field(REVERSE_POLARITY)
device:emit_event_for_endpoint(ib.endpoint_id, attribute(position))
if attribute == capabilities.windowShadeLevel.shadeLevel then
device:set_field(CURRENT_LIFT, position)
else
device:set_field(CURRENT_TILT, position)
end
local lift_position = device:get_field(CURRENT_LIFT)
local tilt_position = device:get_field(CURRENT_TILT)
-- Update the window shade status according to the lift and tilt positions.
-- LIFT TILT Window Shade
-- 100 any Open
-- 1-99 any Partially Open
-- 0 1-100 Partially Open
-- 0 0 Closed
-- 0 nil Closed
-- nil 100 Open
-- nil 1-99 Partially Open
-- nil 0 Closed
--
-- Note that lift or tilt may be nil if either the window shade does not
-- support them or if they haven't been received from a device report yet.
if lift_position == nil then
if tilt_position == 0 then
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.open() or windowShade.closed()
)
elseif tilt_position == 100 then
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.closed() or windowShade.open()
)
else
device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open())
end
elseif lift_position == 100 then
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.closed() or windowShade.open()
)
elseif lift_position > 0 then
device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open())
elseif lift_position == 0 then
if tilt_position == nil or tilt_position == 0 then
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.open() or windowShade.closed()
)
elseif tilt_position > 0 then
device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open())
end
end
end
end
The potential issue is in current_status_handler().
Original current_status_handler()
The current generic handler is:
-- checks the current position of the shade
local function current_status_handler(driver, device, ib, response)
local windowShade = capabilities.windowShade.windowShade
local reverse = device:get_field(REVERSE_POLARITY)
local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL
if state == 1 then -- opening
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.closing() or windowShade.opening()
)
elseif state == 2 then -- closing
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.opening() or windowShade.closing()
)
elseif state ~= 0 then -- unknown
device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown())
end
end
This means:
OperationalStatus = 1 -> emit opening
OperationalStatus = 2 -> emit closing
OperationalStatus != 0 -> emit unknown
OperationalStatus = 0 -> do nothing
So when the device reports that movement has stopped (OperationalStatus == 0), the generic driver does not emit a final shade state and does not actively read the final position. It relies on a separate CurrentPositionLiftPercent100ths and/or CurrentPositionTiltPercent100ths report to arrive afterward.
That works if the device reliably reports final position after movement. It can fail if a device reports opening / closing, later reports or internally reaches not moving, but does not produce a final position report on the subscription path after a SmartThings-originated command.
In that case, the SmartThings state can remain at the last transient value: opening or closing.
The manual refresh behavior in the report fits this theory: refresh forces a fresh read of the subscribed attributes, including the current position, and then the existing current_pos_handler() can emit the correct final open, closed, or partially_open state.
Why this may be device-dependent
This is probably not only one side's fault.
The device should ideally report its final position reliably after a movement command. If it does not, or if it reports differently depending on whether movement was initiated by the SmartThings app or by the manufacturer's remote, that is a device/reporting behavior issue.
However, the generic driver is also somewhat optimistic. Once it has emitted opening or closing, it assumes that a final position report will arrive and clear the transient state. If that report does not arrive, the driver currently has no fallback when it sees OperationalStatus == 0.
So the device may be exposing the problem, but the driver could be made more defensive.
Comparison with existing subdriver
There is already a special subdriver:
src/sub_drivers/matter-window-covering-position-updates-while-moving
It currently appears to be selected only for:
local SUB_WINDOW_COVERING_VID_PID = {
{0x10e1, 0x1005} -- VDA
}
and can_handle.lua loads the subdriver only when the Matter vendor_id and product_id match this list:
if device.manufacturer_info.vendor_id == v[1] and
device.manufacturer_info.product_id == v[2] then
return true, require("sub_drivers.matter-window-covering-position-updates-while-moving")
end
SmartWings 0x146F / 0x1001 is therefore not handled by that subdriver.
The subdriver handles the interaction between OperationalStatus and CurrentPositionLiftPercent100ths more defensively than the generic driver. It uses a small state machine:
local StateMachineEnum = {
STATE_IDLE = 0x00,
STATE_MOVING = 0x01,
STATE_OPERATIONAL_STATE_FIRED = 0x02,
STATE_CURRENT_POSITION_FIRED = 0x03
}
In particular, when it is already in STATE_MOVING and receives OperationalStatus == 0, it records that the operational state has fired:
elseif state_machine == StateMachineEnum.STATE_MOVING then
if state == 0 then -- not moving
device:set_field(STATE_MACHINE, StateMachineEnum.STATE_OPERATIONAL_STATE_FIRED)
And if the position report arrived first, it can later combine the stopped state with the stored/latest position to emit the final state:
elseif state_machine == StateMachineEnum.STATE_CURRENT_POSITION_FIRED then
if state == 0 then -- not moving
if position == 100 then
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and attr.closed() or attr.open()
)
elseif position == 0 then
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and attr.open() or attr.closed()
)
else
device:emit_event_for_endpoint(ib.endpoint_id, attr.partially_open())
end
else
device:emit_event_for_endpoint(ib.endpoint_id, attr.unknown())
end
device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE)
end
So the subdriver is more robust about report ordering:
Generic driver:
opening/closing -> waits for separate final position report -> may stay stuck
Subdriver:
opening/closing -> tracks movement, stop, and position sequencing -> can resolve final state when both pieces are available
However, even this subdriver still appears to depend on receiving a final position report at some point. It does not actively read the final position when movement stops.
Suggested improvement
When OperationalStatus == 0, the generic driver could actively read the current lift and/or tilt position. The existing current_pos_handler() would then remain the source of truth for the final state.
This would avoid directly guessing whether stopped means open, closed, or partially_open. Instead, stopped would only be used as a trigger to ask the device for its actual position.
The flow would become:
SmartThings command
-> shade starts moving
-> OperationalStatus = opening/closing
-> driver emits opening/closing
-> shade stops
-> OperationalStatus = 0
-> driver reads CurrentPositionLiftPercent100ths / CurrentPositionTiltPercent100ths
-> current_pos_handler emits open / closed / partially_open
This is intentionally different from simply emitting open or closed in current_status_handler(). OperationalStatus == 0 only says "not moving"; it does not say where the shade stopped.
Minimal SmartWings-specific test change
For a lift-only SmartWings roller shade test driver, the smallest possible change would be:
-- checks the current position of the shade
local function current_status_handler(driver, device, ib, response)
local windowShade = capabilities.windowShade.windowShade
local reverse = device:get_field(REVERSE_POLARITY)
local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL
if state == 1 then -- opening
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.closing() or windowShade.opening()
)
elseif state == 2 then -- closing
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.opening() or windowShade.closing()
)
elseif state == 0 then -- stopped / not moving
device:send(
clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:read(
device,
ib.endpoint_id
)
)
else -- unknown
device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown())
end
end
This should cause the response to be dispatched to the existing CurrentPositionLiftPercent100ths handler, not back into current_status_handler(), unless the device separately sends another OperationalStatus report.
More generic version
For the driver, a more generic version should probably check whether the endpoint supports lift and/or tilt before reading those attributes:
local function endpoint_supports_window_covering_feature(device, endpoint_id, feature)
local eps = device:get_endpoints(
clusters.WindowCovering.ID,
{feature_bitmap = feature}
) or {}
for _, ep in ipairs(eps) do
if ep == endpoint_id then
return true
end
end
return false
end
local function read_current_window_covering_position(device, endpoint_id)
local read_req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {})
local has_read = false
if endpoint_supports_window_covering_feature(
device,
endpoint_id,
clusters.WindowCovering.types.Feature.LIFT
) then
read_req:merge(
clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:read(
device,
endpoint_id
)
)
has_read = true
end
if endpoint_supports_window_covering_feature(
device,
endpoint_id,
clusters.WindowCovering.types.Feature.TILT
) then
read_req:merge(
clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths:read(
device,
endpoint_id
)
)
has_read = true
end
if has_read then
device:send(read_req)
end
end
Then the handler change can stay minimal:
-- checks the current position of the shade
local function current_status_handler(driver, device, ib, response)
local windowShade = capabilities.windowShade.windowShade
local reverse = device:get_field(REVERSE_POLARITY)
local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL
if state == 1 then -- opening
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.closing() or windowShade.opening()
)
elseif state == 2 then -- closing
device:emit_event_for_endpoint(
ib.endpoint_id,
reverse and windowShade.opening() or windowShade.closing()
)
elseif state == 0 then -- stopped / not moving
-- Do not emit a final state directly. "Stopped" only means that the
-- covering is no longer moving; it does not tell us whether it stopped
-- open, closed, or partially open.
--
-- Read the actual final position and let current_pos_handler emit the
-- final windowShade state.
read_current_window_covering_position(device, ib.endpoint_id)
else -- unknown
device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown())
end
end
Why not call device:refresh() here?
Calling device:refresh() would likely read all subscribed attributes, including OperationalStatus again. That may be broader than necessary and could potentially cause repeated OperationalStatus == 0 handling.
Reading only the position attributes is more targeted:
- It avoids guessing the final state.
- It reuses the existing position handler.
- It avoids a wider refresh.
- It should be safe for devices where the final report was simply missed or not sent.
Alternative / additional possible fix
SmartWings could also be added to the existing matter-window-covering-position-updates-while-moving subdriver:
local SUB_WINDOW_COVERING_VID_PID = {
{0x10e1, 0x1005}, -- VDA
{0x146F, 0x1001}, -- SmartWings Window Covering
}
This may help if the problem is primarily report ordering.
However, this may not be sufficient if the device does not send a final CurrentPositionLiftPercent100ths report at all after SmartThings-originated commands. In that case, the explicit read on OperationalStatus == 0 would still be the more robust fix.
Summary
SmartWings Matter-over-Thread window coverings can reportedly remain stuck in the transient
openingorclosingstate in SmartThings after the physical movement has completed. The shades move correctly, and a manual refresh updates the device to the correct final state, which suggests that the final position can be read correctly but is not always resolved automatically after movement.The generic Matter Window Covering driver currently emits
opening/closingfromOperationalStatus, but does nothing whenOperationalStatus == 0(not moving). It therefore relies on a separate finalCurrentPositionLiftPercent100ths/CurrentPositionTiltPercent100thsreport to clear the transient state. If a device does not reliably send that final position report after a SmartThings-originated command, the UI can remain stuck.A defensive improvement would be to use
OperationalStatus == 0as a trigger to read the current lift/tilt position, then let the existing position handler emit the finalopen,closed, orpartially_openstate. This avoids guessing the final state and makes the generic driver more tolerant of device/reporting differences.Device involved in report
SmartWings Window Covering
Matter over Thread
Vendor ID:
0x146FProduct ID:
0x1001Profile:
window-covering-batteryThe device is already included in the official Matter Window Covering fingerprints:
Community report
Original thread:
https://community.smartthings.com/t/smartwings-shades-matter-over-thread-stuck-on-opening-and-closing/309925
A user reports that 7 SmartWings Matter-over-Thread roller shades connected through an Aeotec v3 hub execute SmartThings commands correctly, but remain stuck in
openingorclosingin the SmartThings app after reaching the physical end position.Reported details:
openingorclosing.open/closedstate correctly in SmartThings.So this may not be a universal driver failure. It may depend on device firmware, reporting sequence, command source, hub/app path, Android/iOS behavior, or some combination of those. However, there appears to be a weak spot in the generic driver that could explain the reported behavior and that may be worth hardening.
Current driver behavior
The driver subscribes to and handles:
The position handler already seems to do the right thing when a final position value is received. It converts the Matter
CurrentPosition*Percent100thsvalue into the SmartThings level representation, stores the current lift/tilt position, and then emits the final shade state:The potential issue is in
current_status_handler().Original
current_status_handler()The current generic handler is:
This means:
So when the device reports that movement has stopped (
OperationalStatus == 0), the generic driver does not emit a final shade state and does not actively read the final position. It relies on a separateCurrentPositionLiftPercent100thsand/orCurrentPositionTiltPercent100thsreport to arrive afterward.That works if the device reliably reports final position after movement. It can fail if a device reports
opening/closing, later reports or internally reachesnot moving, but does not produce a final position report on the subscription path after a SmartThings-originated command.In that case, the SmartThings state can remain at the last transient value:
openingorclosing.The manual refresh behavior in the report fits this theory: refresh forces a fresh read of the subscribed attributes, including the current position, and then the existing
current_pos_handler()can emit the correct finalopen,closed, orpartially_openstate.Why this may be device-dependent
This is probably not only one side's fault.
The device should ideally report its final position reliably after a movement command. If it does not, or if it reports differently depending on whether movement was initiated by the SmartThings app or by the manufacturer's remote, that is a device/reporting behavior issue.
However, the generic driver is also somewhat optimistic. Once it has emitted
openingorclosing, it assumes that a final position report will arrive and clear the transient state. If that report does not arrive, the driver currently has no fallback when it seesOperationalStatus == 0.So the device may be exposing the problem, but the driver could be made more defensive.
Comparison with existing subdriver
There is already a special subdriver:
src/sub_drivers/matter-window-covering-position-updates-while-movingIt currently appears to be selected only for:
and
can_handle.lualoads the subdriver only when the Mattervendor_idandproduct_idmatch this list:SmartWings
0x146F / 0x1001is therefore not handled by that subdriver.The subdriver handles the interaction between
OperationalStatusandCurrentPositionLiftPercent100thsmore defensively than the generic driver. It uses a small state machine:In particular, when it is already in
STATE_MOVINGand receivesOperationalStatus == 0, it records that the operational state has fired:And if the position report arrived first, it can later combine the stopped state with the stored/latest position to emit the final state:
So the subdriver is more robust about report ordering:
However, even this subdriver still appears to depend on receiving a final position report at some point. It does not actively read the final position when movement stops.
Suggested improvement
When
OperationalStatus == 0, the generic driver could actively read the current lift and/or tilt position. The existingcurrent_pos_handler()would then remain the source of truth for the final state.This would avoid directly guessing whether stopped means
open,closed, orpartially_open. Instead, stopped would only be used as a trigger to ask the device for its actual position.The flow would become:
This is intentionally different from simply emitting
openorclosedincurrent_status_handler().OperationalStatus == 0only says "not moving"; it does not say where the shade stopped.Minimal SmartWings-specific test change
For a lift-only SmartWings roller shade test driver, the smallest possible change would be:
This should cause the response to be dispatched to the existing
CurrentPositionLiftPercent100thshandler, not back intocurrent_status_handler(), unless the device separately sends anotherOperationalStatusreport.More generic version
For the driver, a more generic version should probably check whether the endpoint supports lift and/or tilt before reading those attributes:
Then the handler change can stay minimal:
Why not call
device:refresh()here?Calling
device:refresh()would likely read all subscribed attributes, includingOperationalStatusagain. That may be broader than necessary and could potentially cause repeatedOperationalStatus == 0handling.Reading only the position attributes is more targeted:
Alternative / additional possible fix
SmartWings could also be added to the existing
matter-window-covering-position-updates-while-movingsubdriver:This may help if the problem is primarily report ordering.
However, this may not be sufficient if the device does not send a final
CurrentPositionLiftPercent100thsreport at all after SmartThings-originated commands. In that case, the explicit read onOperationalStatus == 0would still be the more robust fix.