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,9 @@ components:
version: 1
- id: lockAlarm
version: 1
- id: doorState
version: 1
optional: true
- id: remoteControlStatus
version: 1
- id: lockUsers
Expand Down
3 changes: 3 additions & 0 deletions drivers/SmartThings/matter-lock/profiles/lock-modular.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ components:
capabilities:
- id: lock
version: 1
- id: doorState
version: 1
optional: true
- id: lockAlarm
version: 1
- id: remoteControlStatus
Expand Down
34 changes: 34 additions & 0 deletions drivers/SmartThings/matter-lock/src/new-matter-lock/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ local subscribed_attributes = {
[capabilities.lock.ID] = {
DoorLock.attributes.LockState
},
[capabilities.doorState.ID] = {
DoorLock.attributes.DoorState
},
[capabilities.remoteControlStatus.ID] = {
DoorLock.attributes.OperatingMode
},
Expand Down Expand Up @@ -168,6 +171,12 @@ local function match_profile_modular(driver, device)
local clus_has_feature = function(feature_bitmap)
return DoorLock.are_features_supported(feature_bitmap, ep_cluster.feature_map)
end
if clus_has_feature(DoorLock.types.Feature.DOOR_POSITION_SENSOR) then
Copy link
Contributor

Choose a reason for hiding this comment

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

I know that at least some locks will set the DOOR_POSITION_SENSOR feature flag by default even if a door sensor is not configured. They will report NULL for the DoorState attribute until one has been configured. So I think we should check DoorState and the DPS flag and if they are non-NULL and the flag is set then we can say doorState is supported on this lock.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@tpmanley
I am sorry for the late reply and thank you for the review.

Do you mean to read DoorLock cluster's AttributeList and set the flag if there is DoorState attribute?

Copy link
Contributor

Choose a reason for hiding this comment

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

No problem @HunsupJung . I mean reading the DoorState attribute to confirm it's a non-null value.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@tpmanley
How about setting to unspecifiedError with logging when DoorState attribute is nil?
I think It's not easy to change profile when device start sending the normal value for DoorState after continuously sending nil value.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can do something similar to #2828 by setting a field like profiling_data.DOOR_STATE_NON_NULL when the DoorState attribute is reported as a non-null value. When that field changes from not being set to being set, then that will trigger the re-profile.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@hcarter-775 @nickolas-deboom
Could you review it?

Copy link
Contributor

Choose a reason for hiding this comment

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

Since we wouldn't include the capability by default, Hunsup is right that we'd need to do a read, which would probably be fine. However, to avoid the problem of reads failing I think we could just add DoorState as a subscribed attribute in device_init with add_subscribed_attribute, and then if we get a non-null value reported back, then do what Tom suggested within door_state_handler with some logic like this to re-profile the device:

if ib.data.value ~= nil and device:get_latest_state("main", capabilities.doorState.ID, capabilities.doorState.supportedDoorStates.NAME) == nil then
  device:set_field(profiling_data.DOOR_STATE_NON_NULL, true)
  match_profile(driver, device)
end

Then within match_profile, check for the presence of the profiling_data.DOOR_STATE_NON_NULL as the basis for including doorState in the profile.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, since it's not a mandatory attribute, maybe create a new separate field rather than putting this field within profiling_data, otherwise locks that don't support DPS would be blocked here.

Copy link
Contributor

@hcarter-775 hcarter-775 Mar 12, 2026

Choose a reason for hiding this comment

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

Here is my recomendation:

Using my PR here as a blueprint,

we should update this table:

local profiling_data = {
  BATTERY_SUPPORT = "__BATTERY_SUPPORT",
  ENABLE_DOOR_STATE = "__ENABLE_DOOR_STATE"
}

Then, in device_init:

local function device_init(driver, device)
  device:set_component_to_endpoint_fn(component_to_endpoint)
  if #device:get_endpoints(clusters.DoorLock.ID, {feature_bitmap = clusters.DoorLock.types.Feature.DOOR_POSITION_SENSOR}) == 0 and device:supports_capability(capabilities.doorState) == false then
    device:set_field(profiling_data.ENABLE_DOOR_STATE, false, {persist = true})
  else
    device:add_subscribed_attribute(clusters.DoorLock.attributes.DoorState)
  end
  ... rest of function ...
end

Next, in door_state_handler:

local function door_state_handler(driver, device, ib, response)
  if ib.data.value == nil then
    -- early return on nil data. Also, if ENABLE_DOOR_STATE is unset, set it to false and attempt profile matching.  
    if device:get_field(profiling_data.ENABLE_DOOR_STATE) == nil then
      device:set_field(profiling_data.ENABLE_DOOR_STATE, false)
      match_profile(driver, device)
    end
    return
  elseif device:supports_capability(capabilities.doorState) == false then
    -- if a non-nil report comes in and the doorState capability is unsupported, set ENABLE_DOOR_STATE to true and attempt profile matching.
    device:set_field(profiling_data.ENABLE_DOOR_STATE, true)
    match_profile(driver, device)
    return
  end
  ... rest of function ...
end

Finally, in do_configure, we can remove this section:

if clus_has_feature(DoorLock.types.Feature.DOOR_POSITION_SENSOR) then
  table.insert(main_component_capabilities, capabilities.doorState.ID)
    device.thread:call_with_delay(5, function(t)
    device:emit_event(capabilities.doorState.supportedDoorStates({"open", "closed"}, {visibility = {displayed = false}})) -- open and closed are mandatory
  end)
end

and we can replace it with the following logic:

  ... rest of code ...
  if device:get_field(profiling_data.DOOR_STATE_ENABLED) or device:supports_capability(capabilities.doorState) then
    table.insert(main_component_capabilities, capabilities.doorState.ID)
  end
  ... rest of code ...

probably over by the BATTERY_SUPPORT logic.

This ensures that whether the DOOR_POSITION_SENSOR flag is set and whether the DoorState is populated, the profiling will occur as expected. It also ensures that if the doorState capability is ever set, it will never be unset. Finally, it ensures that if the DOOR_POSITION_SENSOR is ever enabled during the device's lifetime, we will re-attempt a device profile matching (at least on driver restart).

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, to ensure that any re-profiling works as expected, we should generally add gating like this for all modular capability insertions:

if clus_has_feature(DoorLock.types.Feature.USER) or device:supports_capability(capabilities.lockUsers) then
  table.insert(main_component_capabilities, capabilities.lockUsers.ID)
end

to ensure that if an optional capability is already supported, we continue to enable it.

table.insert(main_component_capabilities, capabilities.doorState.ID)
device.thread:call_with_delay(5, function(t)
device:emit_event(capabilities.doorState.supportedDoorStates({"open", "closed"}, {visibility = {displayed = false}})) -- open and closed are mandatory
end)
Comment on lines +229 to +231
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should handle this in infoChanged, gated behind a profile change, since this has the possibility of failing (if a profile change does not occur in <5 seconds)

Copy link
Contributor

Choose a reason for hiding this comment

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

Similar to the discussion here: #2763

end
if clus_has_feature(DoorLock.types.Feature.USER) then
table.insert(main_component_capabilities, capabilities.lockUsers.ID)
end
Expand Down Expand Up @@ -330,6 +339,30 @@ local function lock_state_handler(driver, device, ib, response)
end)
end

