diff --git a/drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml b/drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml index 3d9e68b44c..0647b1518c 100644 --- a/drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml +++ b/drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml @@ -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 diff --git a/drivers/SmartThings/matter-lock/profiles/lock-modular.yml b/drivers/SmartThings/matter-lock/profiles/lock-modular.yml index 3a8a53bf70..b2fcdc4db1 100644 --- a/drivers/SmartThings/matter-lock/profiles/lock-modular.yml +++ b/drivers/SmartThings/matter-lock/profiles/lock-modular.yml @@ -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 diff --git a/drivers/SmartThings/matter-lock/src/device_configuration.lua b/drivers/SmartThings/matter-lock/src/device_configuration.lua new file mode 100644 index 0000000000..45d87e56c7 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/device_configuration.lua @@ -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) + 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 diff --git a/drivers/SmartThings/matter-lock/src/init.lua b/drivers/SmartThings/matter-lock/src/init.lua index 72bfaae425..e1d26d394c 100755 --- a/drivers/SmartThings/matter-lock/src/init.lua +++ b/drivers/SmartThings/matter-lock/src/init.lua @@ -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 @@ -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 = { @@ -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. --- @@ -390,26 +294,8 @@ 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 @@ -417,7 +303,7 @@ local function do_configure(driver, device) 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 @@ -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 @@ -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 @@ -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}) diff --git a/drivers/SmartThings/matter-lock/src/schlage-sense-pro/can_handle.lua b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/can_handle.lua new file mode 100644 index 0000000000..5c84112b57 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/can_handle.lua @@ -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 diff --git a/drivers/SmartThings/matter-lock/src/schlage-sense-pro/constants.lua b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/constants.lua new file mode 100644 index 0000000000..27683c23b1 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/constants.lua @@ -0,0 +1,110 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Constants for the Schlage Sense Pro UWB door lock. +--- +--- Field of view values: 0 front+left+right, 1 front+left, 2 front+right, 3 front only. +--- Intent mode values: 0 unlock on approach, 1 unlock on arrival, 2 unlock on lock interaction. +--- Cluster and attribute IDs below are provisional until the manufacturer cluster is finalized. + +local constants = {} + +-- Device Identification +constants.SCHLAGE_VENDOR_ID = 0x1236 -- Allegion / Schlage Vendor ID +constants.SCHLAGE_SENSE_PRO_PRODUCT_ID = 0x3800 -- Schlage Sense Pro Product ID + +------------------------------------------------------------------------------------- +-- Matter Manufacturer-Specific Cluster Definitions +-- [PLACEHOLDER - ACTION REQUIRED BY ALLEGION] +------------------------------------------------------------------------------------- + +--- [PLACEHOLDER - ACTION REQUIRED BY ALLEGION] +--- Manufacturer-specific cluster ID for Schlage UWB Configuration. +--- Allegion engineers MUST replace this placeholder with the official cluster ID +--- allocated for the Schlage Sense Pro UWB configuration cluster. +--- (Typically formatted as (VendorID << 16) | MEI, e.g. 0x1236FC01). +constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID = 0x1236FC01 + +--- [PLACEHOLDER - ACTION REQUIRED BY ALLEGION] +--- Attribute ID for Field of View / Approach Direction. +--- Allegion engineers MUST replace this placeholder with the actual attribute ID +--- defined in the custom cluster specification. +constants.ATTR_FIELD_OF_VIEW_ID = 0x0001 + +--- [PLACEHOLDER - ACTION REQUIRED BY ALLEGION] +--- Attribute ID for Intent Mode / Unlock Behavior. +--- Allegion engineers MUST replace this placeholder with the actual attribute ID +--- defined in the custom cluster specification. +constants.ATTR_INTENT_MODE_ID = 0x0002 + +--- [PLACEHOLDER - ACTION REQUIRED BY ALLEGION] +--- Command IDs (used only if interaction requires Matter Invoke Commands instead of Attribute Writes). +--- If commands are used, Allegion engineers MUST replace these placeholders with the actual command IDs. +constants.CMD_SET_FIELD_OF_VIEW_ID = 0x0001 +constants.CMD_SET_INTENT_MODE_ID = 0x0002 + +------------------------------------------------------------------------------------- +-- Capability Definitions and Value Mappings +-- [VERIFICATION REQUIRED BY ALLEGION] +------------------------------------------------------------------------------------- + +constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS = "lockUwbApproachSettings" +constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS = "lockUwbModeSettings" + +--- Field of view (lockUwbApproachSettings) mappings. +--- 0: front+left+right, 1: front+left, 2: front+right, 3: front only. +constants.APPROACH_DIRECTION = { + FRONT_LEFT_RIGHT = "frontLeftRight", + FRONT_LEFT = "frontLeft", + FRONT_RIGHT = "frontRight", + FRONT_ONLY = "frontOnly", +} + +constants.RAW_TO_APPROACH_DIRECTION = { + [0] = constants.APPROACH_DIRECTION.FRONT_LEFT_RIGHT, + [1] = constants.APPROACH_DIRECTION.FRONT_LEFT, + [2] = constants.APPROACH_DIRECTION.FRONT_RIGHT, + [3] = constants.APPROACH_DIRECTION.FRONT_ONLY, +} + +constants.APPROACH_DIRECTION_TO_RAW = { + [constants.APPROACH_DIRECTION.FRONT_LEFT_RIGHT] = 0, + [constants.APPROACH_DIRECTION.FRONT_LEFT] = 1, + [constants.APPROACH_DIRECTION.FRONT_RIGHT] = 2, + [constants.APPROACH_DIRECTION.FRONT_ONLY] = 3, +} + +constants.SUPPORTED_APPROACH_DIRECTIONS = { + constants.APPROACH_DIRECTION.FRONT_LEFT_RIGHT, + constants.APPROACH_DIRECTION.FRONT_LEFT, + constants.APPROACH_DIRECTION.FRONT_RIGHT, + constants.APPROACH_DIRECTION.FRONT_ONLY, +} + +--- Intent mode (lockUwbModeSettings) mappings. +--- 0: unlock on approach, 1: unlock on arrival, 2: unlock on lock interaction. +constants.INTENT_MODE = { + UNLOCK_ON_APPROACH = "unlockOnApproach", + UNLOCK_ON_ARRIVAL = "unlockOnArrival", + UNLOCK_ON_INTERACTION = "unlockOnInteraction", +} + +constants.RAW_TO_INTENT_MODE = { + [0] = constants.INTENT_MODE.UNLOCK_ON_APPROACH, + [1] = constants.INTENT_MODE.UNLOCK_ON_ARRIVAL, + [2] = constants.INTENT_MODE.UNLOCK_ON_INTERACTION, +} + +constants.INTENT_MODE_TO_RAW = { + [constants.INTENT_MODE.UNLOCK_ON_APPROACH] = 0, + [constants.INTENT_MODE.UNLOCK_ON_ARRIVAL] = 1, + [constants.INTENT_MODE.UNLOCK_ON_INTERACTION] = 2, +} + +constants.SUPPORTED_INTENT_MODES = { + constants.INTENT_MODE.UNLOCK_ON_APPROACH, + constants.INTENT_MODE.UNLOCK_ON_ARRIVAL, + constants.INTENT_MODE.UNLOCK_ON_INTERACTION, +} + +return constants diff --git a/drivers/SmartThings/matter-lock/src/schlage-sense-pro/init.lua b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/init.lua new file mode 100644 index 0000000000..6fe3fc31ed --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/init.lua @@ -0,0 +1,184 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Subdriver for Allegion / Schlage Sense Pro UWB Door Lock +--- +--- Enables lockUwbApproachSettings and lockUwbModeSettings as optional capabilities +--- on the shared modular lock profiles, and maps those capabilities onto the +--- Matter manufacturer-specific cluster. + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local device_configuration = require "device_configuration" +local lock_utils = require "lock_utils" +local constants = require "schlage-sense-pro.constants" +local schlage_utils = require "schlage-sense-pro.schlage_utils" + +local DoorLock = clusters.DoorLock +local lockUwbApproachSettings = capabilities.lockUwbApproachSettings +local lockUwbModeSettings = capabilities.lockUwbModeSettings + +device_configuration.register_optional_capabilities(function(device) + local matched = require("schlage-sense-pro.can_handle")(nil, nil, device) + if matched then + return { + constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS, + constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS, + } + end +end) + +local function uwb_capabilities_missing(device) + return not device:supports_capability_by_id(constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS) + or not device:supports_capability_by_id(constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS) +end + +------------------------------------------------------------------------------------- +-- Matter Attribute Handlers (Device -> SmartThings Hub) +------------------------------------------------------------------------------------- + +local function field_of_view_attr_handler(driver, device, ib, response) + if ib.data.value ~= nil then + local raw_val = ib.data.value + local enum_val = constants.RAW_TO_APPROACH_DIRECTION[raw_val] + device.log.info(string.format("Schlage UWB Field of View report: raw=%s, enum=%s", tostring(raw_val), tostring(enum_val))) + if enum_val then + device:emit_event(lockUwbApproachSettings.approachDirection(enum_val)) + else + device.log.warn(string.format("Unknown Schlage UWB Field of View value: %s", tostring(raw_val))) + end + end +end + +local function intent_mode_attr_handler(driver, device, ib, response) + if ib.data.value ~= nil then + local raw_val = ib.data.value + local enum_val = constants.RAW_TO_INTENT_MODE[raw_val] + device.log.info(string.format("Schlage UWB Intent Mode report: raw=%s, enum=%s", tostring(raw_val), tostring(enum_val))) + if enum_val then + device:emit_event(lockUwbModeSettings.intentMode(enum_val)) + else + device.log.warn(string.format("Unknown Schlage UWB Intent Mode value: %s", tostring(raw_val))) + end + end +end + +------------------------------------------------------------------------------------- +-- Capability Command Handlers (SmartThings App -> Device) +------------------------------------------------------------------------------------- + +local function handle_set_approach_direction(driver, device, command) + local val = command.args.approachDirection + device.log.info(string.format("Handling setApproachDirection command: %s", tostring(val))) + + local raw_val = nil + if type(val) == "number" then + raw_val = val + elseif type(val) == "string" then + raw_val = constants.APPROACH_DIRECTION_TO_RAW[val] + end + + if raw_val ~= nil and constants.RAW_TO_APPROACH_DIRECTION[raw_val] ~= nil then + if schlage_utils.write_field_of_view(device, raw_val) then + device:emit_event(lockUwbApproachSettings.approachDirection(constants.RAW_TO_APPROACH_DIRECTION[raw_val])) + end + else + device.log.error(string.format("Invalid approachDirection argument: %s", tostring(val))) + end +end + +local function handle_set_intent_mode(driver, device, command) + local val = command.args.intentMode + device.log.info(string.format("Handling setIntentMode command: %s", tostring(val))) + + local raw_val = nil + if type(val) == "number" then + raw_val = val + elseif type(val) == "string" then + raw_val = constants.INTENT_MODE_TO_RAW[val] + end + + if raw_val ~= nil and constants.RAW_TO_INTENT_MODE[raw_val] ~= nil then + if schlage_utils.write_intent_mode(device, raw_val) then + device:emit_event(lockUwbModeSettings.intentMode(constants.RAW_TO_INTENT_MODE[raw_val])) + end + else + device.log.error(string.format("Invalid intentMode argument: %s", tostring(val))) + end +end + +local function handle_refresh(driver, device, command) + device.log.info("Refreshing Schlage Sense Pro UWB and Lock State") + device:send(DoorLock.attributes.LockState:read(device)) + device:set_field(lock_utils.BUSY_STATE, false, { persist = true }) + schlage_utils.read_field_of_view(device) + schlage_utils.read_intent_mode(device) +end + +------------------------------------------------------------------------------------- +-- Lifecycle Handlers +------------------------------------------------------------------------------------- + +local function device_init(driver, device) + device.log.info("Schlage Sense Pro UWB subdriver device_init") + if driver.lifecycle_handlers and driver.lifecycle_handlers.init then + driver.lifecycle_handlers.init(driver, device) + end + + if uwb_capabilities_missing(device) then + device.log.info("Enabling Schlage Sense Pro UWB capabilities on the modular lock profile") + device_configuration.match_profile(driver, device, false) + schlage_utils.sync_uwb_settings(device) + end +end + +local function do_configure(driver, device) + device.log.info("Schlage Sense Pro UWB subdriver do_configure") + if driver.lifecycle_handlers and driver.lifecycle_handlers.doConfigure then + driver.lifecycle_handlers.doConfigure(driver, device) + end + schlage_utils.sync_uwb_settings(device) +end + +local function driver_switched(driver, device) + device.log.info("Schlage Sense Pro UWB subdriver driver_switched") + if driver.lifecycle_handlers and driver.lifecycle_handlers.driverSwitched then + driver.lifecycle_handlers.driverSwitched(driver, device) + end + schlage_utils.sync_uwb_settings(device) +end + +local schlage_sense_pro_subdriver = { + NAME = "schlage-sense-pro", + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure, + driverSwitched = driver_switched, + }, + matter_handlers = { + attr = { + [constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID] = { + [constants.ATTR_FIELD_OF_VIEW_ID] = field_of_view_attr_handler, + [constants.ATTR_INTENT_MODE_ID] = intent_mode_attr_handler, + }, + }, + }, + capability_handlers = { + [constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS] = { + [lockUwbApproachSettings.commands.setApproachDirection.NAME] = handle_set_approach_direction, + }, + [constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS] = { + [lockUwbModeSettings.commands.setIntentMode.NAME] = handle_set_intent_mode, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = handle_refresh, + }, + }, + supported_capabilities = { + lockUwbApproachSettings, + lockUwbModeSettings, + }, + can_handle = require("schlage-sense-pro.can_handle"), +} + +return schlage_sense_pro_subdriver diff --git a/drivers/SmartThings/matter-lock/src/schlage-sense-pro/schlage_utils.lua b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/schlage_utils.lua new file mode 100644 index 0000000000..2334548ac2 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/schlage-sense-pro/schlage_utils.lua @@ -0,0 +1,104 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local constants = require "schlage-sense-pro.constants" + +local DoorLock = clusters.DoorLock +local lockUwbApproachSettings = capabilities.lockUwbApproachSettings +local lockUwbModeSettings = capabilities.lockUwbModeSettings + +local schlage_utils = {} + +--- Find the endpoint that hosts the Schlage UWB custom cluster. +--- Returns nil when neither the custom cluster nor DoorLock is present. +--- @param device st.matter.Device +--- @return number|nil +function schlage_utils.find_endpoint(device) + local custom_eps = device:get_endpoints(constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID) + if #custom_eps > 0 then + return custom_eps[1] + end + local lock_eps = device:get_endpoints(DoorLock.ID) + if #lock_eps > 0 then + return lock_eps[1] + end + return nil +end + +local function endpoint_or_warn(device, action) + local ep = schlage_utils.find_endpoint(device) + if ep == nil then + device.log.warn(string.format("Schlage UWB endpoint not found; skipping %s", action)) + end + return ep +end + +--- @param device st.matter.Device +function schlage_utils.read_field_of_view(device) + local ep = endpoint_or_warn(device, "Field of View read") + if ep == nil then return end + device.log.info(string.format("Reading Schlage UWB Field of View (Cluster: 0x%08X, Attr: 0x%04X, EP: %d)", + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_FIELD_OF_VIEW_ID, ep)) + device:send(cluster_base.read(device, ep, constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_FIELD_OF_VIEW_ID, nil)) +end + +--- @param device st.matter.Device +--- @param raw_value number +--- @return boolean true when the write was sent +function schlage_utils.write_field_of_view(device, raw_value) + local ep = endpoint_or_warn(device, "Field of View write") + if ep == nil then return false end + device.log.info(string.format("Writing Schlage UWB Field of View: %d (Cluster: 0x%08X, Attr: 0x%04X, EP: %d)", + raw_value, constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_FIELD_OF_VIEW_ID, ep)) + local data = data_types.validate_or_build_type(raw_value, data_types.Uint8) + device:send(cluster_base.write(device, ep, constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_FIELD_OF_VIEW_ID, nil, data)) + return true +end + +--- @param device st.matter.Device +function schlage_utils.read_intent_mode(device) + local ep = endpoint_or_warn(device, "Intent Mode read") + if ep == nil then return end + device.log.info(string.format("Reading Schlage UWB Intent Mode (Cluster: 0x%08X, Attr: 0x%04X, EP: %d)", + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_INTENT_MODE_ID, ep)) + device:send(cluster_base.read(device, ep, constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_INTENT_MODE_ID, nil)) +end + +--- @param device st.matter.Device +--- @param raw_value number +--- @return boolean true when the write was sent +function schlage_utils.write_intent_mode(device, raw_value) + local ep = endpoint_or_warn(device, "Intent Mode write") + if ep == nil then return false end + device.log.info(string.format("Writing Schlage UWB Intent Mode: %d (Cluster: 0x%08X, Attr: 0x%04X, EP: %d)", + raw_value, constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_INTENT_MODE_ID, ep)) + local data = data_types.validate_or_build_type(raw_value, data_types.Uint8) + device:send(cluster_base.write(device, ep, constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, constants.ATTR_INTENT_MODE_ID, nil, data)) + return true +end + +function schlage_utils.emit_supported_values(device) + if device:supports_capability_by_id(constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS) then + device:emit_event(lockUwbApproachSettings.supportedApproachDirections( + constants.SUPPORTED_APPROACH_DIRECTIONS, { visibility = { displayed = false } } + )) + end + if device:supports_capability_by_id(constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS) then + device:emit_event(lockUwbModeSettings.supportedIntentModes( + constants.SUPPORTED_INTENT_MODES, { visibility = { displayed = false } } + )) + end +end + +--- Emit supported UWB values and read both attributes. +function schlage_utils.sync_uwb_settings(device) + schlage_utils.emit_supported_values(device) + schlage_utils.read_field_of_view(device) + schlage_utils.read_intent_mode(device) +end + +return schlage_utils diff --git a/drivers/SmartThings/matter-lock/src/sub_drivers.lua b/drivers/SmartThings/matter-lock/src/sub_drivers.lua index 563c428945..44d68b7ec1 100644 --- a/drivers/SmartThings/matter-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/matter-lock/src/sub_drivers.lua @@ -3,6 +3,7 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { + lazy_load_if_possible("schlage-sense-pro"), lazy_load_if_possible("unmigrated-matter-lock"), } return sub_drivers diff --git a/drivers/SmartThings/matter-lock/src/test/test_schlage_sense_pro_uwb.lua b/drivers/SmartThings/matter-lock/src/test/test_schlage_sense_pro_uwb.lua new file mode 100644 index 0000000000..e801051bb1 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_schlage_sense_pro_uwb.lua @@ -0,0 +1,690 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local constants = require "schlage-sense-pro.constants" +local can_handle = require "schlage-sense-pro.can_handle" + +local lockUwbApproachSettings = capabilities.lockUwbApproachSettings +local lockUwbModeSettings = capabilities.lockUwbModeSettings + +local enabled_optional_capabilities = { + { + "main", + { + constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS, + constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS, + } + } +} + +local mock_schlage_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition( + "lock-modular.yml", + { enabled_optional_capabilities = enabled_optional_capabilities } + ), + manufacturer_info = { + vendor_id = constants.SCHLAGE_VENDOR_ID, + product_id = constants.SCHLAGE_SENSE_PRO_PRODUCT_ID, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + { + cluster_id = constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + cluster_type = "SERVER", + cluster_revision = 1, + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.DoorLock.ID } + +local function test_init() + local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_schlage_device) + subscribe_request:merge(clusters.DoorLock.attributes.OperatingMode:subscribe(mock_schlage_device)) + subscribe_request:merge(cluster_base.subscribe(mock_schlage_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_schlage_device)) + subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_schlage_device)) + + test.mock_device.add_test_device(mock_schlage_device) + test.socket.matter:__expect_send({ mock_schlage_device.id, subscribe_request }) +end +test.set_test_init_function(test_init) + +------------------------------------------------------------------------------------- +-- Subdriver Matching Tests +------------------------------------------------------------------------------------- + +test.register_coroutine_test( + "can_handle matches Schlage Sense Pro and rejects other devices", + function() + local matched, subdriver = can_handle(nil, nil, mock_schlage_device) + assert(matched == true, "Expected can_handle to return true for Schlage Sense Pro") + assert(subdriver ~= nil, "Expected subdriver table to be returned") + assert(subdriver.NAME == "schlage-sense-pro", "Expected subdriver NAME to be schlage-sense-pro") + + local other_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-modular.yml"), + manufacturer_info = { + vendor_id = 0x115F, + product_id = 0x2802, + } + }) + local other_matched = can_handle(nil, nil, other_device) + assert(other_matched == false, "Expected can_handle to return false for non-Schlage device") + + local cluster_device = { + manufacturer_info = { + vendor_id = constants.SCHLAGE_VENDOR_ID, + product_id = 0x0001, + }, + get_endpoints = function(_, cluster_id) + if cluster_id == constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID then + return { 1 } + end + return {} + end, + } + local cluster_matched = can_handle(nil, nil, cluster_device) + assert(cluster_matched == true, "Expected can_handle to match Allegion vendor plus custom cluster") + end +) + +------------------------------------------------------------------------------------- +-- Capability Command Tests (Cloud/App -> Device) +------------------------------------------------------------------------------------- + +test.register_message_test( + "Handle lockUwbApproachSettings setApproachDirection('frontOnly')", + { + { + channel = "capability", + direction = "receive", + message = { + mock_schlage_device.id, + { + capability = constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS, + component = "main", + command = "setApproachDirection", + args = { "frontOnly" } + } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_schlage_device.id, + cluster_base.write( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_FIELD_OF_VIEW_ID, + nil, + data_types.validate_or_build_type(3, data_types.Uint8) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_schlage_device:generate_test_message( + "main", + lockUwbApproachSettings.approachDirection("frontOnly") + ) + } + } +) + +test.register_message_test( + "Handle lockUwbApproachSettings setApproachDirection('frontLeftRight')", + { + { + channel = "capability", + direction = "receive", + message = { + mock_schlage_device.id, + { + capability = constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS, + component = "main", + command = "setApproachDirection", + args = { "frontLeftRight" } + } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_schlage_device.id, + cluster_base.write( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_FIELD_OF_VIEW_ID, + nil, + data_types.validate_or_build_type(0, data_types.Uint8) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_schlage_device:generate_test_message( + "main", + lockUwbApproachSettings.approachDirection("frontLeftRight") + ) + } + } +) + +test.register_message_test( + "Handle lockUwbModeSettings setIntentMode('unlockOnArrival')", + { + { + channel = "capability", + direction = "receive", + message = { + mock_schlage_device.id, + { + capability = constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS, + component = "main", + command = "setIntentMode", + args = { "unlockOnArrival" } + } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_schlage_device.id, + cluster_base.write( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_INTENT_MODE_ID, + nil, + data_types.validate_or_build_type(1, data_types.Uint8) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_schlage_device:generate_test_message( + "main", + lockUwbModeSettings.intentMode("unlockOnArrival") + ) + } + } +) + +test.register_message_test( + "Handle lockUwbModeSettings setIntentMode('unlockOnInteraction')", + { + { + channel = "capability", + direction = "receive", + message = { + mock_schlage_device.id, + { + capability = constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS, + component = "main", + command = "setIntentMode", + args = { "unlockOnInteraction" } + } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_schlage_device.id, + cluster_base.write( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_INTENT_MODE_ID, + nil, + data_types.validate_or_build_type(2, data_types.Uint8) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_schlage_device:generate_test_message( + "main", + lockUwbModeSettings.intentMode("unlockOnInteraction") + ) + } + } +) + +------------------------------------------------------------------------------------- +-- Matter Attribute Report Tests (Device -> Hub) +------------------------------------------------------------------------------------- + +test.register_message_test( + "Handle Field of View attribute report from device (raw=2 -> frontRight)", + { + { + channel = "matter", + direction = "receive", + message = { + mock_schlage_device.id, + cluster_base.build_test_report_data( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_FIELD_OF_VIEW_ID, + data_types.Uint8(2) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_schlage_device:generate_test_message( + "main", + lockUwbApproachSettings.approachDirection("frontRight") + ) + } + } +) + +test.register_message_test( + "Handle Intent Mode attribute report from device (raw=0 -> unlockOnApproach)", + { + { + channel = "matter", + direction = "receive", + message = { + mock_schlage_device.id, + cluster_base.build_test_report_data( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_INTENT_MODE_ID, + data_types.Uint8(0) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_schlage_device:generate_test_message( + "main", + lockUwbModeSettings.intentMode("unlockOnApproach") + ) + } + } +) + +------------------------------------------------------------------------------------- +-- Refresh Command Tests +------------------------------------------------------------------------------------- + +test.register_message_test( + "Handle refresh command queries both standard lock state and UWB settings", + { + { + channel = "capability", + direction = "receive", + message = { + mock_schlage_device.id, + { + capability = capabilities.refresh.ID, + component = "main", + command = "refresh", + args = {} + } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_schlage_device.id, + clusters.DoorLock.attributes.LockState:read(mock_schlage_device) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_schlage_device.id, + cluster_base.read( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_FIELD_OF_VIEW_ID, + nil + ) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_schlage_device.id, + cluster_base.read( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_INTENT_MODE_ID, + nil + ) + } + } + } +) + +------------------------------------------------------------------------------------- +-- Lifecycle Event Tests +------------------------------------------------------------------------------------- + +test.register_coroutine_test( + "Handle doConfigure lifecycle event for Schlage Sense Pro UWB lock", + function() + mock_schlage_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + capabilities.lock.supportedLockValues( + { "locked", "unlocked", "not fully locked" }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + capabilities.lock.supportedLockCommands( + { "lock", "unlock" }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + lockUwbApproachSettings.supportedApproachDirections( + constants.SUPPORTED_APPROACH_DIRECTIONS, { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + lockUwbModeSettings.supportedIntentModes( + constants.SUPPORTED_INTENT_MODES, { visibility = { displayed = false } } + ) + ) + ) + test.socket.matter:__expect_send({ + mock_schlage_device.id, + cluster_base.read( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_FIELD_OF_VIEW_ID, + nil + ) + }) + test.socket.matter:__expect_send({ + mock_schlage_device.id, + cluster_base.read( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_INTENT_MODE_ID, + nil + ) + }) + test.socket.device_lifecycle:__queue_receive({ mock_schlage_device.id, "doConfigure" }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Handle driverSwitched lifecycle event for Schlage Sense Pro UWB lock", + function() + mock_schlage_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + capabilities.lock.supportedLockValues( + { "locked", "unlocked", "not fully locked" }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + capabilities.lock.supportedLockCommands( + { "lock", "unlock" }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + lockUwbApproachSettings.supportedApproachDirections( + constants.SUPPORTED_APPROACH_DIRECTIONS, { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_schlage_device:generate_test_message( + "main", + lockUwbModeSettings.supportedIntentModes( + constants.SUPPORTED_INTENT_MODES, { visibility = { displayed = false } } + ) + ) + ) + test.socket.matter:__expect_send({ + mock_schlage_device.id, + cluster_base.read( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_FIELD_OF_VIEW_ID, + nil + ) + }) + test.socket.matter:__expect_send({ + mock_schlage_device.id, + cluster_base.read( + mock_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_INTENT_MODE_ID, + nil + ) + }) + test.socket.device_lifecycle:__queue_receive({ mock_schlage_device.id, "driverSwitched" }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Handle driver update migration for existing lock-modular Schlage device", + function() + local mock_existing_schlage_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-modular.yml"), + manufacturer_info = { + vendor_id = constants.SCHLAGE_VENDOR_ID, + product_id = constants.SCHLAGE_SENSE_PRO_PRODUCT_ID, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + { + cluster_id = constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + cluster_type = "SERVER", + cluster_revision = 1, + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } + } + } + } + }) + + local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_existing_schlage_device) + subscribe_request:merge(clusters.DoorLock.attributes.OperatingMode:subscribe(mock_existing_schlage_device)) + subscribe_request:merge(cluster_base.subscribe(mock_existing_schlage_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_existing_schlage_device)) + subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_existing_schlage_device)) + + test.mock_device.add_test_device(mock_existing_schlage_device) + test.socket.matter:__expect_send({ mock_existing_schlage_device.id, subscribe_request }) + + mock_existing_schlage_device:expect_metadata_update({ + profile = "lock-modular", + optional_component_capabilities = { + { + "main", + { + constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS, + constants.CAPABILITY_LOCK_UWB_MODE_SETTINGS, + } + } + } + }) + test.socket.capability:__expect_send( + mock_existing_schlage_device:generate_test_message( + "main", + capabilities.lock.supportedLockValues( + { "locked", "unlocked", "not fully locked" }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_existing_schlage_device:generate_test_message( + "main", + capabilities.lock.supportedLockCommands( + { "lock", "unlock" }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.matter:__expect_send({ + mock_existing_schlage_device.id, + cluster_base.read( + mock_existing_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_FIELD_OF_VIEW_ID, + nil + ) + }) + test.socket.matter:__expect_send({ + mock_existing_schlage_device.id, + cluster_base.read( + mock_existing_schlage_device, + 1, + constants.SCHLAGE_UWB_CUSTOM_CLUSTER_ID, + constants.ATTR_INTENT_MODE_ID, + nil + ) + }) + + test.socket.device_lifecycle:__queue_receive({ mock_existing_schlage_device.id, "init" }) + test.wait_for_events() + end, + { + test_init = function() end + } +) + +test.register_coroutine_test( + "setApproachDirection does not write when no UWB endpoint exists", + function() + local device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition( + "lock-modular.yml", + { enabled_optional_capabilities = enabled_optional_capabilities } + ), + manufacturer_info = { + vendor_id = constants.SCHLAGE_VENDOR_ID, + product_id = constants.SCHLAGE_SENSE_PRO_PRODUCT_ID, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + } + } + }) + + test.mock_device.add_test_device(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + test.socket.capability:__queue_receive({ + device.id, + { + capability = constants.CAPABILITY_LOCK_UWB_APPROACH_SETTINGS, + component = "main", + command = "setApproachDirection", + args = { "frontOnly" } + } + }) + test.wait_for_events() + end, + { + test_init = function() end + } +) + +test.run_registered_tests()