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
56 changes: 56 additions & 0 deletions drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,59 @@ components:
version: 1
categories:
- name: PresenceSensor
- id: sensor1
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
- id: sensor2
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
- id: sensor3
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
- id: sensor4
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
- id: sensor5
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
- id: sensor6
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
- id: sensor7
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
- id: sensor8
capabilities:
- id: presenceSensor
version: 1
optional: true
categories:
- name: PresenceSensor
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ end

function AttributeHandlers.occupancy_measured_value_handler(driver, device, ib, response)
if device:supports_capability(capabilities.motionSensor) then
device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive())
device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive())
else
device:emit_event(ib.data.value == 0x01 and capabilities.presenceSensor.presence("present") or capabilities.presenceSensor.presence("not present"))
device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.presenceSensor.presence("present") or capabilities.presenceSensor.presence("not present"))
end
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local UpdateMetadataRequest = {}
UpdateMetadataRequest.__index = UpdateMetadataRequest

function UpdateMetadataRequest.init()
local self = setmetatable({}, UpdateMetadataRequest)
self.profile = nil
self.enabled_components = {}
return self
end

function UpdateMetadataRequest:add_capabilities_to_component(component_id, capability_ids)
if not self.enabled_components[component_id] then
self.enabled_components[component_id] = {}
end
for _, capability_id in ipairs(capability_ids) do
table.insert(self.enabled_components[component_id], capability_id)
end
return self
end

function UpdateMetadataRequest:add_profile(profile)
self.profile = profile
return self
end

function UpdateMetadataRequest:formatted_enabled_components()
local formatted_enabled_components = {}
for component_id, capability_ids in pairs(self.enabled_components) do
table.insert(formatted_enabled_components, { component_id, capability_ids })
end
return formatted_enabled_components
end

function UpdateMetadataRequest:format()
local formatted_request = {
profile = self.profile,
optional_component_capabilities = self:formatted_enabled_components()
}
return formatted_request
end

return UpdateMetadataRequest
44 changes: 44 additions & 0 deletions drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,48 @@ function utils.tbl_contains(array, value)
return false
end

--- Deeply compare two values.
--- Handles metatables. Can optionally ignore cycle checking and/or function differences.
---
--- @param a any
--- @param b any
--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean }
--- @param seen table|nil
--- @return boolean
function utils.deep_equals(a, b, opts, seen)
if a == b then return true end -- same object
if type(a) ~= type(b) then return false end -- different type
if type(a) == "function" and opts and opts.ignore_functions then return true end
if type(a) ~= "table" then return false end -- same type but not table, thus was already compared

-- check for cycles in table references and preserve reference topology.
if not (opts and opts.ignore_cycles) then
seen = seen or {}
seen[a] = seen[a] or {}
if seen[a][b] then
return seen[a][b]
end
seen[a][b] = true
end

-- Compare keys/values from a
for k, v in pairs(a) do
if not utils.deep_equals(v, b[k], opts, seen) then
return false
end
end

-- Ensure b doesn't have extra keys
for k in pairs(b) do
if a[k] == nil then
return false
end
end

-- Compare metatables
local mt_a = getmetatable(a)
local mt_b = getmetatable(b)
return utils.deep_equals(mt_a, mt_b, opts, seen)
end

return utils
Original file line number Diff line number Diff line change
Expand Up @@ -30,53 +30,37 @@ function DeviceConfiguration.supported_level_measurements(device)
return measurement_caps, level_caps
end

-- Match Modular Profile
function DeviceConfiguration.match_profile(device)
local update_metadata_request = require "sensor_utils.update_metadata_request"

local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID)
local humidity_eps = embedded_cluster_utils.get_endpoints(device, clusters.RelativeHumidityMeasurement.ID)

local optional_supported_component_capabilities = {}
local main_component_capabilities = {}
local profile_name
local MAIN_COMPONENT_IDX = 1
local CAPABILITIES_LIST_IDX = 2
local updated_metadata = update_metadata_request.init()
local preference_tags = ""

if #temp_eps > 0 then
table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID)
updated_metadata:add_capabilities_to_component("main", { capabilities.temperatureMeasurement.ID })
preference_tags = preference_tags .. "-temp"
end
if #humidity_eps > 0 then
table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID)
updated_metadata:add_capabilities_to_component("main", { capabilities.relativeHumidityMeasurement.ID })
preference_tags = preference_tags .. "-humidity"
end

local measurement_caps, level_caps = DeviceConfiguration.supported_level_measurements(device)
updated_metadata:add_capabilities_to_component("main", measurement_caps)
updated_metadata:add_capabilities_to_component("main", level_caps)

for _, cap_id in ipairs(measurement_caps) do
table.insert(main_component_capabilities, cap_id)
end

for _, cap_id in ipairs(level_caps) do
table.insert(main_component_capabilities, cap_id)
end

table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities})

if #temp_eps > 0 and #humidity_eps > 0 then
profile_name = "aqs-modular-temp-humidity"
elseif #temp_eps > 0 then
profile_name = "aqs-modular-temp"
elseif #humidity_eps > 0 then
profile_name = "aqs-modular-humidity"
else
profile_name = "aqs-modular"
end

device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities})
device:try_update_metadata(updated_metadata:add_profile("aqs-modular" .. preference_tags):format())

-- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW.
-- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher.
if version.api < 15 or version.rpc < 9 then
local MAIN_COMPONENT_IDX = 1
local CAPABILITIES_LIST_IDX = 2
-- add mandatory capabilities for subscription
local total_supported_capabilities = optional_supported_component_capabilities
local total_supported_capabilities = updated_metadata:formatted_enabled_components()
table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.airQualityHealthConcern.ID)
table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID)
table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,48 +90,4 @@ function AirQualitySensorUtils.convert_value_to_unit(value, from_unit, to_unit,
return conversion_function(value, fields.molecular_weights[capability_name])
end

--- Deeply compare two values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

since this is now being used in 2 subdrivers, I just moved this to the main utils file

--- Handles metatables. Can optionally ignore cycle checking and/or function differences.
---
--- @param a any
--- @param b any
--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean }
--- @param seen table|nil
--- @return boolean
function AirQualitySensorUtils.deep_equals(a, b, opts, seen)
if a == b then return true end -- same object
if type(a) ~= type(b) then return false end -- different type
if type(a) == "function" and opts and opts.ignore_functions then return true end
if type(a) ~= "table" then return false end -- same type but not table, thus was already compared

-- check for cycles in table references and preserve reference topology.
if not (opts and opts.ignore_cycles) then
seen = seen or {}
seen[a] = seen[a] or {}
if seen[a][b] then
return seen[a][b]
end
seen[a][b] = true
end

-- Compare keys/values from a
for k, v in pairs(a) do
if not AirQualitySensorUtils.deep_equals(v, b[k], opts, seen) then
return false
end
end

-- Ensure b doesn't have extra keys
for k in pairs(b) do
if a[k] == nil then
return false
end
end

-- Compare metatables
local mt_a = getmetatable(a)
local mt_b = getmetatable(b)
return AirQualitySensorUtils.deep_equals(mt_a, mt_b, opts, seen)
end

return AirQualitySensorUtils
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
local version = require "version"
local capabilities = require "st.capabilities"
local clusters = require "st.matter.clusters"
local sensor_utils = require "sensor_utils.utils"
local aqs_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils"
local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields"
local attribute_handlers = require "sub_drivers.air_quality_sensor.air_quality_sensor_handlers.attribute_handlers"
Expand Down Expand Up @@ -67,7 +68,7 @@ function AirQualitySensorLifecycleHandlers.device_init(driver, device)
end

function AirQualitySensorLifecycleHandlers.info_changed(driver, device, event, args)
if not aqs_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then
if not sensor_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then
if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then
--re-up subscription with new capabilities using the modular supports_capability override
device:extend_device("supports_capability_by_id", aqs_utils.supports_capability_by_id_modular)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local capabilities = require "st.capabilities"
local sensor_utils = require "sensor_utils.utils"

local COMPONENT_TO_ENDPOINT_MAP = "__COMPONENT_TO_ENDPOINT_MAP"

local function endpoint_to_component(device, endpoint_id)
local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {}
for component_id, ep_id in pairs(map) do
if ep_id == endpoint_id then return component_id end
end
return "main"
end

local function configure_profile(device)
local update_metadata_request = require "sensor_utils.update_metadata_request"

local updated_component_to_endpoint_map = {}
local updated_metadata = update_metadata_request.init()
for _, ep in ipairs(device.endpoints) do
-- >2 since EP0 is the root node, EP1 is a permanent Presence Sensor, and EP2 is a permanent Light Sensor
if ep.endpoint_id > 2 then
local component_id = string.format("sensor%d", ep.endpoint_id - 2) -- ex. sensor1, sensor2, etc. for EP3, EP4, etc.
updated_component_to_endpoint_map[component_id] = ep.endpoint_id
updated_metadata:add_capabilities_to_component(component_id, { capabilities.presenceSensor.ID })
end
end
device:set_field(COMPONENT_TO_ENDPOINT_MAP, updated_component_to_endpoint_map, { persist = true })
device:try_update_metadata(updated_metadata:add_profile("aqara-fp400"):format())
end


local Fp400LifecycleHandlers = {}

-- overwrite to avoid unnecessary metadata update calls
function Fp400LifecycleHandlers.do_configure() end
function Fp400LifecycleHandlers.do_configure(driver, device)
configure_profile(device)
end

-- overwrite to avoid unnecessary metadata update calls
function Fp400LifecycleHandlers.driver_switched(driver, device)
configure_profile(device)
device:try_update_metadata({provisioning_state = "PROVISIONED"})
end

function Fp400LifecycleHandlers.info_changed(driver, device, event, args)
if not sensor_utils.deep_equals(args.old_st_store.endpoints, device.endpoints) then
configure_profile(device)
device:subscribe()
end
end

function Fp400LifecycleHandlers.device_init(driver, device)
device:set_endpoint_to_component_fn(endpoint_to_component)
device:subscribe()
end

local aqara_fp400_handler = {
NAME = "aqara-fp400",
lifecycle_handlers = {
doConfigure = Fp400LifecycleHandlers.do_configure,
driverSwitched = Fp400LifecycleHandlers.driver_switched,
infoChanged = Fp400LifecycleHandlers.info_changed,
init = Fp400LifecycleHandlers.device_init,
},
can_handle = require("sub_drivers.aqara_fp400.can_handle"),
}
Expand Down
Loading
Loading