local function door_state_handler(driver, device, ib, response)
if ib.data.value == nil then return end
local DoorStateEnum = DoorLock.types.DoorStateEnum
local doorState = capabilities.doorState.doorState
local DOOR_STATE_MAP = {
[DoorStateEnum.DOOR_OPEN] = doorState.open,
[DoorStateEnum.DOOR_CLOSED] = doorState.closed,
[DoorStateEnum.DOOR_JAMMED] = doorState.jammed,
[DoorStateEnum.DOOR_FORCED_OPEN] = doorState.forcedOpen,
[DoorStateEnum.DOOR_UNSPECIFIED_ERROR] = doorState.unspecifiedError,
[DoorStateEnum.DOOR_AJAR] = doorState.ajar
}
device:emit_event(DOOR_STATE_MAP[ib.data.value]())
Copy link
Contributor

Choose a reason for hiding this comment

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

DoorState is a nullable attribute so you should check for nil here


local supportedDoorStates = device:get_latest_state("main", capabilities.doorState.ID, capabilities.doorState.supportedDoorStates.NAME) or {}
for _, state in pairs(supportedDoorStates) do
if state == DOOR_STATE_MAP[ib.data.value].NAME then
return
end
end
table.insert(supportedDoorStates, DOOR_STATE_MAP[ib.data.value].NAME);
device:emit_event(capabilities.doorState.supportedDoorStates(supportedDoorStates, {visibility = {displayed = false}}))
end

