Skip to content

Matter Window Covering: final open/closed state may not be resolved when OperationalStatus returns to 0 #3043

Description

@ldeora

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions