Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ components:
- id: lockAliro
version: 1
optional: true
- id: lockUwbApproachSettings
version: 1
optional: true
- id: lockUwbModeSettings
version: 1
optional: true
- id: battery
version: 1
optional: true
Expand Down
6 changes: 6 additions & 0 deletions drivers/SmartThings/matter-lock/profiles/lock-modular.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ components:
- id: lockAliro
version: 1
optional: true
- id: lockUwbApproachSettings
version: 1
optional: true
- id: lockUwbModeSettings
version: 1
optional: true
- id: battery
version: 1
optional: true
Expand Down
164 changes: 164 additions & 0 deletions drivers/SmartThings/matter-lock/src/device_configuration.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local capabilities = require "st.capabilities"
local clusters = require "st.matter.clusters"
local version = require "version"
local lock_utils = require "lock_utils"

if version.api < 10 then
clusters.DoorLock = require "DoorLock"
end

local DoorLock = clusters.DoorLock

local device_configuration = {}

device_configuration.battery_support = {
NO_BATTERY = "NO_BATTERY",
BATTERY_LEVEL = "BATTERY_LEVEL",
BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE"
}

device_configuration.profiling_data = {
BATTERY_SUPPORT = "__BATTERY_SUPPORT",
}

local optional_capability_providers = {}

--- Register extra optional capabilities contributed by a subdriver.
--- The provider is called with the device and returns a list of capability ids, or nil.
--- @param provider fun(device: Device): string[]|nil
function device_configuration.register_optional_capabilities(provider)
Comment thread
hcarter-775 marked this conversation as resolved.
Resolved
table.insert(optional_capability_providers, provider)
end

local function extra_optional_capabilities(device)
local extras = {}
local seen = {}
for _, provider in ipairs(optional_capability_providers) do
local caps = provider(device)
for _, cap_id in ipairs(caps or {}) do
if not seen[cap_id] then
seen[cap_id] = true
table.insert(extras, cap_id)
end
end
end
return extras
end

local function profiling_data_still_required(device)
for _, field in pairs(device_configuration.profiling_data) do
if device:get_field(field) == nil then
return true
end
end
return false
end

function device_configuration.match_profile_modular(driver, device)
local enabled_optional_component_capability_pairs = {}
local main_component_capabilities = {}
local modular_profile_name = "lock-modular"
for _, device_ep in pairs(device.endpoints) do
for _, ep_cluster in pairs(device_ep.clusters) do
if ep_cluster.cluster_id == DoorLock.ID then
local clus_has_feature = function(feature_bitmap)
return DoorLock.are_features_supported(
feature_bitmap,
lock_utils.get_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, device_ep.endpoint_id) or
ep_cluster.feature_map
)
end
if clus_has_feature(DoorLock.types.Feature.USER) then
table.insert(main_component_capabilities, capabilities.lockUsers.ID)
end
if clus_has_feature(DoorLock.types.Feature.PIN_CREDENTIAL) then
table.insert(main_component_capabilities, capabilities.lockCredentials.ID)
end
if clus_has_feature(DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES) or
clus_has_feature(DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES) then
table.insert(main_component_capabilities, capabilities.lockSchedules.ID)
end
if clus_has_feature(DoorLock.types.Feature.UNBOLT) then
device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}}))
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}}))
modular_profile_name = "lock-modular-embedded-unlatch"
else
device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}}))
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}}))
end
if clus_has_feature(DoorLock.types.Feature.ALIRO_PROVISIONING) then
table.insert(main_component_capabilities, capabilities.lockAliro.ID)
end
break
end
end
end

-- Subdriver registrations, including Schlage UWB settings, are applied on every profile match.
for _, cap_id in ipairs(extra_optional_capabilities(device)) do
table.insert(main_component_capabilities, cap_id)
end

local supported_battery_type = device:get_field(device_configuration.profiling_data.BATTERY_SUPPORT)
if supported_battery_type == device_configuration.battery_support.BATTERY_LEVEL then
table.insert(main_component_capabilities, capabilities.batteryLevel.ID)
elseif supported_battery_type == device_configuration.battery_support.BATTERY_PERCENTAGE then
table.insert(main_component_capabilities, capabilities.battery.ID)
end

table.insert(enabled_optional_component_capability_pairs, {"main", main_component_capabilities})
if modular_profile_name == "lock-modular-embedded-unlatch"
or device:supports_capability(capabilities.lockCodes)
or lock_utils.optional_capabilities_list_changed(enabled_optional_component_capability_pairs, device.profile.components) then
device:try_update_metadata({profile = modular_profile_name, optional_component_capabilities = enabled_optional_component_capability_pairs})
end
end

local function match_profile_switch(driver, device)
local user_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.USER})
local pin_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.PIN_CREDENTIAL})
local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES})
local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES})
local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT})

local profile_name = "lock"
if #user_eps > 0 then
profile_name = profile_name .. "-user"
if #pin_eps > 0 then
profile_name = profile_name .. "-pin"
end
if #week_schedule_eps + #year_schedule_eps > 0 then
profile_name = profile_name .. "-schedule"
end
end
if #unbolt_eps > 0 then
profile_name = profile_name .. "-unlatch"
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}}))
else
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}}))
end

local supported_battery_type = device:get_field(device_configuration.profiling_data.BATTERY_SUPPORT)
if supported_battery_type == device_configuration.battery_support.BATTERY_LEVEL then
profile_name = profile_name .. "-batteryLevel"
elseif supported_battery_type == device_configuration.battery_support.BATTERY_PERCENTAGE then
profile_name = profile_name .. "-battery"
end

device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name))
device:try_update_metadata({profile = profile_name})
end

function device_configuration.match_profile(driver, device, ignore_static_profiling)
if profiling_data_still_required(device) then return end
if version.api >= 15 and version.rpc >= 9 then
device_configuration.match_profile_modular(driver, device)
elseif ignore_static_profiling ~= true then
match_profile_switch(driver, device)
end
end

return device_configuration
130 changes: 8 additions & 122 deletions drivers/SmartThings/matter-lock/src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ local version = require "version"
if version.api < 10 then
clusters.DoorLock = require "DoorLock"
end
local device_configuration = require "device_configuration"

local DoorLock = clusters.DoorLock
local PowerSource = clusters.PowerSource
Expand Down Expand Up @@ -46,15 +47,8 @@ local ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP = {
["nonEvictableEndpointKey"] = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY
}

local battery_support = {
NO_BATTERY = "NO_BATTERY",
BATTERY_LEVEL = "BATTERY_LEVEL",
BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE"
}

local profiling_data = {
BATTERY_SUPPORT = "__BATTERY_SUPPORT",
}
local battery_support = device_configuration.battery_support
local profiling_data = device_configuration.profiling_data

local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID}
local subscribed_attributes = {
Expand Down Expand Up @@ -209,96 +203,6 @@ local function set_reader_config(device)
device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "IN_PROGRESS", {persist = true})
end

local function match_profile_modular(driver, device)
local enabled_optional_component_capability_pairs = {}
local main_component_capabilities = {}
local modular_profile_name = "lock-modular"
for _, device_ep in pairs(device.endpoints) do
for _, ep_cluster in pairs(device_ep.clusters) do
if ep_cluster.cluster_id == DoorLock.ID then
local clus_has_feature = function(feature_bitmap)
return DoorLock.are_features_supported(
feature_bitmap,
lock_utils.get_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, device_ep.endpoint_id) or
ep_cluster.feature_map
)
end
if clus_has_feature(DoorLock.types.Feature.USER) then
table.insert(main_component_capabilities, capabilities.lockUsers.ID)
end
if clus_has_feature(DoorLock.types.Feature.PIN_CREDENTIAL) then
table.insert(main_component_capabilities, capabilities.lockCredentials.ID)
end
if clus_has_feature(DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES) or
clus_has_feature(DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES) then
table.insert(main_component_capabilities, capabilities.lockSchedules.ID)
end
if clus_has_feature(DoorLock.types.Feature.UNBOLT) then
device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}}))
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}}))
modular_profile_name = "lock-modular-embedded-unlatch" -- use the embedded config specified in this profile for devices supporting "unlatch"
else
device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}}))
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}}))
end
if clus_has_feature(DoorLock.types.Feature.ALIRO_PROVISIONING) then
table.insert(main_component_capabilities, capabilities.lockAliro.ID)
end
break
end
end
end

local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT)
if supported_battery_type == battery_support.BATTERY_LEVEL then
table.insert(main_component_capabilities, capabilities.batteryLevel.ID)
elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then
table.insert(main_component_capabilities, capabilities.battery.ID)
end

table.insert(enabled_optional_component_capability_pairs, {"main", main_component_capabilities})
if modular_profile_name == "lock-modular-embedded-unlatch" -- the embedded config that may be needed is not checked by an optional capability comparison
or device:supports_capability(capabilities.lockCodes) -- always re-profile a device using the legacy code storage methods
or lock_utils.optional_capabilities_list_changed(enabled_optional_component_capability_pairs, device.profile.components) then
device:try_update_metadata({profile = modular_profile_name, optional_component_capabilities = enabled_optional_component_capability_pairs})
end
end

local function match_profile_switch(driver, device)
local user_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.USER})
local pin_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.PIN_CREDENTIAL})
local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES})
local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES})
local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT})

local profile_name = "lock"
if #user_eps > 0 then
profile_name = profile_name .. "-user"
if #pin_eps > 0 then
profile_name = profile_name .. "-pin"
end
if #week_schedule_eps + #year_schedule_eps > 0 then
profile_name = profile_name .. "-schedule"
end
end
if #unbolt_eps > 0 then
profile_name = profile_name .. "-unlatch"
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}}))
else
device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}}))
end

local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT)
if supported_battery_type == battery_support.BATTERY_LEVEL then
profile_name = profile_name .. "-batteryLevel"
elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then
profile_name = profile_name .. "-battery"
end

device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name))
device:try_update_metadata({profile = profile_name})
end

--- Deeply compare two values.
--- Handles metatables. Can optionally ignore cycle checking and/or function differences.
---
Expand Down Expand Up @@ -390,34 +294,16 @@ local function info_changed(driver, device, event, args)
end
end

local function profiling_data_still_required(device)
for _, field in pairs(profiling_data) do
if device:get_field(field) == nil then
return true -- data still required if a field is nil
end
end
return false
end

local function match_profile(driver, device, ignore_static_profiling)
if profiling_data_still_required(device) then return end
if version.api >= 15 and version.rpc >= 9 then
match_profile_modular(driver, device)
elseif ignore_static_profiling ~= true then
match_profile_switch(driver, device)
end
end

local function do_configure(driver, device)
match_profile(driver, device, false)
device_configuration.match_profile(driver, device, false)
device.thread:call_with_delay(5, function()
device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true}))
device:emit_event(capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) -- lockJammed is mandatory
end)
end

local function driver_switched(driver, device)
match_profile(driver, device, false)
device_configuration.match_profile(driver, device, false)
device:try_update_metadata({provisioning_state = "PROVISIONED"})
end

Expand Down Expand Up @@ -641,7 +527,7 @@ local function door_lock_feature_map_handler(driver, device, ib, response)
local feature_map = lock_utils.get_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, ib.endpoint_id) or nil
if feature_map ~= ib.data.value then
lock_utils.set_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, ib.endpoint_id, ib.data.value, { persist = true })
match_profile(driver, device, true)
device_configuration.match_profile(driver, device, true)
end
end

Expand All @@ -660,7 +546,7 @@ local function handle_power_source_attribute_list(driver, device, ib, response)
device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist=true})
end
if latest_battery_support == nil or latest_battery_support ~= device:get_field(profiling_data.BATTERY_SUPPORT) then
match_profile(driver, device, false)
device_configuration.match_profile(driver, device, false)
end
end

Expand Down Expand Up @@ -2452,7 +2338,7 @@ local function handle_refresh(driver, device, command)
else
device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist = true})
end
match_profile(driver, device, true)
device_configuration.match_profile(driver, device, true)
else
device:send(DoorLock.attributes.LockState:read(device))
device:set_field(lock_utils.BUSY_STATE, false, {persist = true})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local constants = require "schlage-sense-pro.constants"

--- Matches Allegion vendor ID 0x1236 with product ID 0x3800 or the custom UWB cluster.
local function is_schlage_sense_pro(opts, driver, device)
if device.manufacturer_info and device.manufacturer_info.vendor_id == constants.SCHLAGE_VENDOR_ID then
if device.manufacturer_info.product_id == constants.SCHLAGE_SENSE_PRO_PRODUCT_ID or
#device:get_endpoints(constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID) > 0 then
return true, require("schlage-sense-pro")
end
end
return false
end

return is_schlage_sense_pro
Loading
Loading