---------------------
-- Operating Modes --
---------------------
Expand Down Expand Up @@ -2831,6 +2864,7 @@ local new_matter_lock_handler = {
attr = {
[DoorLock.ID] = {
[DoorLock.attributes.LockState.ID] = lock_state_handler,
[DoorLock.attributes.DoorState.ID] = door_state_handler,
[DoorLock.attributes.OperatingMode.ID] = operating_modes_handler,
[DoorLock.attributes.NumberOfTotalUsersSupported.ID] = total_users_supported_handler,
[DoorLock.attributes.NumberOfPINUsersSupported.ID] = pin_users_supported_handler,
Expand Down
137 changes: 135 additions & 2 deletions drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@ local DoorLock = clusters.DoorLock
local types = DoorLock.types
local lock_utils = require "lock_utils"

local enabled_optional_component_capability_pairs = {{
"main",
{
capabilities.doorState.ID,
capabilities.lockUsers.ID,
capabilities.lockCredentials.ID,
capabilities.lockSchedules.ID
}
}}
local mock_device = test.mock_device.build_test_matter_device({
profile = t_utils.get_profile_definition("lock-user-pin-schedule.yml"),
profile = t_utils.get_profile_definition(
"lock-modular.yml",
{enabled_optional_capabilities = enabled_optional_component_capability_pairs}
),
manufacturer_info = {
vendor_id = 0x115f,
product_id = 0x2802,
Expand All @@ -34,7 +46,7 @@ local mock_device = test.mock_device.build_test_matter_device({
cluster_id = DoorLock.ID,
cluster_type = "SERVER",
cluster_revision = 1,
feature_map = 0x0181, -- PIN & USR & COTA
feature_map = 0x01A1, -- PIN & DPS & USR & COTA
}
},
device_types = {
Expand All @@ -48,6 +60,7 @@ local function test_init()
test.disable_startup_messages()
-- subscribe request
local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device)
subscribe_request:merge(DoorLock.attributes.DoorState:subscribe(mock_device))
subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device))
subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device))
subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device))
Expand Down Expand Up @@ -606,6 +619,126 @@ test.register_coroutine_test(
}
)

test.register_coroutine_test(
"Handle received DoorState.DOOR_CLOSED from Matter device.",
function()
test.socket.matter:__queue_receive(
{
mock_device.id,
DoorLock.attributes.DoorState:build_test_report_data(
mock_device, 1, DoorLock.attributes.DoorState.DOOR_CLOSED
),
}
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.doorState.closed())
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.supportedDoorStates({"closed"}, {visibility={displayed=false}}))
)
end
)

test.register_coroutine_test(
"Handle received DoorState.DOOR_JAMMED from Matter device.",
function()
test.socket.matter:__queue_receive(
{
mock_device.id,
DoorLock.attributes.DoorState:build_test_report_data(
mock_device, 1, DoorLock.attributes.DoorState.DOOR_JAMMED
),
}
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.doorState.jammed())
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.supportedDoorStates({"jammed"}, {visibility={displayed=false}}))
)
end
)

test.register_coroutine_test(
"Handle received DoorState.DOOR_FORCED_OPEN from Matter device.",
function()
test.socket.matter:__queue_receive(
{
mock_device.id,
DoorLock.attributes.DoorState:build_test_report_data(
mock_device, 1, DoorLock.attributes.DoorState.DOOR_FORCED_OPEN
),
}
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.doorState.forcedOpen())
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.supportedDoorStates({"forcedOpen"}, {visibility={displayed=false}}))
)
end
)

test.register_coroutine_test(
"Handle received DoorState.DOOR_UNSPECIFIED_ERROR from Matter device.",
function()
test.socket.matter:__queue_receive(
{
mock_device.id,
DoorLock.attributes.DoorState:build_test_report_data(
mock_device, 1, DoorLock.attributes.DoorState.DOOR_UNSPECIFIED_ERROR
),
}
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.doorState.unspecifiedError())
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.supportedDoorStates({"unspecifiedError"}, {visibility={displayed=false}}))
)
end
)

test.register_coroutine_test(
"Handle received DoorState.DOOR_AJAR from Matter device.",
function()
test.socket.matter:__queue_receive(
{
mock_device.id,
DoorLock.attributes.DoorState:build_test_report_data(
mock_device, 1, DoorLock.attributes.DoorState.DOOR_AJAR
),
}
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.doorState.ajar())
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.supportedDoorStates({"ajar"}, {visibility={displayed=false}}))
)
end
)

test.register_coroutine_test(
"Handle received DoorState.DOOR_OPEN from Matter device.",
function()
test.socket.matter:__queue_receive(
{
mock_device.id,
DoorLock.attributes.DoorState:build_test_report_data(
mock_device, 1, DoorLock.attributes.DoorState.DOOR_OPEN
),
}
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.doorState.open())
)
test.socket.capability:__expect_send(
mock_device:generate_test_message("main", capabilities.doorState.supportedDoorStates({"open"}, {visibility={displayed=false}}))
)
end
)

local function refresh_commands(dev)
local req = DoorLock.attributes.LockState:read(dev)
return req
Expand Down
Loading